krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

1e3c748119c6e9eec27f02146f17ea0302ff648a

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-03-19T06:53:46Z

feat: add inbox threads and move builds under more
 Hutch/App/RootView.swift                        |  26 ++---
 Hutch/Models/Inbox.swift                        |   6 +
 Hutch/Views/Inbox/InboxView.swift               |  23 +++-
 Hutch/Views/Inbox/InboxViewModel.swift          |  76 ++++++++----
 Hutch/Views/Inbox/ThreadDetailView.swift        |  10 +-
 Hutch/Views/Inbox/ThreadViewModel.swift         | 116 ++++++++++++++-----
 Hutch/Views/Repositories/DiffView.swift         | 147 +++++++++++++++++++++++-
 Hutch/Views/Tickets/TicketDetailView.swift      |  51 +++++++-
 Hutch/Views/Tickets/TicketDetailViewModel.swift |  80 +++++++++++++
 9 files changed, 458 insertions(+), 77 deletions(-)

diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift
index 36cbe5c..042494f 100644
--- a/Hutch/App/RootView.swift
+++ b/Hutch/App/RootView.swift
@@ -66,19 +66,6 @@ struct RootView: View {
                 Label("Repositories", systemImage: "book.closed")
             }
 
-            NavigationStack(path: $buildsPath) {
-                BuildListView()
-                    // Int destination used by deep links (hutch://builds/<id>).
-                    // JobSummary destination is registered inside BuildListView.
-                    .navigationDestination(for: Int.self) { jobId in
-                        BuildDetailView(jobId: jobId)
-                    }
-            }
-            .tag(AppState.Tab.builds)
-            .tabItem {
-                Label("Builds", systemImage: "hammer")
-            }
-
             NavigationStack(path: $ticketsPath) {
                 TrackerListView()
                     // Deep link destination for jumping straight to a ticket.
@@ -96,6 +83,19 @@ struct RootView: View {
                 .tabItem {
                     Label("Settings", systemImage: "gear")
                 }
+
+            NavigationStack(path: $buildsPath) {
+                BuildListView()
+                    // Int destination used by deep links (hutch://builds/<id>).
+                    // JobSummary destination is registered inside BuildListView.
+                    .navigationDestination(for: Int.self) { jobId in
+                        BuildDetailView(jobId: jobId)
+                    }
+            }
+            .tag(AppState.Tab.builds)
+            .tabItem {
+                Label("Builds", systemImage: "hammer")
+            }
         }
         .overlay {
             if isResolvingDeepLink {
diff --git a/Hutch/Models/Inbox.swift b/Hutch/Models/Inbox.swift
index 720c71f..54e2685 100644
--- a/Hutch/Models/Inbox.swift
+++ b/Hutch/Models/Inbox.swift
@@ -178,6 +178,12 @@ enum InboxReadStateStore {
         defaults.set(dictionary, forKey: key)
     }
 
+    static func markUnread(for threadID: String, defaults: UserDefaults = .standard) {
+        var dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval] ?? [:]
+        dictionary.removeValue(forKey: threadID)
+        defaults.set(dictionary, forKey: key)
+    }
+
     static func isUnread(threadID: String, lastActivityAt: Date, defaults: UserDefaults = .standard) -> Bool {
         guard let lastViewedAt = lastViewedAt(for: threadID, defaults: defaults) else {
             return true
diff --git a/Hutch/Views/Inbox/InboxView.swift b/Hutch/Views/Inbox/InboxView.swift
index e5f3619..602f8d9 100644
--- a/Hutch/Views/Inbox/InboxView.swift
+++ b/Hutch/Views/Inbox/InboxView.swift
@@ -31,6 +31,12 @@ struct InboxView: View {
                 NavigationLink(value: thread) {
                     InboxThreadRow(thread: thread)
                 }
+                .swipeActions(edge: .leading, allowsFullSwipe: true) {
+                    readStateAction(for: thread, in: viewModel)
+                }
+                .swipeActions(edge: .trailing, allowsFullSwipe: true) {
+                    readStateAction(for: thread, in: viewModel)
+                }
             }
         }
         .listStyle(.plain)
@@ -39,7 +45,7 @@ struct InboxView: View {
                 SRHTLoadingStateView(message: "Loading inbox…")
             } else if let error = viewModel.error, viewModel.threads.isEmpty {
                 SRHTErrorStateView(
-                    title: "Couldn't Load Threads",
+                    title: "Failed to load inbox",
                     message: error,
                     retryAction: { await viewModel.loadThreads() }
                 )
@@ -64,6 +70,21 @@ struct InboxView: View {
             }
         }
     }
+
+    @ViewBuilder
+    private func readStateAction(for thread: InboxThreadSummary, in viewModel: InboxViewModel) -> some View {
+        Button {
+            withAnimation(.easeInOut(duration: 0.2)) {
+                viewModel.toggleThreadReadState(thread)
+            }
+        } label: {
+            Label(
+                thread.isUnread ? "Mark as Read" : "Mark as Unread",
+                systemImage: thread.isUnread ? "envelope.open" : "envelope.badge"
+            )
+        }
+        .tint(thread.isUnread ? .blue : .gray)
+    }
 }
 
 private struct InboxThreadRow: View {
diff --git a/Hutch/Views/Inbox/InboxViewModel.swift b/Hutch/Views/Inbox/InboxViewModel.swift
index 808a5ef..9211b40 100644
--- a/Hutch/Views/Inbox/InboxViewModel.swift
+++ b/Hutch/Views/Inbox/InboxViewModel.swift
@@ -134,33 +134,34 @@ final class InboxViewModel {
                 return lhs.lastActivityAt > rhs.lastActivityAt
             }
         } catch {
-            threads = []
-            self.error = error.localizedDescription
+            inboxListLogger.error("Inbox request failed: type=inbox error=\(error.localizedDescription, privacy: .public)")
+            self.error = "Failed to load inbox"
         }
     }
 
     func markThreadRead(_ thread: InboxThreadSummary) {
         let viewedAt = max(Date(), thread.lastActivityAt)
         InboxReadStateStore.markViewed(viewedAt, for: thread.id)
-        guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return }
-        let current = threads[index]
-        threads[index] = InboxThreadSummary(
-            rootEmailID: current.rootEmailID,
-            rootMessageID: current.rootMessageID,
-            threadRootEmailIDs: current.threadRootEmailIDs,
-            threadRootMessageIDs: current.threadRootMessageIDs,
-            listID: current.listID,
-            listRID: current.listRID,
-            listName: current.listName,
-            listOwner: current.listOwner,
-            subject: current.subject,
-            latestSender: current.latestSender,
-            lastActivityAt: current.lastActivityAt,
-            messageCount: current.messageCount,
-            repo: current.repo,
-            containsPatch: current.containsPatch,
-            isUnread: false
+        inboxListLogger.debug(
+            "Inbox mark read: key=\(thread.id, privacy: .public) latestActivityAt=\(thread.lastActivityAt.ISO8601Format(), privacy: .public) storedLastViewedAt=\(viewedAt.ISO8601Format(), privacy: .public)"
         )
+        updateThread(thread, isUnread: false)
+    }
+
+    func markThreadUnread(_ thread: InboxThreadSummary) {
+        InboxReadStateStore.markUnread(for: thread.id)
+        inboxListLogger.debug(
+            "Inbox mark unread: key=\(thread.id, privacy: .public) latestActivityAt=\(thread.lastActivityAt.ISO8601Format(), privacy: .public) storedLastViewedAt=nil"
+        )
+        updateThread(thread, isUnread: true)
+    }
+
+    func toggleThreadReadState(_ thread: InboxThreadSummary) {
+        if thread.isUnread {
+            markThreadRead(thread)
+        } else {
+            markThreadUnread(thread)
+        }
     }
 
     private func fetchSubscriptions() async throws -> [InboxActivitySubscription] {
@@ -211,7 +212,7 @@ final class InboxViewModel {
                         do {
                             return (try await self.fetchThreads(for: mailingList), nil)
                         } catch {
-                            return ([], error.localizedDescription)
+                            return ([], "rid=\(mailingList.rid) error=\(error.localizedDescription)")
                         }
                     }
                 }
@@ -229,6 +230,9 @@ final class InboxViewModel {
 
             summaries.append(contentsOf: batchResult.0)
             failureMessages.append(contentsOf: batchResult.1)
+            for failure in batchResult.1 {
+                inboxListLogger.error("Inbox request failed: type=listThreads \(failure, privacy: .public)")
+            }
             startIndex = endIndex
         }
 
@@ -248,11 +252,15 @@ final class InboxViewModel {
         )
 
         return response.list.threads.results.prefix(listThreadFetchLimit).map { thread in
-            let threadID = "\(mailingList.rid)#\(thread.root.messageID)"
             let groupingKey = "\(mailingList.rid)#\(thread.subject.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression).trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive]).lowercased())"
+            let lastViewedAt = InboxReadStateStore.lastViewedAt(for: groupingKey)
+            let isUnread = InboxReadStateStore.isUnread(threadID: groupingKey, lastActivityAt: thread.updated)
             inboxListLogger.debug(
                 "Inbox thread grouping candidate: listRID=\(mailingList.rid, privacy: .public) rootMessageID=\(thread.root.messageID, privacy: .public) rootEmailID=\(thread.root.id, privacy: .public) groupingKey=\(groupingKey, privacy: .public)"
             )
+            inboxListLogger.debug(
+                "Inbox unread state: key=\(groupingKey, privacy: .public) latestActivityAt=\(thread.updated.ISO8601Format(), privacy: .public) lastViewedAt=\(lastViewedAt?.ISO8601Format() ?? "nil", privacy: .public) isUnread=\(isUnread, privacy: .public)"
+            )
             return InboxThreadSummary(
                 rootEmailID: thread.root.id,
                 rootMessageID: thread.root.messageID,
@@ -268,7 +276,7 @@ final class InboxViewModel {
                 messageCount: thread.replies + 1,
                 repo: Self.deriveRepositoryName(from: mailingList.name),
                 containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
-                isUnread: InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated)
+                isUnread: isUnread
             )
         }
     }
@@ -318,6 +326,28 @@ final class InboxViewModel {
         }
     }
 
+    private func updateThread(_ thread: InboxThreadSummary, isUnread: Bool) {
+        guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return }
+        let current = threads[index]
+        threads[index] = InboxThreadSummary(
+            rootEmailID: current.rootEmailID,
+            rootMessageID: current.rootMessageID,
+            threadRootEmailIDs: current.threadRootEmailIDs,
+            threadRootMessageIDs: current.threadRootMessageIDs,
+            listID: current.listID,
+            listRID: current.listRID,
+            listName: current.listName,
+            listOwner: current.listOwner,
+            subject: current.subject,
+            latestSender: current.latestSender,
+            lastActivityAt: current.lastActivityAt,
+            messageCount: current.messageCount,
+            repo: current.repo,
+            containsPatch: current.containsPatch,
+            isUnread: isUnread
+        )
+    }
+
     private func deduplicateMailingLists(_ mailingLists: [InboxMailingListReference]) -> [InboxMailingListReference] {
         var seen = Set<String>()
         return mailingLists.filter { mailingList in
diff --git a/Hutch/Views/Inbox/ThreadDetailView.swift b/Hutch/Views/Inbox/ThreadDetailView.swift
index 3c42e7f..7677698 100644
--- a/Hutch/Views/Inbox/ThreadDetailView.swift
+++ b/Hutch/Views/Inbox/ThreadDetailView.swift
@@ -93,6 +93,14 @@ struct ThreadDetailView: View {
                     .padding(.vertical, 4)
                 }
 
+                if let partialWarning = viewModel.partialWarning {
+                    Section {
+                        Text(partialWarning)
+                            .font(.caption)
+                            .foregroundStyle(.secondary)
+                    }
+                }
+
                 ForEach(thread.messages) { message in
                     InboxMessageRow(message: message)
                 }
@@ -111,7 +119,7 @@ struct ThreadDetailView: View {
                 SRHTLoadingStateView(message: "Loading thread…")
             } else if let error = viewModel.error, viewModel.thread == nil {
                 SRHTErrorStateView(
-                    title: "Couldn't Load Thread",
+                    title: "Failed to load thread",
                     message: error,
                     retryAction: { await viewModel.loadThread() }
                 )
diff --git a/Hutch/Views/Inbox/ThreadViewModel.swift b/Hutch/Views/Inbox/ThreadViewModel.swift
index 10a5b59..c042fba 100644
--- a/Hutch/Views/Inbox/ThreadViewModel.swift
+++ b/Hutch/Views/Inbox/ThreadViewModel.swift
@@ -61,6 +61,7 @@ final class ThreadViewModel {
     private(set) var thread: InboxThreadDetail?
     private(set) var isLoading = false
     var error: String?
+    var partialWarning: String?
     var composeDraft: MailComposeDraft?
 
     private let summary: InboxThreadSummary
@@ -168,6 +169,7 @@ final class ThreadViewModel {
         guard !isLoading else { return }
         isLoading = true
         error = nil
+        partialWarning = nil
         defer { isLoading = false }
 
         inboxLogger.debug("Opening inbox thread: \(self.summary.debugIdentifierSummary, privacy: .public)")
@@ -187,18 +189,27 @@ final class ThreadViewModel {
             )
             var messagesByID: [Int: InboxMessage] = [:]
 
+            var hadPartialReplyFailure = false
+
             for payload in threadPayloads {
                 guard let rootMessage = Self.message(from: payload.root, fallbackID: summary.rootEmailID) else {
                     continue
                 }
                 messagesByID[rootMessage.id] = rootMessage
 
-                let descendantMessages = try await fetchAllDescendantMessages(
-                    initialPayload: payload,
-                    candidateMessageIDs: Self.messageIDCandidates(from: payload.root?.messageID ?? summary.rootMessageID)
-                )
-                for message in descendantMessages {
-                    messagesByID[message.id] = message
+                do {
+                    let descendantMessages = try await fetchAllDescendantMessages(
+                        initialPayload: payload,
+                        candidateMessageIDs: Self.messageIDCandidates(from: payload.root?.messageID ?? summary.rootMessageID)
+                    )
+                    for message in descendantMessages {
+                        messagesByID[message.id] = message
+                    }
+                } catch {
+                    hadPartialReplyFailure = true
+                    inboxLogger.error(
+                        "Inbox thread descendants failed for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)"
+                    )
                 }
             }
 
@@ -223,9 +234,15 @@ final class ThreadViewModel {
                 messageCount: max(messages.count, summary.messageCount ?? 0),
                 messages: messages
             )
+            if hadPartialReplyFailure {
+                partialWarning = "Some replies could not be loaded."
+            }
         } catch {
-            thread = nil
-            self.error = error.localizedDescription
+            if thread == nil {
+                self.error = "Failed to load thread"
+            } else {
+                self.error = error.localizedDescription
+            }
             inboxLogger.error("Inbox thread detail failed for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)")
         }
     }
@@ -318,18 +335,27 @@ final class ThreadViewModel {
                 variables["cursor"] = threadCursor
             }
 
-            let response: InboxThreadDetailResponse = try await Self.executeGraphQLRequest(
-                client: client,
-                query: Self.threadDetailQuery,
-                variables: {
-                    var variables = variables
-                    variables["descCursor"] = nil as String?
-                    return variables
-                }()
-            )
+            let response: InboxThreadDetailResponse
+            do {
+                response = try await Self.executeGraphQLRequest(
+                    client: client,
+                    query: Self.threadDetailQuery,
+                    variables: {
+                        var variables = variables
+                        variables["descCursor"] = nil as String?
+                        return variables
+                    }()
+                )
+            } catch {
+                if Self.isRecoverableNoRows(error) {
+                    inboxLogger.error("Inbox thread page scan recoverable miss for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)")
+                    return nil
+                }
+                throw error
+            }
 
             guard let threadPage = response.list?.threads else {
-                throw SRHTError.graphQLErrors([GraphQLError(message: "Thread is no longer available.", locations: nil)])
+                return nil
             }
 
             let candidates = threadPage.results.map { payload in
@@ -389,15 +415,26 @@ final class ThreadViewModel {
         candidateMessageIDs: [String]
     ) async throws -> InboxThreadMessagesPage? {
         for messageID in candidateMessageIDs {
-            let response: InboxThreadLookupResponse = try await Self.executeGraphQLRequest(
-                client: client,
-                query: Self.threadByMessageIDQuery,
-                variables: [
-                    "rid": summary.listRID,
-                    "messageID": messageID,
-                    "descCursor": cursor
-                ]
-            )
+            let response: InboxThreadLookupResponse
+            do {
+                response = try await Self.executeGraphQLRequest(
+                    client: client,
+                    query: Self.threadByMessageIDQuery,
+                    variables: [
+                        "rid": summary.listRID,
+                        "messageID": messageID,
+                        "descCursor": cursor
+                    ]
+                )
+            } catch {
+                if Self.isRecoverableNoRows(error) {
+                    inboxLogger.error(
+                        "Inbox descendant page recoverable miss: thread=\(self.summary.debugIdentifierSummary, privacy: .public) messageID=\(messageID, privacy: .public) error=\(error.localizedDescription, privacy: .public)"
+                    )
+                    continue
+                }
+                throw error
+            }
 
             if let descendants = response.list?.message?.thread?.descendants {
                 return descendants
@@ -533,7 +570,8 @@ final class ThreadViewModel {
     }
 
     private static func sanitizedDisplayBody(from body: String) -> String {
-        let lines = body.components(separatedBy: .newlines)
+        let normalizedBody = normalizeLineEndings(in: body)
+        let lines = normalizedBody.components(separatedBy: "\n")
         let headerPrefixes = ["From:", "Date:", "To:", "Cc:", "Subject:"]
         var headerCount = 0
         var blankLineIndex: Int?
@@ -551,7 +589,7 @@ final class ThreadViewModel {
         }
 
         guard headerCount >= 2, let blankLineIndex else {
-            return body
+            return stripLeadingFromLineIfPresent(in: normalizedBody)
         }
 
         return lines.dropFirst(blankLineIndex + 1).joined(separator: "\n")
@@ -644,6 +682,19 @@ final class ThreadViewModel {
             .replacingOccurrences(of: "\r", with: "\n")
     }
 
+    private static func stripLeadingFromLineIfPresent(in body: String) -> String {
+        let lines = body.components(separatedBy: "\n")
+        guard let firstLine = lines.first, firstLine.hasPrefix("From:") else {
+            return body
+        }
+
+        var remainingLines = Array(lines.dropFirst())
+        if let nextLine = remainingLines.first, nextLine.isEmpty {
+            remainingLines.removeFirst()
+        }
+        return remainingLines.joined(separator: "\n")
+    }
+
     private static func leadingHeaderValue(named headerName: String, in body: String) -> String? {
         let prefix = "\(headerName):"
         let lines = body.components(separatedBy: .newlines)
@@ -699,4 +750,11 @@ final class ThreadViewModel {
         }
         return payload
     }
+
+    private static func isRecoverableNoRows(_ error: Error) -> Bool {
+        guard case let SRHTError.graphQLErrors(errors) = error else {
+            return false
+        }
+        return errors.allSatisfy { $0.message.localizedCaseInsensitiveContains("no rows in result set") }
+    }
 }
diff --git a/Hutch/Views/Repositories/DiffView.swift b/Hutch/Views/Repositories/DiffView.swift
index 4380e22..4b8e512 100644
--- a/Hutch/Views/Repositories/DiffView.swift
+++ b/Hutch/Views/Repositories/DiffView.swift
@@ -9,8 +9,73 @@ struct DiffView: View {
     let diff: String
 
     var body: some View {
-        let lines = normalizedDiff.components(separatedBy: "\n")
+        VStack(alignment: .leading, spacing: 12) {
+            ForEach(fileSections) { section in
+                DiffFileSectionView(section: section)
+            }
+        }
+    }
+
+    private var fileSections: [DiffFileSection] {
+        DiffFileSection.parse(from: normalizedDiff)
+    }
 
+    private var normalizedDiff: String {
+        diff
+            .replacingOccurrences(of: "\r\n", with: "\n")
+            .replacingOccurrences(of: "\r", with: "\n")
+    }
+}
+
+private struct DiffFileSectionView: View {
+    let section: DiffFileSection
+    @State private var isExpanded = true
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 0) {
+            Button {
+                isExpanded.toggle()
+            } label: {
+                HStack(spacing: 10) {
+                    Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
+                        .font(.caption.weight(.semibold))
+                        .foregroundStyle(.secondary)
+                        .frame(width: 12)
+
+                    Text(section.filename)
+                        .font(.subheadline.weight(.semibold))
+                        .foregroundStyle(.primary)
+                        .lineLimit(1)
+
+                    Spacer(minLength: 8)
+
+                    Text(section.changeSummary)
+                        .font(.caption.weight(.medium))
+                        .foregroundStyle(.secondary)
+                }
+                .padding(.horizontal, 10)
+                .padding(.vertical, 8)
+                .contentShape(Rectangle())
+            }
+            .buttonStyle(.plain)
+            .background(Color(.tertiarySystemBackground))
+
+            if isExpanded {
+                DiffBlockView(lines: section.lines)
+            }
+        }
+        .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
+        .overlay {
+            RoundedRectangle(cornerRadius: 8, style: .continuous)
+                .strokeBorder(Color.primary.opacity(0.06))
+        }
+    }
+}
+
+private struct DiffBlockView: View {
+    let lines: [String]
+
+    var body: some View {
         VStack(alignment: .leading, spacing: 0) {
             ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
                 DiffLineView(line: line)
@@ -19,13 +84,83 @@ struct DiffView: View {
         .font(.system(.caption, design: .monospaced))
         .frame(maxWidth: .infinity, alignment: .leading)
         .background(Color(.secondarySystemBackground))
-        .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
     }
+}
 
-    private var normalizedDiff: String {
-        diff
-            .replacingOccurrences(of: "\r\n", with: "\n")
-            .replacingOccurrences(of: "\r", with: "\n")
+private struct DiffFileSection: Identifiable {
+    let id: String
+    let filename: String
+    let lines: [String]
+    let additions: Int
+    let deletions: Int
+
+    var changeSummary: String {
+        "+\(additions)  -\(deletions)"
+    }
+
+    static func parse(from diff: String) -> [DiffFileSection] {
+        let lines = diff.components(separatedBy: "\n")
+        guard !lines.isEmpty else { return [] }
+
+        let boundaries = lines.enumerated().compactMap { index, line in
+            line.hasPrefix("diff --git ") ? index : nil
+        }
+
+        guard !boundaries.isEmpty else {
+            let section = makeSection(lines: lines, fallbackIndex: 0)
+            return section.lines.isEmpty ? [] : [section]
+        }
+
+        var sections: [DiffFileSection] = []
+        for (position, startIndex) in boundaries.enumerated() {
+            let endIndex = position + 1 < boundaries.count ? boundaries[position + 1] : lines.count
+            let sectionLines = Array(lines[startIndex..<endIndex])
+            let section = makeSection(lines: sectionLines, fallbackIndex: position)
+            if !section.lines.isEmpty {
+                sections.append(section)
+            }
+        }
+        return sections
+    }
+
+    private static func makeSection(lines: [String], fallbackIndex: Int) -> DiffFileSection {
+        let filename = fileName(from: lines) ?? "File \(fallbackIndex + 1)"
+        let additions = lines.filter { $0.hasPrefix("+") && !$0.hasPrefix("+++") }.count
+        let deletions = lines.filter { $0.hasPrefix("-") && !$0.hasPrefix("---") }.count
+        return DiffFileSection(
+            id: "\(fallbackIndex)-\(filename)",
+            filename: filename,
+            lines: lines,
+            additions: additions,
+            deletions: deletions
+        )
+    }
+
+    private static func fileName(from lines: [String]) -> String? {
+        if let diffHeader = lines.first(where: { $0.hasPrefix("diff --git ") }) {
+            let parts = diffHeader.split(separator: " ")
+            if let rhs = parts.last, rhs.hasPrefix("b/") {
+                return String(rhs.dropFirst(2))
+            }
+        }
+
+        if let plusHeader = lines.first(where: { $0.hasPrefix("+++ ") }) {
+            let path = String(plusHeader.dropFirst(4))
+            if path.hasPrefix("b/") {
+                return String(path.dropFirst(2))
+            }
+            return path
+        }
+
+        if let minusHeader = lines.first(where: { $0.hasPrefix("--- ") }) {
+            let path = String(minusHeader.dropFirst(4))
+            if path.hasPrefix("a/") {
+                return String(path.dropFirst(2))
+            }
+            return path
+        }
+
+        return nil
     }
 }
 
diff --git a/Hutch/Views/Tickets/TicketDetailView.swift b/Hutch/Views/Tickets/TicketDetailView.swift
index f553bd8..5ca8618 100644
--- a/Hutch/Views/Tickets/TicketDetailView.swift
+++ b/Hutch/Views/Tickets/TicketDetailView.swift
@@ -132,7 +132,7 @@ struct TicketDetailView: View {
             ScrollView {
                 VStack(alignment: .leading, spacing: 0) {
                     // Header
-                    ticketHeader(ticket)
+                    ticketHeader(ticket, viewModel: viewModel)
 
                     Divider()
                         .padding(.vertical, 12)
@@ -186,10 +186,16 @@ struct TicketDetailView: View {
     // MARK: - Header
 
     @ViewBuilder
-    private func ticketHeader(_ ticket: TicketDetail) -> some View {
+    private func ticketHeader(_ ticket: TicketDetail, viewModel: TicketDetailViewModel) -> some View {
         VStack(alignment: .leading, spacing: 8) {
-            Text(ticket.title)
-                .font(.title3.weight(.semibold))
+            HStack(alignment: .top, spacing: 12) {
+                Text(ticket.title)
+                    .font(.title3.weight(.semibold))
+
+                Spacer(minLength: 12)
+
+                assignToMeButton(ticket: ticket, viewModel: viewModel)
+            }
 
             HStack(spacing: 8) {
                 TicketStatusIcon(status: ticket.status)
@@ -235,6 +241,43 @@ struct TicketDetailView: View {
         .padding()
     }
 
+    @ViewBuilder
+    private func assignToMeButton(ticket: TicketDetail, viewModel: TicketDetailViewModel) -> some View {
+        if let currentUser = appState.currentUser {
+            let isAssignedToCurrentUser = ticket.assignees.contains {
+                TicketDetailViewModel.matchesAssignee($0, user: currentUser)
+            }
+
+            if isAssignedToCurrentUser {
+                Label("Assigned to you", systemImage: "checkmark.circle.fill")
+                    .font(.caption.weight(.medium))
+                    .foregroundStyle(.secondary)
+                    .padding(.horizontal, 10)
+                    .padding(.vertical, 6)
+                    .background(Color(.secondarySystemFill), in: Capsule())
+            } else {
+                Button {
+                    Task {
+                        await viewModel.assignToCurrentUser(currentUser)
+                    }
+                } label: {
+                    if viewModel.isPerformingAction {
+                        ProgressView()
+                            .controlSize(.small)
+                            .frame(minWidth: 88)
+                    } else {
+                        Text("Assign to Me")
+                            .font(.caption.weight(.semibold))
+                            .frame(minWidth: 88)
+                    }
+                }
+                .buttonStyle(.borderedProminent)
+                .controlSize(.small)
+                .disabled(viewModel.isPerformingAction)
+            }
+        }
+    }
+
     // MARK: - Comment Input
 
     @ViewBuilder
diff --git a/Hutch/Views/Tickets/TicketDetailViewModel.swift b/Hutch/Views/Tickets/TicketDetailViewModel.swift
index 90e33aa..660715b 100644
--- a/Hutch/Views/Tickets/TicketDetailViewModel.swift
+++ b/Hutch/Views/Tickets/TicketDetailViewModel.swift
@@ -433,6 +433,64 @@ final class TicketDetailViewModel {
         isPerformingAction = false
     }
 
+    func assignToCurrentUser(_ user: User) async {
+        guard !isPerformingAction, let currentTicket = ticket else { return }
+
+        let currentAssignees = currentTicket.assignees
+        let currentEntity = Entity(canonicalName: user.canonicalName)
+        guard !currentAssignees.contains(where: { Self.matchesAssignee($0, user: user) }) else {
+            return
+        }
+
+        isPerformingAction = true
+        error = nil
+
+        ticket = TicketDetail(
+            id: currentTicket.id,
+            created: currentTicket.created,
+            updated: currentTicket.updated,
+            title: currentTicket.title,
+            description: currentTicket.description,
+            status: currentTicket.status,
+            resolution: currentTicket.resolution,
+            authenticity: currentTicket.authenticity,
+            submitter: currentTicket.submitter,
+            assignees: currentAssignees + [currentEntity],
+            labels: currentTicket.labels
+        )
+
+        do {
+            _ = try await client.execute(
+                service: .todo,
+                query: Self.assignUserMutation,
+                variables: [
+                    "trackerId": trackerId,
+                    "ticketId": ticketId,
+                    "userId": user.id
+                ],
+                responseType: AssignUserResponse.self
+            )
+            await loadTicket()
+        } catch {
+            ticket = TicketDetail(
+                id: currentTicket.id,
+                created: currentTicket.created,
+                updated: currentTicket.updated,
+                title: currentTicket.title,
+                description: currentTicket.description,
+                status: currentTicket.status,
+                resolution: currentTicket.resolution,
+                authenticity: currentTicket.authenticity,
+                submitter: currentTicket.submitter,
+                assignees: currentAssignees,
+                labels: currentTicket.labels
+            )
+            self.error = error.localizedDescription
+        }
+
+        isPerformingAction = false
+    }
+
     func unassignUser(username: String) async {
         guard !isPerformingAction else { return }
         isPerformingAction = true
@@ -558,4 +616,26 @@ final class TicketDetailViewModel {
         isPerformingAction = false
     }
 
+    static func matchesAssignee(_ entity: Entity, user: User) -> Bool {
+        let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
+        let userCanonical = normalizedCanonicalName(user.canonicalName)
+        if assigneeCanonical == userCanonical {
+            return true
+        }
+        return normalizedUsername(entity.canonicalName) == normalizedUsername(user.username)
+    }
+
+    private static func normalizedCanonicalName(_ value: String) -> String {
+        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+        if trimmed.hasPrefix("~") {
+            return trimmed
+        }
+        return "~\(trimmed)"
+    }
+
+    private static func normalizedUsername(_ value: String) -> String {
+        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+        return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
+    }
+
 }