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
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Foundation
import PsycheCore

enum ActionSheetSection: Equatable, Hashable {
Expand Down Expand Up @@ -25,6 +26,16 @@ struct ActionSheetStatus: Equatable {
let tone: ActionSheetStatusTone
}

struct ActionSheetCancelControl: Equatable {
let label: String
let response: MobileActionResponse
}

struct ActionSheetPullRequestDraft: Equatable {
let title: String
let body: String
}

enum ActionSheetPresentation {
static func sectionOrder(
hasScope: Bool,
Expand Down Expand Up @@ -54,6 +65,23 @@ enum ActionSheetPresentation {
option.danger == true ? .destructive : .normal
}

static func visibleChoiceOptions(_ options: [MobileActionOption]) -> [MobileActionOption] {
guard let cancelOption = cancelOption(in: options) else {
return options
}
return options.filter { $0.id != cancelOption.id }
}

static func cancelControl(for options: [MobileActionOption]) -> ActionSheetCancelControl {
if let cancelOption = cancelOption(in: options) {
return ActionSheetCancelControl(
label: cancelOption.label,
response: .choice(optionID: cancelOption.id)
)
}
return ActionSheetCancelControl(label: "Cancel", response: .cancel)
}

static func inputLineRange(_ requestedMaximum: Int?) -> ClosedRange<Int> {
1...min(max(requestedMaximum ?? 6, 1), 12)
}
Expand Down Expand Up @@ -98,4 +126,54 @@ enum ActionSheetPresentation {
static func primaryInputLabel(for action: PaneAction) -> String {
action == .createPR ? "Create Pull Request" : "Continue"
}

static func controlIdentifier(for label: String) -> String {
let slug = label
.lowercased()
.map { character -> String in
character.isLetter || character.isNumber ? String(character) : "-"
}
.joined()
.split(separator: "-", omittingEmptySubsequences: true)
.joined(separator: "-")
return "remote-action-control-\(slug)"
}

static func pullRequestDraft(from summary: String) -> ActionSheetPullRequestDraft {
let normalized = summary
.replacingOccurrences(of: "\r\n", with: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalized.isEmpty else {
return ActionSheetPullRequestDraft(title: "", body: "")
}
guard let newline = normalized.firstIndex(of: "\n") else {
return ActionSheetPullRequestDraft(title: normalized, body: "")
}

let title = String(normalized[..<newline])
.trimmingCharacters(in: .whitespacesAndNewlines)
let bodyStart = normalized.index(after: newline)
let body = String(
String(normalized[bodyStart...])
.drop(while: { $0 == "\n" })
)
.trimmingCharacters(in: .whitespacesAndNewlines)

return ActionSheetPullRequestDraft(title: title, body: body)
}

static func pullRequestSummary(title: String, body: String) -> String {
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedBody = body
.replacingOccurrences(of: "\r\n", with: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedBody.isEmpty else {
return trimmedTitle
}
return "\(trimmedTitle)\n\n\(trimmedBody)"
}

private static func cancelOption(in options: [MobileActionOption]) -> MobileActionOption? {
options.first { $0.id == "cancel" }
}
}
104 changes: 81 additions & 23 deletions native/ios/PsycheApp/Sources/PsycheApp/Views/ActionSheetView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ struct ActionSheetView: View {
@ObservedObject var store: RemoteActionStore

@State private var draft = ""
@State private var pullRequestTitle = ""
@State private var pullRequestBody = ""

var body: some View {
NavigationStack {
Expand All @@ -15,7 +17,7 @@ struct ActionSheetView: View {
ActionSheetPresentation.sectionOrder(
hasScope: !presentation.scope.rows.isEmpty,
hasConsequence: presentation.scope.consequence != nil,
hasRelatedFiles: !presentation.relatedFiles.isEmpty
hasRelatedFiles: standaloneRelatedFiles(for: presentation).isEmpty == false
),
id: \.self
) { section in
Expand Down Expand Up @@ -100,6 +102,7 @@ struct ActionSheetView: View {
case .pullRequestReview(let review):
pullRequestReviewSection(
review,
paneID: presentation.paneID,
message: presentation.message
)
case .progress(let progress):
Expand Down Expand Up @@ -145,6 +148,7 @@ struct ActionSheetView: View {

private func pullRequestReviewSection(
_ review: RemoteActionReview,
paneID: String,
message: String
) -> some View {
Section("Pull Request Review") {
Expand All @@ -165,18 +169,23 @@ struct ActionSheetView: View {
.foregroundStyle(PsycheTheme.amber)
}

TextField("Summary", text: $draft, axis: .vertical)
.lineLimit(1...12)
TextField("Title", text: $pullRequestTitle)
.lineLimit(1)
.disabled(ActionSheetPresentation.editingDisabled(isSubmitting: store.isSubmitting))
.accessibilityIdentifier("remote-action-pr-title")

TextField("Summary", text: $pullRequestBody, axis: .vertical)
.lineLimit(3...12)
.disabled(ActionSheetPresentation.editingDisabled(isSubmitting: store.isSubmitting))
.accessibilityIdentifier("remote-action-pr-body")

if !review.details.files.isEmpty {
LabeledContent("Review files") {
VStack(alignment: .trailing, spacing: 8) {
ForEach(review.details.files.indices, id: \.self) { index in
Label(review.details.files[index], systemImage: "doc")
.multilineTextAlignment(.trailing)
}
}
ForEach(review.details.files, id: \.self) { path in
relatedFileLink(
path,
paneID: paneID,
accessibilityIdentifier: "remote-action-pr-file-\(path)"
)
}
}
}
Expand Down Expand Up @@ -232,8 +241,12 @@ struct ActionSheetView: View {
@ViewBuilder
private func relatedFilesSection(for presentation: RemoteActionPresentation) -> some View {
Section("Related Files") {
ForEach(presentation.relatedFiles.indices, id: \.self) { index in
Label(presentation.relatedFiles[index], systemImage: "doc")
ForEach(standaloneRelatedFiles(for: presentation), id: \.self) { path in
relatedFileLink(
path,
paneID: presentation.paneID,
accessibilityIdentifier: "remote-action-file-\(path)"
)
}
}
}
Expand Down Expand Up @@ -273,7 +286,7 @@ struct ActionSheetView: View {
case .choice(let options):
Section("Controls") {
submittingIndicator
ForEach(options) { option in
ForEach(ActionSheetPresentation.visibleChoiceOptions(options)) { option in
Button(
role: ActionSheetPresentation.optionRole(option).buttonRole,
action: { respond(.choice(optionID: option.id)) }
Expand All @@ -284,9 +297,10 @@ struct ActionSheetView: View {
.accessibilityElement(children: .ignore)
.accessibilityLabel(option.label)
.accessibilityValue(option.description ?? "")
.accessibilityIdentifier("remote-action-choice-\(option.id)")
.accessibilityIdentifier(ActionSheetPresentation.controlIdentifier(for: option.label))
}
responseButton("Cancel", response: .cancel)
let cancelControl = ActionSheetPresentation.cancelControl(for: options)
responseButton(cancelControl.label, response: cancelControl.response)
}
case .input:
Section("Controls") {
Expand All @@ -304,8 +318,8 @@ struct ActionSheetView: View {
responseButton("Cancel", response: .cancel)
responseButton(
ActionSheetPresentation.primaryInputLabel(for: presentation.action),
response: .input(value: draft),
recoveryText: draft
response: .input(value: pullRequestSummary),
recoveryText: pullRequestSummary
)
}
case .progress:
Expand All @@ -332,19 +346,24 @@ struct ActionSheetView: View {
response: MobileActionResponse,
recoveryText: String? = nil
) -> some View {
Button(
label,
role: role.buttonRole,
action: { respond(response, recoveryText: recoveryText) }
)
Button(role: role.buttonRole, action: { respond(response, recoveryText: recoveryText) }) {
Text(label)
.frame(maxWidth: .infinity, alignment: .leading)
}
.disabled(store.isSubmitting)
.accessibilityElement(children: .ignore)
.accessibilityLabel(label)
.accessibilityIdentifier(ActionSheetPresentation.controlIdentifier(for: label))
}

private func dismissButton(_ label: String) -> some View {
Button(label) {
store.dismiss()
}
.disabled(store.isSubmitting)
.accessibilityElement(children: .ignore)
.accessibilityLabel(label)
.accessibilityIdentifier(ActionSheetPresentation.controlIdentifier(for: label))
}

private func choiceLabel(for option: MobileActionOption) -> some View {
Expand Down Expand Up @@ -384,17 +403,56 @@ struct ActionSheetView: View {
private func resetDraft() {
guard let presentation = store.presentation else {
draft = ""
pullRequestTitle = ""
pullRequestBody = ""
return
}

switch presentation.content {
case .input(let input):
draft = input.defaultValue
pullRequestTitle = ""
pullRequestBody = ""
case .pullRequestReview(let review):
draft = review.defaultSummary
let reviewDraft = ActionSheetPresentation.pullRequestDraft(from: review.defaultSummary)
draft = ""
pullRequestTitle = reviewDraft.title
pullRequestBody = reviewDraft.body
default:
draft = ""
pullRequestTitle = ""
pullRequestBody = ""
}
}

private func standaloneRelatedFiles(for presentation: RemoteActionPresentation) -> [String] {
switch presentation.content {
case .pullRequestReview(let review):
let reviewFiles = Set(review.details.files)
return presentation.relatedFiles.filter { !reviewFiles.contains($0) }
default:
return presentation.relatedFiles
}
}

private func relatedFileLink(
_ path: String,
paneID: String,
accessibilityIdentifier: String
) -> some View {
NavigationLink {
ActionSheetRelatedFileView(paneID: paneID, path: path)
} label: {
Label(path, systemImage: "doc")
}
.accessibilityIdentifier(accessibilityIdentifier)
}

private var pullRequestSummary: String {
ActionSheetPresentation.pullRequestSummary(
title: pullRequestTitle,
body: pullRequestBody
)
}

private func color(for tone: ActionSheetStatusTone) -> Color {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,74 @@ private struct FileBrowserRow: View {
}
}

struct ActionSheetRelatedFileView: View {
@EnvironmentObject private var store: WorkspaceStore

let paneID: String
let path: String

@State private var file: BrowserFile?
@State private var isLoading = false
@State private var errorMessage: String?

var body: some View {
Group {
if store.isStale {
ContentUnavailableView(
"Workspace out of date",
systemImage: "wifi.exclamationmark",
description: Text("Reconnect before inspecting this file.")
)
} else if isLoading, file == nil, errorMessage == nil {
ProgressView("Loading file…")
} else if let file {
switch FileInspectionDestination.forFile(file) {
case .preview:
FilePreviewView(paneID: paneID, file: file)
case .diff:
DiffView(paneID: paneID, file: file)
}
} else if let errorMessage {
ContentUnavailableView(
"Couldn't load file",
systemImage: "exclamationmark.triangle",
description: Text(errorMessage)
)
} else {
ProgressView("Loading file…")
}
}
.navigationTitle(path.components(separatedBy: "/").last ?? path)
.navigationBarTitleDisplayMode(.inline)
.task(id: store.isStale) {
guard !store.isStale else {
isLoading = false
return
}
await load()
}
.accessibilityIdentifier("action-sheet-related-file")
}

private func load() async {
guard !isLoading else { return }
isLoading = true
errorMessage = nil
defer { isLoading = false }

do {
let snapshot = try await store.listFiles(inPane: paneID)
guard let file = snapshot.files.first(where: { $0.path == path }) else {
errorMessage = "The host did not publish \(path) for this pane."
return
}
self.file = file
} catch {
errorMessage = error.localizedDescription
}
}
}

private struct FilePreviewView: View {
@EnvironmentObject private var store: WorkspaceStore

Expand Down
Loading
Loading