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
Expand Up @@ -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
)
}
}
}
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import CmuxMobileShellModel
import CmuxMobileSupport
import Foundation
import SwiftUI

/// Display-only derivations of ``MobileWorkspacePreview`` used by the workspace
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
})
Comment on lines +94 to +100

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 NavigationLink + simultaneousGesture may double-push in the compact stack

NavigationLink(value: workspace.id) and simultaneousGesture(TapGesture().onEnded { selectWorkspace(workspace.id) }) both fire on the same tap. selectWorkspace (line 182–184 of WorkspaceShellView) guards with compactNavigationPath.last != id before setting the path to [id] — but that guard is only effective if the NavigationLink has already appended id to the path first. If the SwiftUI TapGesture fires before UIKit/NavigationStack processes the link activation (making the path still []), selectWorkspace sets compactNavigationPath = [id] and then the NavigationLink appends id again, yielding [id, id]. The NavigationStack would then push the workspace detail view twice, leaving a stale intermediate destination in the back-stack. Since simultaneousGesture makes the ordering undefined, this race is present even though it may not reproduce consistently on any given device.

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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading