| Commit message (Collapse) | Author | Age | Files | Lines |
| | |
|
| | |
|
| |
|
|
|
|
| |
- 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
|
| |
|
|
| |
The 3.8.3 train is closed for new submissions on App Store Connect.
|
| | |
|
| | |
|
| |\
| |
| | |
Remove Splash syntax highlighter dependency
|
| |/
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| | |
|
| |
|
|
|
|
| |
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.
|
| |\
| |
| | |
Move Home system status into a title-bar status badge
|
| |/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |\
| |
| | |
docs: note dual-forge issue/patch tracking in README
|
| |/
|
|
|
| |
Hutch is hosted on both GitHub and sourcehut as synced peers; tell
contributors they can file issues or send patches on either forge.
|
| |\
| |
| | |
v3.8.1: SonarCloud triage, forceRefresh fix, housekeeping
|
| | |
| |
| |
| |
| |
| |
| |
| | |
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.
|
| | |
| |
| |
| |
| |
| | |
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.
|
| | |
| |
| |
| |
| |
| | |
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.
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
- 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).
|
| | |
| |
| |
| |
| |
| |
| |
| | |
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.
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
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.
|
| |\ \
| |/
|/| |
ci: drop the redundant post-merge test run
|
| |/
|
|
|
|
|
|
| |
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.
|
| | |
|
| |
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |\
| |
| | |
Phase 3: API features
|
| | |
| |
| |
| |
| | |
It did its job: tags=1 confirmed the menu was disabled by its own tags check
while the explanation sat unreachable inside it.
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
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.
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
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.
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
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.
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
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.
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
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.
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
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.
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
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.
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
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.
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
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.
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
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.
|
| |/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |\
| |
| | |
Start a new account at zero unread
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
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.
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
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.
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
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.
|
| |\ \
| |/
|/| |
Phase 2: patchset review
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
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.
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
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.
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
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.
|