summaryrefslogtreecommitdiff
Commit message (Collapse)AuthorAgeFilesLines
* 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.
| * chore: bump to 3.7.0 and record Phase 2Christian Cleberg2026-07-153-26/+38
| | | | | | | | | | | | | | MARKETING_VERSION 3.6.0 -> 3.7.0, build 88 -> 89. The README feature list also picks up Phase 1's ticket editing, subscriptions, and email preferences, which it never gained.
| * feat: review patchsetsChristian Cleberg2026-07-156-1/+810
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Patchsets are how contributions reach sourcehut, and Hutch had no reference to them anywhere. This adds review and triage: read a series, see its checks and version chain, and set its status. Two schema facts shaped the design. MailingList exposes no patchsets field, so a list's patchsets cannot be queried directly. They are reachable only through thread roots, so the existing threads query now also selects root.patchset — no extra request — and the Patches tab is derived from that. It appears only on lists that actually carry patches. Patch carries no diff. index, count, version, prefix, subject, and trailers are all it has; the diff exists only inside the email body. Patch bodies are split with the same InboxThreadUtilities.segmentMessageBody the inbox uses and rendered through the existing DiffView. Patches are ordered by their [PATCH n/m] index rather than receipt order, since mail arrives out of sequence. Patches with no index are kept at the end rather than dropped, because a one-off patch has no prefix. updatePatchset is nullable, so a null response is treated as a declined change and the local status is left alone rather than advanced optimistically. UNKNOWN and SUPERSEDED are not offered: the first is a sentinel, the second is set by the server when a newer version lands. Patch submission stays out of scope. It is a git send-email flow, not a GraphQL mutation.
| * refactor: share the email body diff splitterChristian Cleberg2026-07-154-90/+223
|/ | | | | | | | | | | | | segmentMessageBody and its helpers were private to ThreadViewModel, reachable from tests only through a segmentMessageBodyForTesting shim. Patchset review needs the same splitting, because sr.ht's Patch type carries no diff — the diff only exists inside the email body — so this has to be shared rather than duplicated. Moved to InboxThreadUtilities. The shim is gone; the existing test calls the real function directly now. Also adds the Patchset model layer that the coming views build on.
* Phase 1: close the write gaps (#3)v3.6.0Christian Cleberg2026-07-1511-453/+862
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | * refactor: collapse duplicated request paths in SRHTClient Five request paths each repeated the token guard, header setup, status-code handling, and a ~35-line #if DEBUG logging block. The file carried that block five times over. Extract makeAuthorizedRequest, send, and encodedGraphQLBody, and route execute, executeAndCache, executeMultipartFiles, and performGraphQLRequest through them. executeMultipart is now the single-file case of executeMultipartFiles, which it already was byte for byte. 938 lines to 612, with one copy of the logging block. fetchText keeps its own guard: it is a GET to an allowlisted URL and must not run GraphQL error checks over what is usually a plain-text build log. One behavior change falls out. executeAndCache wrote the raw response to the cache before decoding, so a 200 carrying GraphQL errors was cached and then thrown. Routing it through performGraphQLRequest surfaces those errors first, so error payloads are no longer cached. * feat: edit and delete tickets updateTicket and deleteTicket both existed in todo.sr.ht's API but were never called, so a ticket could be filed and its status changed but its subject and body were frozen from the moment it was created, and it could never be removed. Edit opens a sheet seeded with the current subject and body. The input carries only fields that actually changed, so an edit cannot clobber a field the user did not touch, and Save stays disabled until something differs. Clearing the body sends an explicit null via updateValue rather than a nil subscript assignment, which would drop the key and silently leave the old body in place — the same trap fixed for repository descriptions in 7ffef07. Delete is destructive and irreversible, so it sits behind a confirmation dialog naming the ticket and pops the detail view on success. * feat: subscribe to and unsubscribe from tickets ticketSubscribe and ticketUnsubscribe existed in the API but were never called, so email notifications for a ticket could only be managed on the web. Ticket.subscription is null when the user is not subscribed, so the detail query now reads it and the menu reflects real server state rather than guessing. The toggle updates optimistically and reverts on failure, so the control never claims a subscription that did not take. Decoded into the private payload rather than TicketDetail, which is Codable and cached — adding a field there would have changed the cached shape and touched every optimistic-update construction site. * feat: subscribe to and unsubscribe from trackers trackerSubscribe and trackerUnsubscribe existed in the API but were never called. Tracker.subscription is null when not subscribed, so the state can be read rather than guessed. The read is a separate uncached query. The tickets query it sits beside is paginated and cached, and a per-user subscription has no business riding along in page payloads or being served stale from disk. Unsubscribe passes tickets: false, so leaving a tracker does not silently drop subscriptions to individual tickets the user opted into. * feat: unsubscribe from mailing lists mailingListUnsubscribe existed in the API but was never called, so the list of subscriptions was readable and nothing more. Scoped to unsubscribe. MailingList has no subscription field, unlike Ticket and Tracker, so per-list state is only knowable from the subscriptions query — which is exactly what builds this view. Subscribing would need a list the user is by definition not subscribed to, and sr.ht exposes no discovery API to find one (see SCOPE.md on hub.sr.ht), so there is nowhere honest to put that action yet. The row is removed optimistically and restored if the mutation fails. The confirmation says plainly that Hutch cannot resubscribe, since it cannot. * feat: manage todo and lists email preferences updatePreferences existed on both services but was never called, so these were web-only settings. The two services expose preferences/updatePreferences under identical names but with different fields — notifySelf on todo, copySelf on lists — and there is no shared preferences service, so both are read and written side by side. They load concurrently and one service being unreachable does not hide the other's toggle. These are server-side and apply beyond Hutch, unlike the @AppStorage toggles above them in Settings, so the footer says so and each toggle reverts if its mutation fails. * refactor: drop the memory-only cache path Two executeCached overloads existed with different return types and semantics: one doing stale-while-revalidate against the persistent cache with TTLs, the other only consulting the in-memory responseCache. The second was an easy thing to reach for by mistake, since the compiler picked it purely on argument labels. It turned out to be dead. All 38 call sites already used the TTL-aware overload, and the memory-only one was the sole caller of executeAndCache, so both are removed. Its doc comment promised refresh "via the onRefresh callback", which the signature has not had for some time. SRHTClient is now 569 lines, down from 938 before this branch. responseCache stays as the in-memory layer behind cachedPayload and the three view models that read it directly. * chore: bump to 3.6.0 and record Phase 1 MARKETING_VERSION 3.5.0 -> 3.6.0, build 87 -> 88. * fix: decode preferences responses on the main actor The module sets SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor, so the response types are implicitly main-actor isolated and their Decodable conformances are too. Decoding straight from an `async let` used those conformances from a nonisolated context, which warns today and is an error in the Swift 6 language mode. Move each fetch into its own method and `async let` over those instead, so decoding stays on the main actor. This is what HomeViewModel.loadDashboard already does, and the concurrency is unaffected — the network work still overlaps, since execute suspends and frees the actor.
* docs: remove stray tags from roadmapChristian Cleberg2026-07-151-2/+0
| | | | Two closing XML tags were left at the end of the file when it was written.
* Merge pull request #2 from ↵Christian Cleberg2026-07-152-3/+3
|\ | | | | | | | | zerolabsco/dependabot/swift/github.com/apple/swift-markdown-0.8.0 chore(deps): bump github.com/apple/swift-markdown from 0.7.3 to 0.8.0
| * chore(deps): bump github.com/apple/swift-markdown from 0.7.3 to 0.8.0dependabot[bot]2026-07-162-3/+3
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Bumps [github.com/apple/swift-markdown](https://github.com/apple/swift-markdown) from 0.7.3 to 0.8.0. - [Release notes](https://github.com/apple/swift-markdown/releases) - [Commits](https://github.com/apple/swift-markdown/compare/0.7.3...3c6f9523da3a1ec2fd829673e472d95b8097a3b8) --- updated-dependencies: - dependency-name: github.com/apple/swift-markdown dependency-version: 0.8.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <[email protected]>
* | Merge pull request #1 from ↵Christian Cleberg2026-07-151-2/+2
|\ \ | | | | | | | | | | | | zerolabsco/dependabot/swift/github.com/swiftlang/swift-cmark-0.8.0 chore(deps): bump github.com/swiftlang/swift-cmark from 0.7.1 to 0.8.0
| * | chore(deps): bump github.com/swiftlang/swift-cmark from 0.7.1 to 0.8.0dependabot[bot]2026-07-161-2/+2
| |/ | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Bumps [github.com/swiftlang/swift-cmark](https://github.com/swiftlang/swift-cmark) from 0.7.1 to 0.8.0. - [Release notes](https://github.com/swiftlang/swift-cmark/releases) - [Changelog](https://github.com/swiftlang/swift-cmark/blob/gfm/changelog.txt) - [Commits](https://github.com/swiftlang/swift-cmark/compare/0.7.1...924936d0427cb25a61169739a7660230bffa6ea6) --- updated-dependencies: - dependency-name: github.com/swiftlang/swift-cmark dependency-version: 0.8.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <[email protected]>
* | bump actions/checkout versionChristian Cleberg2026-07-151-1/+1
| |
* | ci: constrain GITHUB_TOKEN to contents: readChristian Cleberg2026-07-151-0/+5
|/ | | | | | | | | | The workflow set no permissions, so GITHUB_TOKEN inherited the repository default — read-write for repositories created before February 2023. Flagged by CodeQL as actions/missing-workflow-permissions (CWE-275). Checkout only reads the repo and xcodebuild uses no token, so contents: read covers the job. upload-artifact authenticates with the separate runtime token and is unaffected.
* Set package ecosystem to 'swift' for DependabotChristian Cleberg2026-07-151-0/+11
|
* docs: add roadmapv3.5.0Christian Cleberg2026-07-151-0/+112
| | | | | | Four phases ordered by dependency, with feature gaps identified by diffing the schema dumps in Docs/API against actual call sites. Records Phase 0 as done and what unblocking CI turned up.
* ci: run the test plan on macOSChristian Cleberg2026-07-151-0/+60
| | | | | | | | | | | | | The builds.sr.ht job runs on Ubuntu and can only do secret scanning and structure checks, so the 214 tests ran only when someone remembered to run them in Xcode. That is why the suite had rotted to ten failures. builds.sr.ht has no macOS image and its maintainer has ruled them out, so xcodebuild cannot run there. Add a job on the GitHub mirror instead. Pinned to macos-26 because macos-latest still points at macOS 15, which lacks the iOS 26 SDK. The simulator is resolved at runtime rather than pinned by name, since device names shift between runner images.