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
@@ -0,0 +1,114 @@
import CmuxMobileSupport
import SwiftUI

/// Snapshot-isolated native menu for switching the active workspace surface.
struct TerminalPickerMenu: View, Equatable {
let value: TerminalPickerMenuValue
let actions: TerminalPickerMenuActions
#if DEBUG
private let diagnostics = TerminalPickerMenuDiagnostics()
#endif

nonisolated static func == (lhs: Self, rhs: Self) -> Bool {
lhs.value == rhs.value
}
Comment on lines +12 to +14

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Equal Values Retain Old Actions

The equality check ignores actions, although the rendered menu invokes those closures. If SwiftUI preserves this view identity while the workspace changes to one with an equal menu snapshot, .equatable() can retain closures from the previous WorkspaceDetailView; selecting a terminal can then close the browser or perform another action for the previous workspace. Include the action owner, such as the workspace ID, in the compared value.

Rule Used: Flag SwiftUI changes that can cause stale state, b... (source)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not reachable: terminal ids are globally unique surface ids, so a workspace change always changes the row ids and therefore the menu value; equal snapshots across workspaces cannot occur. The existing .onChange(of: terminalPickerLiveMembership) also resyncs the snapshot on any membership change, installing fresh closures. Within the single-render window the closures act via the live store reference keyed by unique terminal id, so they cannot target the wrong workspace.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a sound argument. Globally unique terminal IDs ensure workspace switches always produce unequal TerminalPickerMenuValues, .equatable() never suppresses those updates, and the .onChange resync installs fresh closures before any interaction is possible. The stale-closure path I described isn't reachable given those invariants.


var body: some View {
Menu {
instrumentedMenuContent
} label: {
Label(
value.selectedName ?? L10n.string("mobile.terminal.select", defaultValue: "Terminal"),
systemImage: "rectangle.stack"
)
.labelStyle(.iconOnly)
}
.foregroundStyle(TerminalPalette.foreground)
.accessibilityLabel(L10n.string("mobile.terminal.picker.title", defaultValue: "Terminals"))
.accessibilityIdentifier("MobileTerminalDropdown")
.accessibilityValue(value.selectedName ?? "")
}

@ViewBuilder
private var instrumentedMenuContent: some View {
#if DEBUG
let _ = diagnostics.recordContentBuilderEvaluation(rowCount: value.rows.count)
#endif
menuContent
}

@ViewBuilder
private var menuContent: some View {
Section(L10n.string("mobile.terminal.picker.title", defaultValue: "Terminals")) {
ForEach(value.rows) { terminal in
Button {
actions.selectTerminal(terminal.id)
} label: {
Label(
terminal.name,
systemImage: terminal.id == value.selectedID && !value.hasActiveBrowser
? "checkmark.circle.fill"
: "terminal"
)
}
.accessibilityIdentifier("MobileTerminalMenuItem-\(terminal.id.rawValue)")
}
}

Section {
Button(action: actions.createWorkspace) {
Label(
L10n.string("mobile.workspace.new", defaultValue: "New Workspace"),
systemImage: "plus.square.on.square"
)
}
.disabled(!value.canCreateWorkspace)
.accessibilityIdentifier("MobileNewWorkspaceMenuItem")

Button(action: actions.createTerminal) {
Label(L10n.string("mobile.terminal.new", defaultValue: "New Terminal"), systemImage: "plus")
}
.accessibilityIdentifier("MobileNewTerminalMenuItem")

Button(action: actions.openBrowser) {
Label(
L10n.string("mobile.browser.new", defaultValue: "New Browser"),
systemImage: value.hasActiveBrowser ? "checkmark.circle.fill" : "globe"
)
}
.accessibilityIdentifier("MobileNewBrowserMenuItem")
}

#if canImport(UIKit)
Section {
if !value.hasActiveBrowser && !value.isChatMode {
Button(action: actions.openTextSheet) {
Label(
L10n.string("mobile.terminal.viewAsText", defaultValue: "View as Text"),
systemImage: "doc.plaintext"
)
}
.accessibilityIdentifier("MobileViewAsTextMenuItem")
}

#if DEBUG
Button(action: actions.copyDebugLogs) {
Label(
L10n.string("mobile.debug.copyLogs", defaultValue: "Copy Debug Logs"),
systemImage: "doc.on.clipboard"
)
}
.accessibilityIdentifier("MobileCopyDebugLogsMenuItem")
#endif

Button(action: actions.sendFeedback) {
Label(
L10n.string("mobile.feedback.send", defaultValue: "Send Feedback"),
systemImage: "paperplane"
)
}
.accessibilityIdentifier("MobileSendFeedbackMenuItem")
}
#endif
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import CmuxMobileShellModel

/// User actions emitted by ``TerminalPickerMenu`` without exposing mutable stores to its row subtree.
struct TerminalPickerMenuActions {
let selectTerminal: (MobileTerminalPreview.ID) -> Void
let createWorkspace: () -> Void
let createTerminal: () -> Void
let openBrowser: () -> Void
let openTextSheet: () -> Void
let copyDebugLogs: () -> Void
let sendFeedback: () -> Void
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#if DEBUG
import Foundation
import OSLog

/// DEBUG-only events for counting terminal-picker menu evaluation and snapshot writes.
struct TerminalPickerMenuDiagnostics {
private let logger = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "com.cmuxterm.app",
category: "TerminalPickerMenu"
)
private let signpostLog = OSLog(
subsystem: Bundle.main.bundleIdentifier ?? "com.cmuxterm.app",
category: "TerminalPickerMenu"
)

func recordContentBuilderEvaluation(rowCount: Int) {
logger.debug("content-builder evaluated rows=\(rowCount, privacy: .public)")
os_signpost(
.event,
log: signpostLog,
name: "ContentBuilderEvaluation",
"rows=%{public}d",
rowCount
)
}

func recordRowsWrite(rowCount: Int, includesTitleChanges: Bool) {
logger.debug(
"snapshot rows write rows=\(rowCount, privacy: .public) includeTitles=\(includesTitleChanges, privacy: .public)"
)
os_signpost(
.event,
log: signpostLog,
name: "SnapshotRowsWrite",
"rows=%{public}d includeTitles=%{public}d",
rowCount,
includesTitleChanges ? 1 : 0
)
}
}
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import CmuxMobileShellModel

/// Immutable state that determines the native terminal picker's presented menu.
struct TerminalPickerMenuValue: Equatable {
let rows: [TerminalPickerMenuRow]
let selectedID: MobileTerminalPreview.ID?
let selectedName: String?
let canCreateWorkspace: Bool
let hasActiveBrowser: Bool
let isChatMode: Bool

init(
liveTerminals: [MobileTerminalPreview],
snapshotRows: [TerminalPickerMenuRow],
selectedID: MobileTerminalPreview.ID?,
canCreateWorkspace: Bool,
hasActiveBrowser: Bool,
isChatMode: Bool
) {
rows = snapshotRows.isEmpty
? liveTerminals.map(TerminalPickerMenuRow.init)
: snapshotRows
let selection = rows.resolvedTerminalPickerSelection(selectedID: selectedID)
self.selectedID = selection?.id
selectedName = selection?.name
self.canCreateWorkspace = canCreateWorkspace
self.hasActiveBrowser = hasActiveBrowser
self.isChatMode = isChatMode
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,24 @@ extension WorkspaceDetailView {
let rows = terminalPickerLiveRows
if includeTitleChanges {
guard terminalPickerRows != rows else { return }
#if DEBUG
TerminalPickerMenuDiagnostics().recordRowsWrite(
rowCount: rows.count,
includesTitleChanges: true
)
#endif
terminalPickerRows = rows
return
}
guard terminalPickerRows.isEmpty
|| TerminalPickerMenuMembership(terminalPickerRows) != TerminalPickerMenuMembership(rows)
else { return }
#if DEBUG
TerminalPickerMenuDiagnostics().recordRowsWrite(
rowCount: rows.count,
includesTitleChanges: false
)
#endif
terminalPickerRows = rows
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -350,101 +350,35 @@ struct WorkspaceDetailView: View {
// Native menu keeps press-drag-release selection and routes through
// `selectTerminalFromPicker`; keyboard-dismiss-on-open is unavailable.
var terminalPickerToolbarButton: some View {
let rows = terminalPickerRows.isEmpty ? terminalPickerLiveRows : terminalPickerRows
let selection = terminalPickerLiveRows.resolvedTerminalPickerSelection(selectedID: store.selectedTerminalID)

return Menu {
terminalPickerMenuContent(rows: rows, selectedID: selection?.id)
} label: {
Label(
selection?.name ?? L10n.string("mobile.terminal.select", defaultValue: "Terminal"),
systemImage: "rectangle.stack"
TerminalPickerMenu(
value: TerminalPickerMenuValue(
liveTerminals: workspace.terminals,
snapshotRows: terminalPickerRows,
selectedID: store.selectedTerminalID,
canCreateWorkspace: canCreateWorkspace,
hasActiveBrowser: activeBrowser != nil,
isChatMode: isChatMode
),
actions: TerminalPickerMenuActions(
selectTerminal: selectTerminalFromPicker,
createWorkspace: createWorkspaceFromToolbar,
createTerminal: createTerminalFromToolbar,
openBrowser: openBrowserFromToolbar,
openTextSheet: openTextSheetFromMenu,
copyDebugLogs: {
#if DEBUG
copyDebugLogsFromMenu()
#endif
},
sendFeedback: openFeedbackComposerFromMenu
)
.labelStyle(.iconOnly)
}
.foregroundStyle(TerminalPalette.foreground)
.accessibilityLabel(L10n.string("mobile.terminal.picker.title", defaultValue: "Terminals"))
.accessibilityIdentifier("MobileTerminalDropdown")
.accessibilityValue(selection?.name ?? "")
)
.equatable()
.simultaneousGesture(TapGesture().onEnded { syncTerminalPickerRows(includeTitleChanges: true) })
.onAppear { syncTerminalPickerRows(includeTitleChanges: true) }
.onChange(of: terminalPickerLiveMembership) { _, _ in syncTerminalPickerRows() }
}

@ViewBuilder
private func terminalPickerMenuContent(
rows: [TerminalPickerMenuRow],
selectedID: MobileTerminalPreview.ID?
) -> some View {
Section(L10n.string("mobile.terminal.picker.title", defaultValue: "Terminals")) {
ForEach(rows) { terminal in
Button {
selectTerminalFromPicker(terminal.id)
} label: {
Label(
terminal.name,
systemImage: terminal.id == selectedID && activeBrowser == nil
? "checkmark.circle.fill"
: "terminal"
)
}
.accessibilityIdentifier("MobileTerminalMenuItem-\(terminal.id.rawValue)")
}
}

Section {
Button(action: createWorkspaceFromToolbar) {
Label(L10n.string("mobile.workspace.new", defaultValue: "New Workspace"), systemImage: "plus.square.on.square")
}
.disabled(!canCreateWorkspace)
.accessibilityIdentifier("MobileNewWorkspaceMenuItem")

Button(action: createTerminalFromToolbar) {
Label(L10n.string("mobile.terminal.new", defaultValue: "New Terminal"), systemImage: "plus")
}
.accessibilityIdentifier("MobileNewTerminalMenuItem")

Button(action: openBrowserFromToolbar) {
Label(
L10n.string("mobile.browser.new", defaultValue: "New Browser"),
systemImage: activeBrowser == nil ? "globe" : "checkmark.circle.fill"
)
}
.accessibilityIdentifier("MobileNewBrowserMenuItem")
}

#if canImport(UIKit)
Section {
// Only while the terminal pane is showing: browser and chat modes
// do not mount a terminal surface for text capture.
if activeBrowser == nil && !isChatMode {
Button(action: openTextSheetFromMenu) {
Label(
L10n.string("mobile.terminal.viewAsText", defaultValue: "View as Text"),
systemImage: "doc.plaintext"
)
}
.accessibilityIdentifier("MobileViewAsTextMenuItem")
}

#if DEBUG
Button(action: copyDebugLogsFromMenu) {
Label(L10n.string("mobile.debug.copyLogs", defaultValue: "Copy Debug Logs"), systemImage: "doc.on.clipboard")
}
.accessibilityIdentifier("MobileCopyDebugLogsMenuItem")
#endif

Button(action: openFeedbackComposerFromMenu) {
Label(
L10n.string("mobile.feedback.send", defaultValue: "Send Feedback"),
systemImage: "paperplane"
)
}
.accessibilityIdentifier("MobileSendFeedbackMenuItem")
}
#endif
}

#if canImport(UIKit)
#if DEBUG
private func copyDebugLogsFromMenu() {
Expand Down
Loading
Loading