summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-03-19 01:53:46 -0500
committerChristian Cleberg <[email protected]>2026-03-19 01:53:46 -0500
commit1e3c748119c6e9eec27f02146f17ea0302ff648a (patch)
tree6a5a7c6d22a9022b1b3daa3710768d12981ca0ed
parent441b69afae30ee6b662be38004fd7b5de1e47302 (diff)
downloadhutch-1e3c748119c6e9eec27f02146f17ea0302ff648a.tar.gz
hutch-1e3c748119c6e9eec27f02146f17ea0302ff648a.tar.bz2
hutch-1e3c748119c6e9eec27f02146f17ea0302ff648a.zip
feat: add inbox threads and move builds under more
-rw-r--r--Hutch/App/RootView.swift26
-rw-r--r--Hutch/Models/Inbox.swift6
-rw-r--r--Hutch/Views/Inbox/InboxView.swift23
-rw-r--r--Hutch/Views/Inbox/InboxViewModel.swift76
-rw-r--r--Hutch/Views/Inbox/ThreadDetailView.swift10
-rw-r--r--Hutch/Views/Inbox/ThreadViewModel.swift116
-rw-r--r--Hutch/Views/Repositories/DiffView.swift147
-rw-r--r--Hutch/Views/Tickets/TicketDetailView.swift51
-rw-r--r--Hutch/Views/Tickets/TicketDetailViewModel.swift80
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
+ }
+
}