From 523387442555881bfb4e16ab4e7f6c20fb24eddb Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 11 Mar 2026 18:32:23 -0700 Subject: [PATCH 1/3] ok --- Sources/Panels/BrowserPanel.swift | 105 ++++++++++++++++++ Sources/TerminalController.swift | 11 +- Sources/Workspace.swift | 57 ++++++++++ cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 70 ++++++++++++ 4 files changed, 233 insertions(+), 10 deletions(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 5c2d7cd8..adda9205 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2710,6 +2710,111 @@ final class BrowserPanel: Panel, ObservableObject { } } +extension BrowserPanel { + private var needsWorkspaceContextReset: Bool { + shouldRenderWebView || + currentURL != nil || + !pageTitle.isEmpty || + faviconPNGData != nil || + searchState != nil || + nativeCanGoBack || + nativeCanGoForward || + restoredHistoryCurrentURL != nil || + !restoredBackHistoryStack.isEmpty || + !restoredForwardHistoryStack.isEmpty || + estimatedProgress > 0 || + isLoading || + isDownloading || + activeDownloadCount != 0 || + preferredDeveloperToolsVisible || + webView.superview != nil + } + + func resetForWorkspaceContextChange(reason: String) { + guard needsWorkspaceContextReset else { +#if DEBUG + dlog( + "browser.contextReset.skip panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) render=\(shouldRenderWebView ? 1 : 0)" + ) +#endif + return + } + +#if DEBUG + dlog( + "browser.contextReset.begin panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) render=\(shouldRenderWebView ? 1 : 0) " + + "url=\(preferredURLStringForOmnibar() ?? "nil")" + ) +#endif + + _ = hideDeveloperTools() + cancelDeveloperToolsRestoreRetry() + preferredDeveloperToolsVisible = false + preferredDeveloperToolsPresentation = .unknown + forceDeveloperToolsRefreshOnNextAttach = false + developerToolsDetachedOpenGraceDeadline = nil + developerToolsRestoreRetryAttempt = 0 + preferredAttachedDeveloperToolsWidth = nil + preferredAttachedDeveloperToolsWidthFraction = nil + + loadingEndWorkItem?.cancel() + loadingEndWorkItem = nil + loadingGeneration &+= 1 + activeDownloadCount = 0 + isDownloading = false + isLoading = false + estimatedProgress = 0 + nativeCanGoBack = false + nativeCanGoForward = false + navigationDelegate?.lastAttemptedURL = nil + abandonRestoredSessionHistoryIfNeeded() + + pendingAddressBarFocusRequestId = nil + preferredFocusIntent = .addressBar + suppressOmnibarAutofocusUntil = nil + suppressWebViewFocusUntil = nil + endSuppressWebViewFocusForAddressBar() + invalidateAddressBarPageFocusRestoreAttempts() + invalidateSearchFocusRequests(reason: "contextReset") + searchState = nil + + pageTitle = "" + currentURL = nil + faviconPNGData = nil + lastFaviconURLString = nil + activePortalHostLease = nil + pendingDistinctPortalHostReplacementPaneId = nil + lockedPortalHost = nil + + let oldWebView = webView + webViewObservers.removeAll() + webViewCancellables.removeAll() + BrowserWindowPortalRegistry.detach(webView: oldWebView) + oldWebView.stopLoading() + oldWebView.navigationDelegate = nil + oldWebView.uiDelegate = nil + if let oldCmuxWebView = oldWebView as? CmuxWebView { + oldCmuxWebView.onContextMenuDownloadStateChanged = nil + } + + let replacement = Self.makeWebView() + webView = replacement + webViewInstanceID = UUID() + shouldRenderWebView = false + bindWebView(replacement) + refreshNavigationAvailability() + +#if DEBUG + dlog( + "browser.contextReset.end panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) instance=\(webViewInstanceID.uuidString.prefix(6))" + ) +#endif + } +} + func resolveBrowserNavigableURL(_ input: String) -> URL? { let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 44fd70cf..6a708ae2 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -13813,16 +13813,7 @@ class TerminalController { result = "ERROR: Tab not found" return } - tab.statusEntries.removeAll() - tab.logEntries.removeAll() - tab.progress = nil - tab.gitBranch = nil - tab.panelGitBranches.removeAll() - tab.pullRequest = nil - tab.panelPullRequests.removeAll() - tab.surfaceListeningPorts.removeAll() - tab.listeningPorts.removeAll() - tab.metadataBlocks.removeAll() + tab.resetSidebarContext(reason: "reset_sidebar") } return result } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 1ceca0b4..3f9e25ff 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1686,6 +1686,63 @@ final class Workspace: Identifiable, ObservableObject { } } + func resetSidebarContext(reason: String = "unspecified") { + statusEntries.removeAll() + logEntries.removeAll() + progress = nil + gitBranch = nil + panelGitBranches.removeAll() + pullRequest = nil + panelPullRequests.removeAll() + surfaceListeningPorts.removeAll() + listeningPorts.removeAll() + metadataBlocks.removeAll() + resetBrowserPanelsForContextChange(reason: reason) + } + + func resetBrowserPanelsForContextChange(reason: String) { + let browserPanels = panels.values.compactMap { $0 as? BrowserPanel } + guard !browserPanels.isEmpty else { return } + +#if DEBUG + dlog( + "workspace.contextReset.browserPanels workspace=\(id.uuidString.prefix(5)) " + + "reason=\(reason) count=\(browserPanels.count)" + ) +#endif + + for browserPanel in browserPanels { + browserPanel.resetForWorkspaceContextChange(reason: reason) + + guard let tabId = surfaceIdFromPanelId(browserPanel.id), + let existing = bonsplitController.tab(tabId) else { + continue + } + + let nextTitle = browserPanel.displayTitle + if panelTitles[browserPanel.id] != nextTitle { + panelTitles[browserPanel.id] = nextTitle + } + + let resolvedTitle = resolvedPanelTitle(panelId: browserPanel.id, fallback: nextTitle) + let titleUpdate: String? = existing.title == resolvedTitle ? nil : resolvedTitle + let faviconUpdate: Data?? = existing.iconImageData == nil ? nil : .some(nil) + let loadingUpdate: Bool? = existing.isLoading ? false : nil + + guard titleUpdate != nil || faviconUpdate != nil || loadingUpdate != nil else { + continue + } + + bonsplitController.updateTab( + tabId, + title: titleUpdate, + iconImageData: faviconUpdate, + hasCustomTitle: panelCustomTitles[browserPanel.id] != nil, + isLoading: loadingUpdate + ) + } + } + @discardableResult func updatePanelTitle(panelId: UUID, title: String) -> Bool { let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 3db83f10..68513559 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -2430,6 +2430,76 @@ final class BrowserSessionHistoryRestoreTests: XCTestCase { XCTAssertFalse(panel.shouldRenderWebView) } + + func testResetSidebarContextClearsBrowserPanelsIntoNewTabState() throws { + let workspace = Workspace() + let paneId = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first) + let contextPanelId = try XCTUnwrap(workspace.focusedPanelId) + let browser = try XCTUnwrap( + workspace.newBrowserSurface( + inPane: paneId, + url: URL(string: "https://example.com"), + focus: false + ) + ) + + browser.restoreSessionNavigationHistory( + backHistoryURLStrings: ["https://example.com/prev"], + forwardHistoryURLStrings: ["https://example.com/next"], + currentURLString: "https://example.com/current" + ) + browser.startFind() + + workspace.statusEntries["task"] = SidebarStatusEntry(key: "task", value: "Issue #1208") + workspace.metadataBlocks["notes"] = SidebarMetadataBlock( + key: "notes", + markdown: "test", + priority: 0, + timestamp: Date() + ) + workspace.progress = SidebarProgressState(value: 0.5, label: "Loading") + workspace.updatePanelGitBranch(panelId: contextPanelId, branch: "issue-1208", isDirty: false) + workspace.updatePanelPullRequest( + panelId: contextPanelId, + number: 1208, + label: "PR", + url: try XCTUnwrap(URL(string: "https://example.com/pull/1208")), + status: .open + ) + workspace.surfaceListeningPorts[contextPanelId] = [3000] + workspace.recomputeListeningPorts() + + XCTAssertTrue(browser.shouldRenderWebView) + XCTAssertNotNil(browser.preferredURLStringForOmnibar()) + XCTAssertTrue(browser.canGoBack) + XCTAssertTrue(browser.canGoForward) + XCTAssertNotNil(browser.searchState) + XCTAssertFalse(workspace.statusEntries.isEmpty) + XCTAssertFalse(workspace.metadataBlocks.isEmpty) + XCTAssertNotNil(workspace.progress) + XCTAssertNotNil(workspace.gitBranch) + XCTAssertNotNil(workspace.pullRequest) + XCTAssertEqual(workspace.listeningPorts, [3000]) + + workspace.resetSidebarContext(reason: "test") + + XCTAssertTrue(workspace.statusEntries.isEmpty) + XCTAssertTrue(workspace.logEntries.isEmpty) + XCTAssertTrue(workspace.metadataBlocks.isEmpty) + XCTAssertNil(workspace.progress) + XCTAssertNil(workspace.gitBranch) + XCTAssertTrue(workspace.panelGitBranches.isEmpty) + XCTAssertNil(workspace.pullRequest) + XCTAssertTrue(workspace.panelPullRequests.isEmpty) + XCTAssertTrue(workspace.surfaceListeningPorts.isEmpty) + XCTAssertTrue(workspace.listeningPorts.isEmpty) + XCTAssertFalse(browser.shouldRenderWebView) + XCTAssertNil(browser.preferredURLStringForOmnibar()) + XCTAssertFalse(browser.canGoBack) + XCTAssertFalse(browser.canGoForward) + XCTAssertNil(browser.searchState) + } + } @MainActor From 81618bf3cf3c46458661e34f97193253fbb52ed3 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 11 Mar 2026 18:46:33 -0700 Subject: [PATCH 2/3] Fix stale browser pane content after drag splits --- Sources/BrowserWindowPortal.swift | 14 ++++++++++++++ Sources/Workspace.swift | 15 +++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index 92ba0fe0..ac1048cf 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -2786,9 +2786,11 @@ final class WindowBrowserPortal: NSObject { } return } + let previousTransientRecoveryReason = entry.transientRecoveryReason func hideContainerView(reason: String) { containerView.setPaneTopChromeHeight(0) containerView.setSearchOverlay(nil) + containerView.setPaneDropContext(nil) containerView.setDropZoneOverlay(zone: nil) if !containerView.isHidden, webView.superview === containerView { webView.browserPortalNotifyHidden(reason: reason) @@ -2819,6 +2821,7 @@ final class WindowBrowserPortal: NSObject { "reason=\(reason) frame=\(browserPortalDebugFrame(containerView.frame))" ) #endif + containerView.setPaneDropContext(nil) containerView.setDropZoneOverlay(zone: nil) return true } @@ -2962,6 +2965,8 @@ final class WindowBrowserPortal: NSObject { "reason=hostBoundsNotReady frame=\(browserPortalDebugFrame(containerView.frame))" ) #endif + containerView.setPaneDropContext(nil) + containerView.setDropZoneOverlay(zone: nil) return } } else { @@ -3024,6 +3029,9 @@ final class WindowBrowserPortal: NSObject { shouldHide && entry.visibleInUI && !containerView.isHidden + let recoveredFromTransientGeometry = + previousTransientRecoveryReason != nil && + transientRecoveryReason == nil #if DEBUG let frameWasClamped = hasFiniteFrame && !Self.rectApproximatelyEqual(frameInHost, targetFrame) if frameWasClamped { @@ -3152,10 +3160,16 @@ final class WindowBrowserPortal: NSObject { } containerView.setPaneTopChromeHeight(shouldHide ? 0 : entry.paneTopChromeHeight) containerView.setSearchOverlay(shouldHide ? nil : entry.searchOverlay) + containerView.setPaneDropContext(containerView.isHidden ? nil : entry.paneDropContext) containerView.setDropZoneOverlay(zone: containerView.isHidden ? nil : entry.dropZone) if revealedForDisplay { refreshReasons.append("reveal") } + if recoveredFromTransientGeometry { + // Drag/reparent churn can recover to the same visible frame we preserved. + // Force a redraw so WebKit doesn't keep stale tiles until a later resize/focus. + refreshReasons.append("transientRecovery") + } if forcePresentationRefresh { refreshReasons.append("anchor") } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 3f9e25ff..ed48b3a0 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -4896,6 +4896,21 @@ extension Workspace: BonsplitDelegate { "originalKinds=[\(paneKindSummary(originalPane))] newKinds=[\(paneKindSummary(newPane))]" ) #endif + let rearmBrowserPortalHostReplacement: (PaneID, String) -> Void = { paneId, reason in + for tab in controller.tabs(inPane: paneId) { + guard let panelId = self.panelIdFromSurfaceId(tab.id), + let browserPanel = self.browserPanel(for: panelId) else { + continue + } + browserPanel.preparePortalHostReplacementForNextDistinctClaim( + inPane: paneId, + reason: reason + ) + } + } + rearmBrowserPortalHostReplacement(originalPane, "workspace.didSplit.original") + rearmBrowserPortalHostReplacement(newPane, "workspace.didSplit.new") + // Only auto-create a terminal if the split came from bonsplit UI. // Programmatic splits via newTerminalSplit() set isProgrammaticSplit and handle their own panels. guard !isProgrammaticSplit else { From 8aafb68935d77c3a2df51f01147e16f44c486a6a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 11 Mar 2026 19:16:17 -0700 Subject: [PATCH 3/3] Fix browser pane drag/drop follow-ups --- Sources/BrowserWindowPortal.swift | 9 ++- Sources/Panels/BrowserPanel.swift | 55 ++++++++++++++----- Sources/Panels/BrowserPanelView.swift | 9 ++- Sources/Workspace.swift | 12 +--- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 13 +++++ 5 files changed, 67 insertions(+), 31 deletions(-) diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index f102cf9a..6bf9a18d 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -2856,6 +2856,7 @@ final class WindowBrowserPortal: NSObject { containerView.setPaneTopChromeHeight(0) containerView.setSearchOverlay(nil) containerView.setPaneDropContext(nil) + containerView.setPortalDragDropZone(nil) containerView.setDropZoneOverlay(zone: nil) if !containerView.isHidden, webView.superview === containerView { webView.browserPortalNotifyHidden(reason: reason) @@ -2887,6 +2888,7 @@ final class WindowBrowserPortal: NSObject { ) #endif containerView.setPaneDropContext(nil) + containerView.setPortalDragDropZone(nil) containerView.setDropZoneOverlay(zone: nil) return true } @@ -3031,6 +3033,7 @@ final class WindowBrowserPortal: NSObject { ) #endif containerView.setPaneDropContext(nil) + containerView.setPortalDragDropZone(nil) containerView.setDropZoneOverlay(zone: nil) return } @@ -3096,7 +3099,8 @@ final class WindowBrowserPortal: NSObject { !containerView.isHidden let recoveredFromTransientGeometry = previousTransientRecoveryReason != nil && - transientRecoveryReason == nil + transientRecoveryReason == nil && + !shouldHide #if DEBUG let frameWasClamped = hasFiniteFrame && !Self.rectApproximatelyEqual(frameInHost, targetFrame) if frameWasClamped { @@ -3139,6 +3143,7 @@ final class WindowBrowserPortal: NSObject { if hasExistingVisibleFrame { containerView.setDropZoneOverlay(zone: nil) containerView.setPaneDropContext(nil) + containerView.setPortalDragDropZone(nil) return } } @@ -3268,7 +3273,7 @@ final class WindowBrowserPortal: NSObject { containerOwnsWebView && hostView.reapplyHostedInspectorDividerIfNeeded(in: containerView, reason: "portal.sync") if !shouldHide, containerOwnsWebView, !refreshReasons.isEmpty { - if hostedInspectorAdjustedDuringSync { + if hostedInspectorAdjustedDuringSync && !recoveredFromTransientGeometry { #if DEBUG dlog( "browser.portal.refresh.skip web=\(browserPortalDebugToken(webView)) " + diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index adda9205..61daf94e 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2005,6 +2005,12 @@ final class BrowserPanel: Panel, ObservableObject { setupObservers(for: webView) } + private func isCurrentWebView(_ candidate: WKWebView, instanceID: UUID? = nil) -> Bool { + guard candidate === webView else { return false } + guard let instanceID else { return true } + return instanceID == webViewInstanceID + } + init(workspaceId: UUID, initialURL: URL? = nil, bypassInsecureHTTPHostOnce: String? = nil) { self.id = UUID() self.workspaceId = workspaceId @@ -2020,15 +2026,16 @@ final class BrowserPanel: Panel, ObservableObject { navDelegate.didFinish = { webView in BrowserHistoryStore.shared.recordVisit(url: webView.url, title: webView.title) Task { @MainActor [weak self] in - self?.refreshFavicon(from: webView) - self?.applyBrowserThemeModeIfNeeded() + guard let self, self.isCurrentWebView(webView) else { return } + self.refreshFavicon(from: webView) + self.applyBrowserThemeModeIfNeeded() // Keep find-in-page open through load completion and refresh matches for the new DOM. - self?.restoreFindStateAfterNavigation(replaySearch: true) + self.restoreFindStateAfterNavigation(replaySearch: true) } } - navDelegate.didFailNavigation = { [weak self] _, failedURL in + navDelegate.didFailNavigation = { [weak self] failedWebView, failedURL in Task { @MainActor in - guard let self else { return } + guard let self, self.isCurrentWebView(failedWebView) else { return } // Clear stale title/favicon from the previous page so the tab // shows the failed URL instead of the old page's branding. self.pageTitle = failedURL.isEmpty ? "" : failedURL @@ -2162,10 +2169,13 @@ final class BrowserPanel: Panel, ObservableObject { } private func setupObservers(for webView: WKWebView) { + let observedWebViewInstanceID = webViewInstanceID + // URL changes let urlObserver = webView.observe(\.url, options: [.new]) { [weak self] webView, _ in Task { @MainActor in - self?.currentURL = webView.url + guard let self, self.isCurrentWebView(webView, instanceID: observedWebViewInstanceID) else { return } + self.currentURL = webView.url } } webViewObservers.append(urlObserver) @@ -2173,12 +2183,13 @@ final class BrowserPanel: Panel, ObservableObject { // Title changes let titleObserver = webView.observe(\.title, options: [.new]) { [weak self] webView, _ in Task { @MainActor in + guard let self, self.isCurrentWebView(webView, instanceID: observedWebViewInstanceID) else { return } // Keep showing the last non-empty title while the new navigation is loading. // WebKit often clears title to nil/"" during reload/navigation, which causes // a distracting tab-title flash (e.g. to host/URL). Only accept non-empty titles. let trimmed = (webView.title ?? "").trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } - self?.pageTitle = trimmed + self.pageTitle = trimmed } } webViewObservers.append(titleObserver) @@ -2186,7 +2197,8 @@ final class BrowserPanel: Panel, ObservableObject { // Loading state let loadingObserver = webView.observe(\.isLoading, options: [.new]) { [weak self] webView, _ in Task { @MainActor in - self?.handleWebViewLoadingChanged(webView.isLoading) + guard let self, self.isCurrentWebView(webView, instanceID: observedWebViewInstanceID) else { return } + self.handleWebViewLoadingChanged(webView.isLoading) } } webViewObservers.append(loadingObserver) @@ -2194,7 +2206,7 @@ final class BrowserPanel: Panel, ObservableObject { // Can go back let backObserver = webView.observe(\.canGoBack, options: [.new]) { [weak self] webView, _ in Task { @MainActor in - guard let self else { return } + guard let self, self.isCurrentWebView(webView, instanceID: observedWebViewInstanceID) else { return } self.nativeCanGoBack = webView.canGoBack self.refreshNavigationAvailability() } @@ -2204,7 +2216,7 @@ final class BrowserPanel: Panel, ObservableObject { // Can go forward let forwardObserver = webView.observe(\.canGoForward, options: [.new]) { [weak self] webView, _ in Task { @MainActor in - guard let self else { return } + guard let self, self.isCurrentWebView(webView, instanceID: observedWebViewInstanceID) else { return } self.nativeCanGoForward = webView.canGoForward self.refreshNavigationAvailability() } @@ -2214,7 +2226,8 @@ final class BrowserPanel: Panel, ObservableObject { // Progress let progressObserver = webView.observe(\.estimatedProgress, options: [.new]) { [weak self] webView, _ in Task { @MainActor in - self?.estimatedProgress = webView.estimatedProgress + guard let self, self.isCurrentWebView(webView, instanceID: observedWebViewInstanceID) else { return } + self.estimatedProgress = webView.estimatedProgress } } webViewObservers.append(progressObserver) @@ -2250,6 +2263,9 @@ final class BrowserPanel: Panel, ObservableObject { webViewObservers.removeAll() webViewCancellables.removeAll() + faviconTask?.cancel() + faviconTask = nil + faviconRefreshGeneration &+= 1 BrowserWindowPortalRegistry.detach(webView: terminatedWebView) terminatedWebView.stopLoading() terminatedWebView.navigationDelegate = nil @@ -2260,11 +2276,12 @@ final class BrowserPanel: Panel, ObservableObject { let replacement = Self.makeWebView() replacement.pageZoom = desiredZoom - webView = replacement webViewInstanceID = UUID() + webView = replacement shouldRenderWebView = wasRenderable bindWebView(replacement) + applyBrowserThemeModeIfNeeded() if !history.backHistoryURLStrings.isEmpty || !history.forwardHistoryURLStrings.isEmpty { restoreSessionNavigationHistory( @@ -2360,9 +2377,11 @@ final class BrowserPanel: Panel, ObservableObject { guard let scheme = pageURL.scheme?.lowercased(), scheme == "http" || scheme == "https" else { return } faviconRefreshGeneration &+= 1 let refreshGeneration = faviconRefreshGeneration + let refreshWebViewInstanceID = webViewInstanceID faviconTask = Task { @MainActor [weak self, weak webView] in guard let self, let webView else { return } + guard self.isCurrentWebView(webView, instanceID: refreshWebViewInstanceID) else { return } guard self.isCurrentFaviconRefresh(generation: refreshGeneration) else { return } // Try to discover the best icon URL from the document. @@ -2397,6 +2416,7 @@ final class BrowserPanel: Panel, ObservableObject { discoveredURL = u } } + guard self.isCurrentWebView(webView, instanceID: refreshWebViewInstanceID) else { return } guard self.isCurrentFaviconRefresh(generation: refreshGeneration) else { return } let fallbackURL = URL(string: "/favicon.ico", relativeTo: pageURL) @@ -2422,6 +2442,7 @@ final class BrowserPanel: Panel, ObservableObject { } catch { return } + guard self.isCurrentWebView(webView, instanceID: refreshWebViewInstanceID) else { return } guard self.isCurrentFaviconRefresh(generation: refreshGeneration) else { return } guard let http = response as? HTTPURLResponse, @@ -2701,12 +2722,12 @@ final class BrowserPanel: Panel, ObservableObject { if let detachedDeveloperToolsWindowCloseObserver { NotificationCenter.default.removeObserver(detachedDeveloperToolsWindowCloseObserver) } + webViewObservers.removeAll() + webViewCancellables.removeAll() let webView = webView Task { @MainActor in BrowserWindowPortalRegistry.detach(webView: webView) } - webViewObservers.removeAll() - webViewCancellables.removeAll() } } @@ -2761,6 +2782,9 @@ extension BrowserPanel { loadingEndWorkItem?.cancel() loadingEndWorkItem = nil + faviconTask?.cancel() + faviconTask = nil + faviconRefreshGeneration &+= 1 loadingGeneration &+= 1 activeDownloadCount = 0 isDownloading = false @@ -2800,10 +2824,11 @@ extension BrowserPanel { } let replacement = Self.makeWebView() - webView = replacement webViewInstanceID = UUID() + webView = replacement shouldRenderWebView = false bindWebView(replacement) + applyBrowserThemeModeIfNeeded() refreshNavigationAvailability() #if DEBUG diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index e029499b..7122edc7 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -5523,12 +5523,11 @@ struct WebViewRepresentable: NSViewRepresentable { } // SwiftUI can transiently dismantle/rebuild the browser host view during split - // rearrangement. Do not detach the portal-hosted WKWebView here; explicit detach - // still happens on real web view replacement and panel teardown. + // rearrangement. Do not detach the portal-hosted WKWebView or clear its pane-drop + // context here; explicit teardown still happens on real web view replacement and + // panel teardown, and preserving this state lets internal tab drags re-enter the + // browser pane while SwiftUI churns underneath. BrowserWindowPortalRegistry.updateDropZoneOverlay(for: webView, zone: nil) - BrowserWindowPortalRegistry.updatePaneTopChromeHeight(for: webView, height: 0) - BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: nil) - BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: nil) coordinator.lastPortalHostId = nil coordinator.lastSynchronizedHostGeometryRevision = 0 } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index ed48b3a0..1bc7e1ed 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1713,29 +1713,23 @@ final class Workspace: Identifiable, ObservableObject { for browserPanel in browserPanels { browserPanel.resetForWorkspaceContextChange(reason: reason) + let nextTitle = browserPanel.displayTitle + _ = updatePanelTitle(panelId: browserPanel.id, title: nextTitle) guard let tabId = surfaceIdFromPanelId(browserPanel.id), let existing = bonsplitController.tab(tabId) else { continue } - let nextTitle = browserPanel.displayTitle - if panelTitles[browserPanel.id] != nextTitle { - panelTitles[browserPanel.id] = nextTitle - } - - let resolvedTitle = resolvedPanelTitle(panelId: browserPanel.id, fallback: nextTitle) - let titleUpdate: String? = existing.title == resolvedTitle ? nil : resolvedTitle let faviconUpdate: Data?? = existing.iconImageData == nil ? nil : .some(nil) let loadingUpdate: Bool? = existing.isLoading ? false : nil - guard titleUpdate != nil || faviconUpdate != nil || loadingUpdate != nil else { + guard faviconUpdate != nil || loadingUpdate != nil else { continue } bonsplitController.updateTab( tabId, - title: titleUpdate, iconImageData: faviconUpdate, hasCustomTitle: panelCustomTitles[browserPanel.id] != nil, isLoading: loadingUpdate diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 982cc6a5..01406fd2 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -2466,6 +2466,14 @@ final class BrowserSessionHistoryRestoreTests: XCTestCase { url: try XCTUnwrap(URL(string: "https://example.com/pull/1208")), status: .open ) + workspace.logEntries.append( + SidebarLogEntry( + message: "Issue #1208", + level: .info, + source: "test", + timestamp: Date() + ) + ) workspace.surfaceListeningPorts[contextPanelId] = [3000] workspace.recomputeListeningPorts() @@ -2475,12 +2483,15 @@ final class BrowserSessionHistoryRestoreTests: XCTestCase { XCTAssertTrue(browser.canGoForward) XCTAssertNotNil(browser.searchState) XCTAssertFalse(workspace.statusEntries.isEmpty) + XCTAssertFalse(workspace.logEntries.isEmpty) XCTAssertFalse(workspace.metadataBlocks.isEmpty) XCTAssertNotNil(workspace.progress) XCTAssertNotNil(workspace.gitBranch) XCTAssertNotNil(workspace.pullRequest) XCTAssertEqual(workspace.listeningPorts, [3000]) + let priorWebView = browser.webView + let priorInstanceID = browser.webViewInstanceID workspace.resetSidebarContext(reason: "test") XCTAssertTrue(workspace.statusEntries.isEmpty) @@ -2498,6 +2509,8 @@ final class BrowserSessionHistoryRestoreTests: XCTestCase { XCTAssertFalse(browser.canGoBack) XCTAssertFalse(browser.canGoForward) XCTAssertNil(browser.searchState) + XCTAssertFalse(browser.webView === priorWebView) + XCTAssertNotEqual(browser.webViewInstanceID, priorInstanceID) } }