Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Docs/Development.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ Sources/Workspace/
NewRepository.swift making one instead of finding one: `git init`, `git clone`
Project.swift one repository: remote, status, PRs, ports, tree
PullRequest.swift unified PR model + gh/bkt loaders
PullRequestComment.swift reading and posting PR comments
PullRequestComment.swift reading, posting and rewriting PR comments
PullRequestCommit.swift a PR's commits + the diff of one commit
PullRequestActions.swift merge, reject, and drift from the target branch
PullRequestReviewer.swift who is reviewing, their verdicts, and asking more people
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ missing ones, so you never have to work out which command it wanted.
| Read it all | Details, Diff and Commits tabs. Markdown, images and mentions rendered properly. |
| Threads | Replies nested under what they answer, every author in a colour of their own. A resolved one folds to a single row — author, file and first line, under a green **Resolved** chip — in the conversation and in the diff alike; click it to read the rest. |
| Write back | Comment, reply, resolve a thread or open it again, edit the description, approve, request changes, merge, reject. |
| Second thoughts | **Edit** on a comment of your own — in the conversation or on a line of the diff — reopens it in place, wherever the host lets you change it. |
| Reviewers | See who approved, add more from a searchable list of the people who can be asked. |
| `@` anyone | Type `@` in any comment box and pick a name from the repo. |
| Builds beside you | CI runs for the head commit in a side panel, failures first, kept current on their own while the panel is in front of you. |
Expand Down
3 changes: 3 additions & 0 deletions Sources/Workspace/Models/PullRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ enum PullRequestError: LocalizedError {
case commandFailed(String)
case unsupportedHost
case replyUnsupported
case editUnsupported

var errorDescription: String? {
switch self {
Expand All @@ -160,6 +161,8 @@ enum PullRequestError: LocalizedError {
"This repository's remote is neither GitHub nor Bitbucket."
case .replyUnsupported:
"This host cannot thread a reply onto that comment."
case .editUnsupported:
"That comment cannot be edited from here."
}
}
}
Expand Down
374 changes: 345 additions & 29 deletions Sources/Workspace/Models/PullRequestComment.swift

Large diffs are not rendered by default.

29 changes: 29 additions & 0 deletions Sources/Workspace/Models/WorkspaceStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1613,6 +1613,35 @@ final class WorkspaceStore {
item.isPostingComment = false
}

/// Replaces what an already-posted comment says.
///
/// The conversation is read back for the same reason a resolve reads it
/// back: the host's copy is the one that counts, and it is what renders the
/// edited comment — including the "edited" mark a host may add.
func updateComment(
_ body: String,
of comment: PullRequestComment,
on item: ViewerItem,
project: Project,
pr: PullRequest
) async {
item.isPostingComment = true
item.commentError = nil
do {
try await PullRequestService.updateComment(
mentioning(body, on: item, pr: pr),
of: comment,
on: pr,
in: project.url
)
showStatus("Comment updated on #\(pr.number)")
await loadComments(item, project: project, pr: pr)
} catch {
item.commentError = error.localizedDescription
}
item.isPostingComment = false
}

/// Settles a comment thread, or opens it again. The conversation is read
/// back afterwards rather than edited in place: resolution is the host's
/// word, and a thread someone else has already touched should come back
Expand Down
9 changes: 8 additions & 1 deletion Sources/Workspace/Views/DiffView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ struct DiffView: View {
/// The comment whose reply box is open, shared by every thread on screen so
/// that opening one closes the last.
@State private var replyingTo: PullRequestComment?
/// The comment being rewritten, shared the same way for the same reason.
@State private var editing: PullRequestComment?
/// The diff flattened to one entry per visible line — see `DiffElement`.
/// Cached rather than recomputed in `body`, which also runs on every frame
/// of a window resize.
Expand Down Expand Up @@ -349,11 +351,13 @@ struct DiffView: View {
threads: threads(file: file, row: row),
isPosting: comments?.isPosting ?? false,
replyingTo: $replyingTo,
editing: $editing,
mentions: comments?.mentions ?? .none,
onReply: { parent, body in await comments?.reply(parent, body) },
// Flattened: chaining through an optional bundle onto an
// optional closure would otherwise nest one inside the other.
onResolve: comments?.resolve ?? nil
onResolve: comments?.resolve ?? nil,
onEdit: comments?.edit ?? nil
)
.frame(width: width, alignment: .leading)
case .composer:
Expand Down Expand Up @@ -383,6 +387,7 @@ struct DiffView: View {
guard let anchor = row.anchor(in: file, side: side) else { return }
composing = composing == anchor ? nil : anchor
replyingTo = nil
editing = nil
}
}

Expand Down Expand Up @@ -410,6 +415,8 @@ struct DiffComments {
/// Settles a thread, or opens it again. Nil for a diff that has no host
/// behind it to be told.
var resolve: ((PullRequestComment, Bool) async -> Void)?
/// Replaces what a comment says. Nil for the same reason.
var edit: ((PullRequestComment, String) async -> Void)?
}

extension DiffRow {
Expand Down
42 changes: 36 additions & 6 deletions Sources/Workspace/Views/InlineCommentViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,12 @@ struct DiffCommentThreads: View {
let threads: [PullRequestCommentNode]
let isPosting: Bool
@Binding var replyingTo: PullRequestComment?
/// The comment whose text is being rewritten, if any.
@Binding var editing: PullRequestComment?
var mentions: MentionSource = .none
let onReply: (PullRequestComment, String) async -> Void
var onResolve: ((PullRequestComment, Bool) async -> Void)?
var onEdit: ((PullRequestComment, String) async -> Void)?

var body: some View {
VStack(alignment: .leading, spacing: 8) {
Expand All @@ -46,12 +49,14 @@ struct DiffCommentThreads: View {
node: thread,
depth: 0,
replyingTo: $replyingTo,
editing: $editing,
isPosting: isPosting,
mentions: mentions,
// The line it hangs under already says which file this is.
isInline: true,
onReply: onReply,
onResolve: onResolve
onResolve: onResolve,
onEdit: onEdit
)
}
}
Expand Down Expand Up @@ -202,18 +207,43 @@ struct DiffCommentComposer: View {
}
}

/// A text box with Cancel and a send button. Used for replies in a thread and
/// for new comments on a line, which differ only in their wording.
/// A text box with Cancel and a send button. Used for replies in a thread, for
/// new comments on a line, and for rewriting a comment already posted — which
/// differ only in their wording and in what the box starts out holding.
struct CommentComposer: View {
let prompt: String
let sendTitle: String
var sendSymbol = "arrowshape.turn.up.left"
let sendSymbol: String
let isPosting: Bool
var mentions: MentionSource = .none
let mentions: MentionSource
let onCancel: () -> Void
let onSend: (String) async -> Void

@State private var draft = ""
@State private var draft: String

/// `text` is what the box opens with — empty for anything being written for
/// the first time, and the comment as the host stores it for an edit. It is
/// spelled out rather than left to the memberwise initialiser because a
/// `@State` needs its starting value here, before the view is on screen.
init(
prompt: String,
sendTitle: String,
sendSymbol: String = "arrowshape.turn.up.left",
isPosting: Bool,
mentions: MentionSource = .none,
startingFrom text: String = "",
onCancel: @escaping () -> Void,
onSend: @escaping (String) async -> Void
) {
self.prompt = prompt
self.sendTitle = sendTitle
self.sendSymbol = sendSymbol
self.isPosting = isPosting
self.mentions = mentions
self.onCancel = onCancel
self.onSend = onSend
_draft = State(initialValue: text)
}

private var isEmpty: Bool {
draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
Expand Down
Loading
Loading