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
4 changes: 4 additions & 0 deletions apps/ios/ADE.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
AC7600000000000000000006 /* ApprovalRequestKindPrecedenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7500000000000000000006 /* ApprovalRequestKindPrecedenceTests.swift */; };
AC7600000000000000000007 /* WorkPendingInputRescueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7500000000000000000007 /* WorkPendingInputRescueTests.swift */; };
AC7600000000000000000003 /* WorkSessionGroupingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7500000000000000000003 /* WorkSessionGroupingTests.swift */; };
AC760000000000000000000A /* LaneManageRenameTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC750000000000000000000A /* LaneManageRenameTests.swift */; };
AC7600000000000000000004 /* ActivityWidgetPresentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7500000000000000000004 /* ActivityWidgetPresentationTests.swift */; };
AC7600000000000000000002 /* HubProjectPresentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7500000000000000000002 /* HubProjectPresentationTests.swift */; };
AA1100000000000000000003 /* ADESharedTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000003 /* ADESharedTheme.swift */; };
Expand Down Expand Up @@ -362,6 +363,7 @@
AC7500000000000000000006 /* ApprovalRequestKindPrecedenceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ApprovalRequestKindPrecedenceTests.swift; path = ADETests/ApprovalRequestKindPrecedenceTests.swift; sourceTree = "<group>"; };
AC7500000000000000000007 /* WorkPendingInputRescueTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkPendingInputRescueTests.swift; path = ADETests/WorkPendingInputRescueTests.swift; sourceTree = "<group>"; };
AC7500000000000000000003 /* WorkSessionGroupingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkSessionGroupingTests.swift; path = ADETests/WorkSessionGroupingTests.swift; sourceTree = "<group>"; };
AC750000000000000000000A /* LaneManageRenameTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneManageRenameTests.swift; path = ADETests/LaneManageRenameTests.swift; sourceTree = "<group>"; };
AC7500000000000000000004 /* ActivityWidgetPresentationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ActivityWidgetPresentationTests.swift; path = ADETests/ActivityWidgetPresentationTests.swift; sourceTree = "<group>"; };
AC7500000000000000000002 /* HubProjectPresentationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = HubProjectPresentationTests.swift; path = ADETests/HubProjectPresentationTests.swift; sourceTree = "<group>"; };
AA1000000000000000000003 /* ADESharedTheme.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADESharedTheme.swift; path = ADE/Shared/ADESharedTheme.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1172,6 +1174,7 @@
AC7500000000000000000006 /* ApprovalRequestKindPrecedenceTests.swift */,
AC7500000000000000000007 /* WorkPendingInputRescueTests.swift */,
AC7500000000000000000003 /* WorkSessionGroupingTests.swift */,
AC750000000000000000000A /* LaneManageRenameTests.swift */,
AC7500000000000000000004 /* ActivityWidgetPresentationTests.swift */,
AC7500000000000000000002 /* HubProjectPresentationTests.swift */,
AC7100000000000000000001 /* ActivityContractDecodingTests.swift */,
Expand Down Expand Up @@ -1708,6 +1711,7 @@
AC7600000000000000000006 /* ApprovalRequestKindPrecedenceTests.swift in Sources */,
AC7600000000000000000007 /* WorkPendingInputRescueTests.swift in Sources */,
AC7600000000000000000003 /* WorkSessionGroupingTests.swift in Sources */,
AC760000000000000000000A /* LaneManageRenameTests.swift in Sources */,
AC7600000000000000000004 /* ActivityWidgetPresentationTests.swift in Sources */,
AC7600000000000000000002 /* HubProjectPresentationTests.swift in Sources */,
AC7200000000000000000001 /* ActivityContractDecodingTests.swift in Sources */,
Expand Down
4 changes: 4 additions & 0 deletions apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,10 @@ struct LaneDetailScreen: View {
dismiss()
// SyncService refreshes once its retained host cleanup finishes;
// starting another snapshot request here would duplicate that work.
},
onRenamed: {
await loadDetail(refreshRemote: true)
await onRefreshRoot()
}
) {
await loadDetail(refreshRemote: true)
Expand Down
244 changes: 194 additions & 50 deletions apps/ios/ADE/Views/Lanes/LaneManageSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@ struct LaneManageSheet: View {
let snapshot: LaneListSnapshot
let allLaneSnapshots: [LaneListSnapshot]
let onDeleted: (@MainActor () async -> Void)?
let onRenamed: (@MainActor () async -> Void)?
let onComplete: @MainActor () async -> Void

@State private var activeTab: ManageLaneTab = .delete
@State private var displayedName: String
@State private var renameText: String
@State private var renamePresented = false
@State private var selectedParentLaneId: String
@State private var baseBranchOverride: String = ""
@State private var colorText: String
Expand All @@ -25,13 +28,16 @@ struct LaneManageSheet: View {
snapshot: LaneListSnapshot,
allLaneSnapshots: [LaneListSnapshot],
onDeleted: (@MainActor () async -> Void)? = nil,
onRenamed: (@MainActor () async -> Void)? = nil,
onComplete: @escaping @MainActor () async -> Void
) {
self.snapshot = snapshot
self.allLaneSnapshots = allLaneSnapshots
self.onDeleted = onDeleted
self.onRenamed = onRenamed
self.onComplete = onComplete
let primaryLaneId = allLaneSnapshots.first(where: { $0.lane.laneType == "primary" })?.lane.id ?? ""
_displayedName = State(initialValue: snapshot.lane.name)
_renameText = State(initialValue: snapshot.lane.name)
_selectedParentLaneId = State(initialValue: snapshot.lane.parentLaneId ?? primaryLaneId)
_colorText = State(initialValue: snapshot.lane.color ?? "")
Expand Down Expand Up @@ -128,6 +134,13 @@ struct LaneManageSheet: View {
laneAllowsLiveActions(connectionState: syncService.connectionState, laneStatus: syncService.status(for: .lanes))
}

private var showsRenameControl: Bool {
LaneManageRename.showsRenameControl(
laneType: snapshot.lane.laneType,
hostSupportsRename: syncService.canInvokeRemoteAction("lanes.rename")
)
}

private var liveActionNoticePresentation: LaneEmptyStatePresentation? {
laneLiveActionNotice(
connectionState: syncService.connectionState,
Expand Down Expand Up @@ -163,8 +176,6 @@ struct LaneManageSheet: View {
manageErrorBanner(errorMessage)
}

laneNameTitle

laneInfoHeader

if isPrimary {
Expand All @@ -186,13 +197,36 @@ struct LaneManageSheet: View {
.adeScreenBackground()
.overlay { busyOverlay }
.adeNavigationGlass()
.navigationTitle(snapshot.lane.name)
.navigationTitle(displayedName)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close") { dismiss() }
.disabled(busyAction != nil)
}
if showsRenameControl {
ToolbarItem(placement: .confirmationAction) {
Button {
renameText = displayedName
renamePresented = true
} label: {
Image(systemName: "pencil")
}
.accessibilityLabel("Rename lane")
.disabled(busyAction != nil || !canRunLiveActions)
}
}
}
.alert("Rename lane", isPresented: $renamePresented) {
TextField("Lane name", text: $renameText)
.textInputAutocapitalization(.words)
Button("Cancel", role: .cancel) {}
Button("Save") {
let draft = renameText
Task { await performRename(draft: draft) }
}
} message: {
Text("Changes the display name. The git branch stays the same.")
}
.onAppear {
if !availableTabs.contains(activeTab) {
Expand All @@ -216,49 +250,92 @@ struct LaneManageSheet: View {
}
}

private var laneTint: Color {
laneSurfaceTint(forHex: snapshot.lane.color).text ?? ADEColor.accent
private var laneInfoHeader: some View {
VStack(alignment: .leading, spacing: 10) {
metadataRow(
symbol: "arrow.triangle.branch",
value: branchLabel,
monospaced: true,
accessibilityNoun: "Branch",
lineLimit: 1,
dirty: snapshot.lane.status.dirty
) {
if snapshot.lane.status.dirty {
Text("DIRTY")
.font(.caption2.weight(.bold))
.foregroundStyle(ADEColor.warning)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(ADEColor.warning.opacity(0.14), in: Capsule())
}
}
metadataRow(
symbol: "folder",
value: snapshot.lane.worktreePath,
monospaced: false,
accessibilityNoun: "Path",
lineLimit: 2
)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(ADEColor.surfaceBackground.opacity(0.35), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
}

private var laneNameTitle: some View {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(snapshot.lane.name)
.font(.largeTitle.weight(.bold))
.foregroundStyle(laneTint)
.lineLimit(2)
.minimumScaleFactor(0.7)
if snapshot.lane.status.dirty {
Text("DIRTY")
.font(.caption2.weight(.bold))
.foregroundStyle(ADEColor.warning)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(ADEColor.warning.opacity(0.14), in: Capsule())
}
Spacer(minLength: 0)
private func metadataRow(
symbol: String,
value: String,
monospaced: Bool,
accessibilityNoun: String,
lineLimit: Int,
dirty: Bool = false
) -> some View {
metadataRow(
symbol: symbol,
value: value,
monospaced: monospaced,
accessibilityNoun: accessibilityNoun,
lineLimit: lineLimit,
dirty: dirty
) {
EmptyView()
}
.accessibilityAddTraits(.isHeader)
}

private var laneInfoHeader: some View {
VStack(alignment: .leading, spacing: 8) {
LabeledContent("Branch") {
Text(branchLabel)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(ADEColor.textSecondary)
.lineLimit(1)
}
.font(.caption)
LabeledContent("Path") {
Text(snapshot.lane.worktreePath)
.font(.caption)
.foregroundStyle(ADEColor.textSecondary)
.multilineTextAlignment(.trailing)
}
private func metadataRow<Trailing: View>(
symbol: String,
value: String,
monospaced: Bool,
accessibilityNoun: String,
lineLimit: Int,
dirty: Bool = false,
@ViewBuilder trailing: () -> Trailing
) -> some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: symbol)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(ADEColor.textMuted)
.frame(width: 18)
.padding(.top, 1)
.accessibilityHidden(true)
Text(value)
.font(monospaced ? .system(.caption, design: .monospaced) : .caption)
.foregroundStyle(ADEColor.textSecondary)
.lineLimit(lineLimit)
.truncationMode(.middle)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
trailing()
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(ADEColor.surfaceBackground.opacity(0.35), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.accessibilityElement(children: .combine)
.accessibilityLabel(
LaneManageRename.metadataAccessibilityLabel(
noun: accessibilityNoun,
value: value,
dirty: dirty
)
)
}

private var manageTabBar: some View {
Expand Down Expand Up @@ -476,12 +553,6 @@ struct LaneManageSheet: View {
.font(.caption)
.foregroundStyle(ADEColor.textSecondary)

LaneTextField("Lane name", text: $renameText)
LaneActionButton(title: "Save name", symbol: "checkmark.circle.fill", tint: ADEColor.accent) {
Task { await performAction("rename lane") { try await syncService.renameLane(snapshot.lane.id, name: renameText) } }
}
.disabled(!canRunLiveActions || renameText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || renameText == snapshot.lane.name)

VStack(alignment: .leading, spacing: 6) {
Text("Color")
.font(.caption.weight(.semibold))
Expand Down Expand Up @@ -706,24 +777,62 @@ struct LaneManageSheet: View {
}
}

@MainActor
private func performRename(draft: String) async {
let trimmed = LaneManageRename.trimmedName(draft)
if trimmed.isEmpty {
ADEHaptics.warning()
errorMessage = "Lane name cannot be empty."
return
}
if let duplicate = LaneManageRename.duplicateName(
draft: trimmed,
laneId: snapshot.lane.id,
among: allLaneSnapshots.map(\.lane)
) {
ADEHaptics.warning()
errorMessage = "A lane named \"\(duplicate)\" already exists."
return
}
guard LaneManageRename.canSave(draft: trimmed, currentName: displayedName) else { return }
let renamed = await runLiveAction("rename lane") {
try await syncService.renameLane(snapshot.lane.id, name: trimmed)
}
guard renamed else { return }
displayedName = trimmed
renameText = trimmed
ADEHaptics.success()
if let onRenamed {
await onRenamed()
}
}

@MainActor
private func performAction(_ label: String, operation: () async throws -> Void) async {
guard await runLiveAction(label, operation: operation) else { return }
dismiss()
await onComplete()
}

@MainActor
private func runLiveAction(_ label: String, operation: () async throws -> Void) async -> Bool {
guard canRunLiveActions else {
ADEHaptics.warning()
errorMessage = "Reconnect to machine before you \(label)."
return
return false
}
do {
busyAction = label
errorMessage = nil
try await operation()
dismiss()
await onComplete()
busyAction = nil
return true
} catch {
ADEHaptics.error()
errorMessage = error.localizedDescription
busyAction = nil
return false
}
busyAction = nil
}

@MainActor
Expand All @@ -736,3 +845,38 @@ struct LaneManageSheet: View {
}
}
}

enum LaneManageRename {
static func trimmedName(_ raw: String) -> String {
raw.trimmingCharacters(in: .whitespacesAndNewlines)
}

static func canRename(laneType: String) -> Bool {
laneType != "primary"
}

static func showsRenameControl(laneType: String, hostSupportsRename: Bool) -> Bool {
canRename(laneType: laneType) && hostSupportsRename
}

static func duplicateName(draft: String, laneId: String, among: [LaneSummary]) -> String? {
let trimmed = trimmedName(draft)
guard !trimmed.isEmpty else { return nil }
return among.first { candidate in
candidate.id != laneId
&& candidate.archivedAt == nil
&& candidate.name.trimmingCharacters(in: .whitespacesAndNewlines)
.localizedCaseInsensitiveCompare(trimmed) == .orderedSame
}?.name
}

static func canSave(draft: String, currentName: String) -> Bool {
let trimmed = trimmedName(draft)
guard !trimmed.isEmpty else { return false }
return trimmed != currentName.trimmingCharacters(in: .whitespacesAndNewlines)
}

static func metadataAccessibilityLabel(noun: String, value: String, dirty: Bool) -> String {
dirty ? "\(noun), \(value), dirty" : "\(noun), \(value)"
}
}
3 changes: 3 additions & 0 deletions apps/ios/ADE/Views/Work/WorkRootScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,9 @@ struct WorkRootScreen: View {
manageLaneTarget = nil
await reload(refreshRemote: true)
},
onRenamed: {
await reload(refreshRemote: true)
},
onComplete: {
manageLaneTarget = nil
await reload(refreshRemote: true)
Expand Down
Loading
Loading