diff --git a/Sources/TextBoxInput.swift b/Sources/TextBoxInput.swift index d2d6309817c..9f1a5fc41ad 100644 --- a/Sources/TextBoxInput.swift +++ b/Sources/TextBoxInput.swift @@ -3420,17 +3420,51 @@ struct TextBoxInputView: NSViewRepresentable { let nextText = textView.plainText() let nextAttachments = textView.inlineAttachments() let nextHasPendingAttachmentUpload = textView.hasPendingAttachmentUploadPlaceholder() - let contentChanged = parent.text != nextText - || parent.attachments.map(\.id) != nextAttachments.map(\.id) - || parent.hasPendingAttachmentUpload != nextHasPendingAttachmentUpload - parent.text = nextText - parent.attachments = nextAttachments - parent.hasPendingAttachmentUpload = nextHasPendingAttachmentUpload + let didChangeText = parent.text != nextText + let didChangeAttachments = Self.attachmentsDiffer(parent.attachments, nextAttachments) + let didChangePendingUpload = parent.hasPendingAttachmentUpload != nextHasPendingAttachmentUpload + let contentChanged = didChangeText || didChangeAttachments || didChangePendingUpload + if didChangeText { + parent.text = nextText + } + if didChangeAttachments { + parent.attachments = nextAttachments + } + if didChangePendingUpload { + parent.hasPendingAttachmentUpload = nextHasPendingAttachmentUpload + } if contentChanged { parent.onContentChanged() } } + private static func attachmentsDiffer( + _ currentAttachments: [TextBoxAttachment], + _ nextAttachments: [TextBoxAttachment] + ) -> Bool { + guard currentAttachments.count == nextAttachments.count else { return true } + return zip(currentAttachments, nextAttachments).contains { current, next in + current.id != next.id + || current.displayName != next.displayName + || current.submissionText != next.submissionText + || current.submissionPath != next.submissionPath + || current.localURL != next.localURL + || current.cleanupLocalURLWhenDisposed != next.cleanupLocalURLWhenDisposed + || !sameThumbnail(current.thumbnail, next.thumbnail) + } + } + + private static func sameThumbnail(_ currentThumbnail: NSImage?, _ nextThumbnail: NSImage?) -> Bool { + switch (currentThumbnail, nextThumbnail) { + case (nil, nil): + true + case let (current?, next?): + current === next + default: + false + } + } + func recalculateHeight(_ textView: NSTextView) { guard let layoutManager = textView.layoutManager, let textContainer = textView.textContainer else { return } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 82cabb06bbc..983163c8328 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -530,6 +530,7 @@ C13519000000000000000001 /* TerminalStartupEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13519000000000000000002 /* TerminalStartupEnvironment.swift */; }; A5001532 /* TerminalWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001531 /* TerminalWindowPortal.swift */; }; D0B10006A1B2C3D4E5F60001 /* TerminalWindowPortalDebug.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10007A1B2C3D4E5F60001 /* TerminalWindowPortalDebug.swift */; }; + C5345001A1B2C3D4E5F60719 /* TextBoxContentSyncTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5345002A1B2C3D4E5F60719 /* TextBoxContentSyncTests.swift */; }; C0DE7B100000000000000001 /* TextBoxInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE7B100000000000000002 /* TextBoxInput.swift */; }; C0DE7B310000000000000001 /* TextBoxMentionCachedIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE7B310000000000000002 /* TextBoxMentionCachedIndex.swift */; }; C0DE7B250000000000000001 /* TextBoxMentionCandidate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE7B250000000000000002 /* TextBoxMentionCandidate.swift */; }; @@ -1169,6 +1170,7 @@ C13519000000000000000002 /* TerminalStartupEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalStartupEnvironment.swift; sourceTree = ""; }; A5001531 /* TerminalWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindowPortal.swift; sourceTree = ""; }; D0B10007A1B2C3D4E5F60001 /* TerminalWindowPortalDebug.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindowPortalDebug.swift; sourceTree = ""; }; + C5345002A1B2C3D4E5F60719 /* TextBoxContentSyncTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextBoxContentSyncTests.swift; sourceTree = ""; }; C0DE7B100000000000000002 /* TextBoxInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextBoxInput.swift; sourceTree = ""; }; C0DE7B310000000000000002 /* TextBoxMentionCachedIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextBoxMentionCachedIndex.swift; sourceTree = ""; }; C0DE7B250000000000000002 /* TextBoxMentionCandidate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextBoxMentionCandidate.swift; sourceTree = ""; }; @@ -1849,6 +1851,7 @@ F5310001A1B2C3D4E5F60718 /* RovoDevHookConfigTests.swift */, FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */, F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */, + C5345002A1B2C3D4E5F60719 /* TextBoxContentSyncTests.swift */, C0DE7B300000000000000002 /* TextBoxMentionCompletionTests.swift */, C1713005C1713005C1713005 /* CommandPaletteShortcutCustomizationTests.swift */, 17FCD4CC61D54A2F8F2F463D /* AppDelegateBareSpaceShortcutRoutingTests.swift */, @@ -2881,6 +2884,7 @@ A5A5A503A1B2C3D4E5F60718 /* TerminalNotificationQueueTests.swift in Sources */, A5E380700000000000000001 /* TerminalNotificationSocketActionTests.swift in Sources */, C0DE53360000000000000001 /* TerminalSearchOverlayMouseReleaseTests.swift in Sources */, + C5345001A1B2C3D4E5F60719 /* TextBoxContentSyncTests.swift in Sources */, C0DE7B300000000000000001 /* TextBoxMentionCompletionTests.swift in Sources */, F50030040000000000000001 /* TitlebarInteractiveControlTests.swift in Sources */, D3284001A1B2C3D4E5F60718 /* TraditionalChineseIMENumpadRegressionTests.swift in Sources */, diff --git a/cmuxTests/TextBoxContentSyncTests.swift b/cmuxTests/TextBoxContentSyncTests.swift new file mode 100644 index 00000000000..129f67e5079 --- /dev/null +++ b/cmuxTests/TextBoxContentSyncTests.swift @@ -0,0 +1,79 @@ +import AppKit +import SwiftUI +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +struct TextBoxContentSyncTests { + @Test func contentSyncSkipsUnchangedSwiftUIBindings() { + var text = "same" + var attachments: [TextBoxAttachment] = [] + var height: CGFloat = 24 + var hasPendingAttachmentUpload = false + var textWriteCount = 0 + var attachmentWriteCount = 0 + var pendingWriteCount = 0 + var contentChangeCount = 0 + + let inputView = TextBoxInputView( + text: Binding( + get: { text }, + set: { newValue in + textWriteCount += 1 + text = newValue + } + ), + attachments: Binding( + get: { attachments }, + set: { newValue in + attachmentWriteCount += 1 + attachments = newValue + } + ), + textViewHeight: Binding(get: { height }, set: { height = $0 }), + hasPendingAttachmentUpload: Binding( + get: { hasPendingAttachmentUpload }, + set: { newValue in + pendingWriteCount += 1 + hasPendingAttachmentUpload = newValue + } + ), + font: NSFont.systemFont(ofSize: 14), + backgroundColor: .textBackgroundColor, + foregroundColor: .labelColor, + terminalTitle: "codex", + completionRootDirectory: nil, + onSubmit: {}, + onEscape: {}, + onFocusTextBox: {}, + onToggleFocus: {}, + onForwardText: { _, _ in }, + onForwardKey: { _ in }, + onForwardControl: { _ in }, + onPaste: { _, _ in false }, + onInsertFileURLs: { _, _ in false }, + onChooseFiles: {}, + onContentChanged: { contentChangeCount += 1 }, + onTextViewCreated: { _ in }, + onTextViewMovedToWindow: { _ in }, + onTextViewDismantled: { _ in } + ) + let coordinator = TextBoxInputView.Coordinator(parent: inputView) + let textView = TextBoxInputTextView(frame: NSRect(x: 0, y: 0, width: 320, height: 30)) + textView.font = NSFont.systemFont(ofSize: 14) + textView.textColor = .labelColor + textView.string = "same" + + coordinator.textDidChange(Notification(name: NSText.didChangeNotification, object: textView)) + + #expect(textWriteCount == 0) + #expect(attachmentWriteCount == 0) + #expect(pendingWriteCount == 0) + #expect(contentChangeCount == 0) + } +}