Merge branch 'main' into issue-151-ssh-remote-port-proxying
This commit is contained in:
commit
d67090994e
61 changed files with 11220 additions and 614 deletions
|
|
@ -82,6 +82,40 @@ func sidebarSelectedWorkspaceForegroundNSColor(opacity: CGFloat) -> NSColor {
|
|||
let clampedOpacity = max(0, min(opacity, 1))
|
||||
return NSColor.white.withAlphaComponent(clampedOpacity)
|
||||
}
|
||||
|
||||
#if compiler(>=6.2)
|
||||
@available(macOS 26.0, *)
|
||||
enum InternalTabDragConfigurationProvider {
|
||||
// These drags only make sense inside cmux. Outside the app, Finder should
|
||||
// reject them instead of materializing placeholder files from the payload.
|
||||
static let value = DragConfiguration(
|
||||
operationsWithinApp: .init(allowCopy: false, allowMove: true, allowDelete: false),
|
||||
operationsOutsideApp: .init(allowCopy: false, allowMove: false, allowDelete: false)
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
private struct InternalTabDragConfigurationModifier: ViewModifier {
|
||||
@ViewBuilder
|
||||
func body(content: Content) -> some View {
|
||||
#if compiler(>=6.2)
|
||||
if #available(macOS 26.0, *) {
|
||||
content.dragConfiguration(InternalTabDragConfigurationProvider.value)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
#else
|
||||
content
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func internalOnlyTabDrag() -> some View {
|
||||
modifier(InternalTabDragConfigurationModifier())
|
||||
}
|
||||
}
|
||||
|
||||
struct ShortcutHintPillBackground: View {
|
||||
var emphasis: Double = 1.0
|
||||
|
||||
|
|
@ -1398,15 +1432,10 @@ struct ContentView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private enum CommandPaletteRestoreFocusIntent {
|
||||
case panel
|
||||
case browserAddressBar
|
||||
}
|
||||
|
||||
private struct CommandPaletteRestoreFocusTarget {
|
||||
let workspaceId: UUID
|
||||
let panelId: UUID
|
||||
let intent: CommandPaletteRestoreFocusIntent
|
||||
let intent: PanelFocusIntent
|
||||
}
|
||||
|
||||
private enum CommandPaletteInputFocusTarget {
|
||||
|
|
@ -5337,7 +5366,7 @@ struct ContentView: View {
|
|||
static func shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss(
|
||||
focusedPanelIsBrowser: Bool,
|
||||
focusedBrowserAddressBarPanelId: UUID?,
|
||||
focusedPanelId: UUID
|
||||
focusedPanelId: UUID?
|
||||
) -> Bool {
|
||||
focusedPanelIsBrowser && focusedBrowserAddressBarPanelId == focusedPanelId
|
||||
}
|
||||
|
|
@ -5383,15 +5412,10 @@ struct ContentView: View {
|
|||
|
||||
private func presentCommandPalette(initialQuery: String) {
|
||||
if let panelContext = focusedPanelContext {
|
||||
let shouldRestoreBrowserAddressBar = Self.shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss(
|
||||
focusedPanelIsBrowser: panelContext.panel.panelType == .browser,
|
||||
focusedBrowserAddressBarPanelId: AppDelegate.shared?.focusedBrowserAddressBarPanelId(),
|
||||
focusedPanelId: panelContext.panelId
|
||||
)
|
||||
commandPaletteRestoreFocusTarget = CommandPaletteRestoreFocusTarget(
|
||||
workspaceId: panelContext.workspace.id,
|
||||
panelId: panelContext.panelId,
|
||||
intent: shouldRestoreBrowserAddressBar ? .browserAddressBar : .panel
|
||||
intent: panelContext.panel.captureFocusIntent(in: observedWindow)
|
||||
)
|
||||
} else {
|
||||
commandPaletteRestoreFocusTarget = nil
|
||||
|
|
@ -5468,7 +5492,7 @@ struct ContentView: View {
|
|||
if let clickedFocusTarget {
|
||||
dlog(
|
||||
"palette.dismiss.backdrop focusTarget panel=\(clickedFocusTarget.panelId.uuidString.prefix(5)) " +
|
||||
"workspace=\(clickedFocusTarget.workspaceId.uuidString.prefix(5)) intent=\(clickedFocusTarget.intent == .browserAddressBar ? "addressBar" : "panel")"
|
||||
"workspace=\(clickedFocusTarget.workspaceId.uuidString.prefix(5)) intent=\(debugCommandPaletteFocusIntent(clickedFocusTarget.intent))"
|
||||
)
|
||||
} else {
|
||||
dlog("palette.dismiss.backdrop focusTarget=nil")
|
||||
|
|
@ -5507,10 +5531,11 @@ struct ContentView: View {
|
|||
let workspaceId = terminalView.tabId,
|
||||
let panelId = terminalView.terminalSurface?.id,
|
||||
tabManager.tabs.contains(where: { $0.id == workspaceId }) {
|
||||
return CommandPaletteRestoreFocusTarget(
|
||||
return commandPaletteRestoreFocusTarget(
|
||||
workspaceId: workspaceId,
|
||||
panelId: panelId,
|
||||
intent: .panel
|
||||
fallbackIntent: .terminal(.surface),
|
||||
in: window
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -5522,10 +5547,11 @@ struct ContentView: View {
|
|||
let workspaceId = terminalView.tabId,
|
||||
let panelId = terminalView.terminalSurface?.id,
|
||||
tabManager.tabs.contains(where: { $0.id == workspaceId }) {
|
||||
return CommandPaletteRestoreFocusTarget(
|
||||
return commandPaletteRestoreFocusTarget(
|
||||
workspaceId: workspaceId,
|
||||
panelId: panelId,
|
||||
intent: .panel
|
||||
fallbackIntent: .terminal(.surface),
|
||||
in: observedWindow
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -5563,16 +5589,35 @@ struct ContentView: View {
|
|||
continue
|
||||
}
|
||||
|
||||
return CommandPaletteRestoreFocusTarget(
|
||||
return commandPaletteRestoreFocusTarget(
|
||||
workspaceId: workspace.id,
|
||||
panelId: panelId,
|
||||
intent: .panel
|
||||
fallbackIntent: .browser(.webView),
|
||||
in: observedWindow
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func commandPaletteRestoreFocusTarget(
|
||||
workspaceId: UUID,
|
||||
panelId: UUID,
|
||||
fallbackIntent: PanelFocusIntent,
|
||||
in window: NSWindow?
|
||||
) -> CommandPaletteRestoreFocusTarget {
|
||||
let intent = tabManager.tabs
|
||||
.first(where: { $0.id == workspaceId })?
|
||||
.panels[panelId]?
|
||||
.captureFocusIntent(in: window) ?? fallbackIntent
|
||||
|
||||
return CommandPaletteRestoreFocusTarget(
|
||||
workspaceId: workspaceId,
|
||||
panelId: panelId,
|
||||
intent: intent
|
||||
)
|
||||
}
|
||||
|
||||
private func restoreCommandPaletteFocus(
|
||||
target: CommandPaletteRestoreFocusTarget,
|
||||
attemptsRemaining: Int
|
||||
|
|
@ -5588,8 +5633,9 @@ struct ContentView: View {
|
|||
if let context = focusedPanelContext,
|
||||
context.workspace.id == target.workspaceId,
|
||||
context.panelId == target.panelId {
|
||||
restoreCommandPaletteInputFocusIfNeeded(target: target, attemptsRemaining: 6)
|
||||
return
|
||||
if context.panel.restoreFocusIntent(target.intent) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
guard attemptsRemaining > 0 else { return }
|
||||
|
|
@ -5598,33 +5644,32 @@ struct ContentView: View {
|
|||
if let context = focusedPanelContext,
|
||||
context.workspace.id == target.workspaceId,
|
||||
context.panelId == target.panelId {
|
||||
restoreCommandPaletteInputFocusIfNeeded(target: target, attemptsRemaining: 6)
|
||||
return
|
||||
if context.panel.restoreFocusIntent(target.intent) {
|
||||
return
|
||||
}
|
||||
}
|
||||
restoreCommandPaletteFocus(target: target, attemptsRemaining: attemptsRemaining - 1)
|
||||
}
|
||||
}
|
||||
|
||||
private func restoreCommandPaletteInputFocusIfNeeded(
|
||||
target: CommandPaletteRestoreFocusTarget,
|
||||
attemptsRemaining: Int
|
||||
) {
|
||||
guard !isCommandPalettePresented else { return }
|
||||
guard target.intent == .browserAddressBar else { return }
|
||||
guard attemptsRemaining > 0 else { return }
|
||||
guard let appDelegate = AppDelegate.shared else { return }
|
||||
|
||||
if appDelegate.requestBrowserAddressBarFocus(panelId: target.panelId) {
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) {
|
||||
restoreCommandPaletteInputFocusIfNeeded(
|
||||
target: target,
|
||||
attemptsRemaining: attemptsRemaining - 1
|
||||
)
|
||||
#if DEBUG
|
||||
private func debugCommandPaletteFocusIntent(_ intent: PanelFocusIntent) -> String {
|
||||
switch intent {
|
||||
case .panel:
|
||||
return "panel"
|
||||
case .terminal(.surface):
|
||||
return "terminal.surface"
|
||||
case .terminal(.findField):
|
||||
return "terminal.findField"
|
||||
case .browser(.webView):
|
||||
return "browser.webView"
|
||||
case .browser(.addressBar):
|
||||
return "browser.addressBar"
|
||||
case .browser(.findField):
|
||||
return "browser.findField"
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private func resetCommandPaletteSearchFocus() {
|
||||
applyCommandPaletteInputFocusPolicy(.search)
|
||||
|
|
@ -8079,6 +8124,7 @@ private enum SidebarHelpMenuAction {
|
|||
case githubIssues
|
||||
case checkForUpdates
|
||||
case sendFeedback
|
||||
case welcome
|
||||
}
|
||||
|
||||
private struct SidebarFeedbackComposerSheet: View {
|
||||
|
|
@ -8455,6 +8501,122 @@ private struct SidebarFeedbackComposerSheet: View {
|
|||
}
|
||||
}
|
||||
|
||||
enum FeedbackComposerBridgeError: LocalizedError {
|
||||
case invalidEmail
|
||||
case emptyMessage
|
||||
case messageTooLong
|
||||
case tooManyImages
|
||||
case invalidImagePath(String)
|
||||
case submissionFailed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidEmail:
|
||||
return "Enter a valid email address."
|
||||
case .emptyMessage:
|
||||
return "Enter a message before sending."
|
||||
case .messageTooLong:
|
||||
return "Your message is too long."
|
||||
case .tooManyImages:
|
||||
return "You can attach up to 10 images."
|
||||
case .invalidImagePath(let path):
|
||||
return "Could not attach image: \(path)"
|
||||
case .submissionFailed(let message):
|
||||
return message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum FeedbackComposerBridge {
|
||||
static func openComposer(in window: NSWindow? = NSApp.keyWindow ?? NSApp.mainWindow) {
|
||||
NotificationCenter.default.post(name: .feedbackComposerRequested, object: window)
|
||||
}
|
||||
|
||||
static func submit(
|
||||
email: String,
|
||||
message: String,
|
||||
imagePaths: [String]
|
||||
) async throws -> Int {
|
||||
let trimmedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedMessage = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
guard isValidEmail(trimmedEmail) else {
|
||||
throw FeedbackComposerBridgeError.invalidEmail
|
||||
}
|
||||
guard normalizedMessage.isEmpty == false else {
|
||||
throw FeedbackComposerBridgeError.emptyMessage
|
||||
}
|
||||
guard message.count <= FeedbackComposerSettings.maxMessageLength else {
|
||||
throw FeedbackComposerBridgeError.messageTooLong
|
||||
}
|
||||
guard imagePaths.count <= FeedbackComposerSettings.maxAttachmentCount else {
|
||||
throw FeedbackComposerBridgeError.tooManyImages
|
||||
}
|
||||
|
||||
let attachments = try imagePaths.map { rawPath in
|
||||
let resolvedURL = URL(fileURLWithPath: rawPath).standardizedFileURL
|
||||
do {
|
||||
return try FeedbackComposerAttachment(url: resolvedURL)
|
||||
} catch {
|
||||
throw FeedbackComposerBridgeError.invalidImagePath(resolvedURL.path)
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try await FeedbackComposerClient.submit(
|
||||
email: trimmedEmail,
|
||||
message: normalizedMessage,
|
||||
attachments: attachments
|
||||
)
|
||||
} catch {
|
||||
throw FeedbackComposerBridgeError.submissionFailed(userFacingMessage(for: error))
|
||||
}
|
||||
|
||||
UserDefaults.standard.set(trimmedEmail, forKey: FeedbackComposerSettings.storedEmailKey)
|
||||
return attachments.count
|
||||
}
|
||||
|
||||
private static func isValidEmail(_ rawValue: String) -> Bool {
|
||||
let email = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard email.isEmpty == false else { return false }
|
||||
let pattern = #"^[A-Z0-9a-z._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$"#
|
||||
return NSPredicate(format: "SELF MATCHES %@", pattern).evaluate(with: email)
|
||||
}
|
||||
|
||||
private static func userFacingMessage(for error: Error) -> String {
|
||||
guard let submissionError = error as? FeedbackComposerSubmissionError else {
|
||||
return "Couldn't send feedback. Please try again."
|
||||
}
|
||||
|
||||
switch submissionError {
|
||||
case .invalidEndpoint:
|
||||
return "Feedback is unavailable right now. Email founders@manaflow.com instead."
|
||||
case .invalidResponse:
|
||||
return "Couldn't send feedback. Please try again."
|
||||
case .attachmentReadFailed:
|
||||
return "One of the selected files could not be attached."
|
||||
case .attachmentPreparationFailed:
|
||||
return "These images are too large to send together. Remove a few and try again."
|
||||
case .transport(let transportError):
|
||||
if transportError.code == .notConnectedToInternet || transportError.code == .networkConnectionLost {
|
||||
return "Couldn't send feedback. Check your connection and try again."
|
||||
}
|
||||
return "Couldn't send feedback. Please try again."
|
||||
case .rejected(let statusCode):
|
||||
switch statusCode {
|
||||
case 400, 413, 415:
|
||||
return "Check your message and attachments, then try again."
|
||||
case 429:
|
||||
return "Too many feedback attempts. Please try again later."
|
||||
case 500...599:
|
||||
return "Feedback is unavailable right now. Email founders@manaflow.com instead."
|
||||
default:
|
||||
return "Couldn't send feedback. Please try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SidebarHelpMenuButton: View {
|
||||
private let docsURL = URL(string: "https://cmux.dev/docs")
|
||||
private let changelogURL = URL(string: "https://cmux.dev/docs/changelog")
|
||||
|
|
@ -8503,6 +8665,12 @@ private struct SidebarHelpMenuButton: View {
|
|||
|
||||
private var helpPopover: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
helpOptionButton(
|
||||
title: String(localized: "sidebar.help.welcome", defaultValue: "Welcome"),
|
||||
action: .welcome,
|
||||
accessibilityIdentifier: "SidebarHelpMenuOptionWelcome",
|
||||
isExternalLink: false
|
||||
)
|
||||
helpOptionButton(
|
||||
title: String(localized: "sidebar.help.sendFeedback", defaultValue: "Send Feedback"),
|
||||
action: .sendFeedback,
|
||||
|
|
@ -8614,14 +8782,17 @@ private struct SidebarHelpMenuButton: View {
|
|||
private func perform(_ action: SidebarHelpMenuAction) {
|
||||
switch action {
|
||||
case .keyboardShortcuts:
|
||||
Task { @MainActor in
|
||||
if let appDelegate = AppDelegate.shared {
|
||||
appDelegate.openPreferencesWindow(
|
||||
debugSource: "sidebarHelpMenu.keyboardShortcuts",
|
||||
navigationTarget: .keyboardShortcuts
|
||||
)
|
||||
} else {
|
||||
AppDelegate.presentPreferencesWindow(navigationTarget: .keyboardShortcuts)
|
||||
isPopoverPresented = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) {
|
||||
Task { @MainActor in
|
||||
if let appDelegate = AppDelegate.shared {
|
||||
appDelegate.openPreferencesWindow(
|
||||
debugSource: "sidebarHelpMenu.keyboardShortcuts",
|
||||
navigationTarget: .keyboardShortcuts
|
||||
)
|
||||
} else {
|
||||
AppDelegate.presentPreferencesWindow(navigationTarget: .keyboardShortcuts)
|
||||
}
|
||||
}
|
||||
}
|
||||
case .docs:
|
||||
|
|
@ -8643,6 +8814,13 @@ private struct SidebarHelpMenuButton: View {
|
|||
case .sendFeedback:
|
||||
isPopoverPresented = false
|
||||
onSendFeedback()
|
||||
case .welcome:
|
||||
isPopoverPresented = false
|
||||
Task { @MainActor in
|
||||
if let appDelegate = AppDelegate.shared {
|
||||
appDelegate.openWelcomeWorkspace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -9538,6 +9716,7 @@ private struct TabItemView: View {
|
|||
dropIndicator = nil
|
||||
return SidebarTabDragPayload.provider(for: tab.id)
|
||||
}
|
||||
.internalOnlyTabDrag()
|
||||
.onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: SidebarTabDropDelegate(
|
||||
targetTabId: tab.id,
|
||||
tabManager: tabManager,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue