diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift index 3e6bcd77a2c..6605e9b95dc 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift @@ -135,7 +135,11 @@ struct DeviceTreeView: View { if expansion.isExpanded(deviceExpansionID(device)) { ForEach(device.instances) { instance in - instanceRows(device: device, instance: instance, isConnectedDevice: isConnectedDevice) + instanceRows( + device: device, + instance: instance, + isConnectedDevice: isConnectedDevice + ) } } } @@ -203,7 +207,7 @@ struct DeviceTreeView: View { workspace: workspace, connectionStatus: store.macConnectionStatus, isSelected: false, - navigationStyle: .push, + navigationStyle: .sidebar, wrapWorkspaceTitles: displaySettings.wrapWorkspaceTitles, previewLineLimit: displaySettings.workspacePreviewLineCount, selectWorkspace: { id in diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWorkspacePreview+Display.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWorkspacePreview+Display.swift index b3b8d48a6c7..f108d2f7452 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWorkspacePreview+Display.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWorkspacePreview+Display.swift @@ -1,5 +1,6 @@ import CmuxMobileShellModel import CmuxMobileSupport +import Foundation import SwiftUI /// Display-only derivations of ``MobileWorkspacePreview`` used by the workspace @@ -42,49 +43,28 @@ extension MobileWorkspacePreview { } /// The row's trailing slot: the connection problem when there is one, - /// otherwise the compact relative activity time. `now` is threaded from the - /// row's `TimelineView` so the label refreshes as time passes and stays - /// deterministic in tests. - func timestampOrStatus(connectionStatus: MobileMacConnectionStatus, now: Date) -> String { + /// otherwise a static activity timestamp. This intentionally avoids a live + /// relative clock in list rows so native swipe tracking is not invalidated by + /// timer-driven row updates. + func timestampOrStatus(connectionStatus: MobileMacConnectionStatus) -> String { if connectionStatus != .connected { return connectionStatus.label } - return relativeActivityLabel(now: now) + return activityTimestampLabel() } - /// Compact relative time for the row's trailing slot, like a messaging list: - /// "now" under a minute, then "2m", "1h", "3d", and a localized month/day - /// past a week. Empty when there is no real activity timestamp. The bucket - /// and its count come from ``MobileRelativeActivity``, computed purely from - /// the injected `now`, so the label is deterministic in tests (only the - /// `monthDay` case formats the date itself, which does not depend on `now`). - func relativeActivityLabel(now: Date) -> String { + /// Static timestamp for the row's trailing slot. Recent activity shows the + /// local time; older activity shows a compact month/day. Empty when there is + /// no real activity timestamp. + func activityTimestampLabel(referenceDate: Date = .now, calendar: Calendar = .current) -> String { let date = latestActivityDate - switch MobileRelativeActivity.bucket(for: date, now: now) { - case .none: - // The trailing slot stays empty rather than echoing the epoch. + guard date > Date(timeIntervalSince1970: 1) else { return "" - case .now: - return L10n.string("mobile.workspace.preview.justNow", defaultValue: "now") - case .minutes(let minutes): - return String( - format: L10n.string("mobile.workspace.preview.minutesCompactFormat", defaultValue: "%dm"), - minutes - ) - case .hours(let hours): - return String( - format: L10n.string("mobile.workspace.preview.hoursCompactFormat", defaultValue: "%dh"), - hours - ) - case .days(let days): - return String( - format: L10n.string("mobile.workspace.preview.daysCompactFormat", defaultValue: "%dd"), - days - ) - case .monthDay: - // Past a week, a month/day date is more useful than "5 weeks ago". - return date.formatted(.dateTime.month(.defaultDigits).day(.defaultDigits)) } + if calendar.isDate(date, inSameDayAs: referenceDate) { + return date.formatted(.dateTime.hour().minute()) + } + return date.formatted(.dateTime.month(.defaultDigits).day(.defaultDigits)) } func detailLine(connectionStatus: MobileMacConnectionStatus) -> String { diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceNavigationRow.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceNavigationRow.swift index 67687cd7d01..da7639c79db 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceNavigationRow.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceNavigationRow.swift @@ -35,17 +35,7 @@ struct WorkspaceNavigationRow: View { @State private var isRenaming = false var body: some View { - WorkspaceRow( - workspace: workspace, - connectionStatus: connectionStatus, - isSelected: navigationStyle == .sidebar && isSelected, - wrapWorkspaceTitles: wrapWorkspaceTitles, - previewLineLimit: previewLineLimit - ) - .onTapGesture { - selectWorkspace(workspace.id) - } - .contentShape(Rectangle()) + rowTarget .contextMenu { contextMenu } .swipeActions(edge: .leading, allowsFullSwipe: true) { if let setUnread { @@ -98,6 +88,36 @@ struct WorkspaceNavigationRow: View { } } + @ViewBuilder + private var rowTarget: some View { + switch navigationStyle { + case .push: + NavigationLink(value: workspace.id) { + rowLabel + } + .simultaneousGesture(TapGesture().onEnded { + selectWorkspace(workspace.id) + }) + case .sidebar: + Button { + selectWorkspace(workspace.id) + } label: { + rowLabel + } + .buttonStyle(.plain) + } + } + + private var rowLabel: some View { + WorkspaceRow( + workspace: workspace, + connectionStatus: connectionStatus, + isSelected: navigationStyle == .sidebar && isSelected, + wrapWorkspaceTitles: wrapWorkspaceTitles, + previewLineLimit: previewLineLimit + ) + } + @ViewBuilder private var contextMenu: some View { if let setPinned { diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceRow.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceRow.swift index c965a735313..3200fe2ec37 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceRow.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceRow.swift @@ -40,16 +40,10 @@ struct WorkspaceRow: View { Spacer(minLength: 8) - // TimelineView re-evaluates the label every minute so a - // quiet row's relative time ("now" -> "1m" -> ...) advances - // without waiting for an unrelated state change to - // invalidate the row. Minute granularity matches the label. - TimelineView(.everyMinute) { context in - Text(workspace.timestampOrStatus(connectionStatus: connectionStatus, now: context.date)) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(1) - } + Text(workspace.timestampOrStatus(connectionStatus: connectionStatus)) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) } Text(workspace.previewLine)