diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 1d86234628e7..3bfdfb34ffef 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -633,6 +633,8 @@ final class FileDropOverlayView: NSView { } } + /// Commits the drop: routes to the active WKWebView, a terminal under the cursor, + /// or the folder-drop handler when no terminal is present (e.g. sidebar area). override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { let hasLocalDraggingSource = sender.draggingSource != nil let types = sender.draggingPasteboard.types @@ -657,10 +659,24 @@ final class FileDropOverlayView: NSView { if let webView { return webView.performDragOperation(sender) } - guard let terminal else { return false } + guard let terminal else { + // No terminal under the drop point — delegate to the folder drop handler. + // In practice the only non-terminal, non-browser area visible to this overlay + // is the sidebar: this overlay is anchored to contentView.topAnchor, so the + // title-bar strip above it is never reachable, and all other areas are filled + // by terminal or browser panels. No title-bar / non-sidebar guard is needed. + let urls = sender.draggingPasteboard.readObjects( + forClasses: [NSURL.self], + options: [.urlReadingFileURLsOnly: true] + ) as? [URL] ?? [] + return onDrop?(urls) ?? false + } return terminal.performDragOperation(sender) } + /// Updates the drag target and returns the proposed drag operation. + /// Returns `.copy` only when capture is enabled and the payload contains at least one + /// directory URL, so plain-file drops do not show a misleading copy cursor. private func updateDragTarget(_ sender: any NSDraggingInfo, phase: String) -> NSDragOperation { let loc = sender.draggingLocation let hasLocalDraggingSource = sender.draggingSource != nil @@ -694,7 +710,21 @@ final class FileDropOverlayView: NSView { hasTerminalTarget: hasTerminalTarget ) #endif - guard shouldCapture, hasTerminalTarget else { return [] } + guard shouldCapture else { return [] } + // Only advertise the copy affordance when the payload contains at least one + // plain directory URL. Filesystem packages (.app, .xcworkspace, etc.) have + // hasDirectoryPath==true but isPackage==true and must be excluded to keep + // the cursor consistent with performDragOperation/handleSidebarFolderDrop. + let urls = sender.draggingPasteboard.readObjects( + forClasses: [NSURL.self], + options: [.urlReadingFileURLsOnly: true] + ) as? [URL] ?? [] + let hasFolder = hasTerminalTarget || urls.contains { url in + guard url.hasDirectoryPath else { return false } + guard let values = try? url.resourceValues(forKeys: [.isPackageKey]) else { return false } + return values.isPackage != true + } + guard hasFolder else { return [] } return .copy } @@ -1363,9 +1393,12 @@ func installFileDropOverlay(on window: NSWindow, tabManager: TabManager) { let overlay = FileDropOverlayView(frame: contentView.frame) overlay.translatesAutoresizingMaskIntoConstraints = false overlay.onDrop = { [weak tabManager] urls in - MainActor.assumeIsolated { - guard let tabManager, let terminal = tabManager.selectedWorkspace?.focusedTerminalPanel else { return false } - return terminal.hostedView.handleDroppedURLs(urls) + guard let tabManager else { return false } + // Note: sidebarSelectionState.selection == .notifications is never set in + // production; all notification UI uses NSPopover via toggleNotificationsPopover. + // No sidebar page switch is needed after a successful drop. + return MainActor.assumeIsolated { + tabManager.handleSidebarFolderDrop(urls) } } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 6df6abaf3ab2..b5acc25a8341 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1133,6 +1133,71 @@ class TabManager: ObservableObject { return newWorkspace } + /// Handles a folder drag-and-drop onto the sidebar or non-terminal window area. + /// + /// - If an existing workspace has the same working directory, selects it and adds a new + /// terminal split. If the workspace contains only non-terminal panels (e.g. browser-only), + /// the terminal split is anchored beside the existing panel rather than creating a new workspace. + /// - Otherwise, creates a new workspace rooted at the dropped folder. + /// + /// Only the first plain-directory URL is acted upon. Non-directory URLs and filesystem + /// packages (.app, .xcworkspace, .playground, etc.) are silently ignored. + @discardableResult + func handleSidebarFolderDrop(_ urls: [URL]) -> Bool { + let folderURLs = urls.filter { url in + // Accept only plain directories; exclude filesystem packages (.app, .xcworkspace, + // .playground, etc.) which report isDirectory=true but should be ignored like files. + guard let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .isPackageKey]) + else { return false } + return values.isDirectory == true && values.isPackage != true + } + let directories = FinderServicePathResolver.orderedUniqueDirectories( + from: folderURLs.filter { $0.isFileURL } + ) + guard let path = directories.first else { return false } + +#if DEBUG + dlog("sidebar.folderDrop path=\(path) tabCount=\(tabs.count)") +#endif + + // Note: resolvingSymlinksInPath is intentionally not applied here. + // orderedUniqueDirectories already uses standardizedFileURL which resolves /tmp → /private/tmp + // on macOS. Adding resolvingSymlinksInPath to both sides would be equivalent but adds + // unnecessary cost; /tmp symlink matching already passes without it. + let existing = tabs.first { workspace in + var cd = workspace.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + while cd.count > 1 && cd.hasSuffix("/") { cd.removeLast() } + return cd == path + } + + if let existing { + selectedTabId = existing.id + // Prefer a focused or existing TerminalPanel as the split source. + // If the workspace has only non-terminal panels (e.g. browser-only), use any panel + // so a new terminal appears as a split beside it rather than creating a new workspace. + let panelId = existing.focusedPanelId + ?? existing.panels.values.compactMap { $0 as? TerminalPanel }.first?.id + ?? existing.panels.values.first?.id + if let panelId { + let newPanel = existing.newTerminalSplit(from: panelId, orientation: .horizontal) +#if DEBUG + if newPanel == nil { + dlog("sidebar.folderDrop.splitFailed panelId=\(panelId.uuidString.prefix(5))") + } +#endif + } +#if DEBUG + dlog("sidebar.folderDrop.existing workspaceId=\(existing.id.uuidString.prefix(5))") +#endif + } else { + addWorkspace(workingDirectory: path, select: true) +#if DEBUG + dlog("sidebar.folderDrop.new path=\(path)") +#endif + } + return true + } + @MainActor private func sendWelcomeWhenReady(to workspace: Workspace) { if let terminalPanel = workspace.focusedTerminalPanel, diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 4557f122e537..b0aa31c1fa08 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -6610,11 +6610,26 @@ final class Workspace: Identifiable, ObservableObject { // Suppress the old view's becomeFirstResponder side-effects during SwiftUI reparenting. // Without this, reparenting triggers onFocus + ghostty_surface_set_focus on the old view, // stealing focus from the new panel and creating model/surface divergence. + let browserSourcePanel = panels[panelId] as? BrowserPanel if focus { previousHostedView?.suppressReparentFocus() - focusPanel(newPanel.id, previousHostedView: previousHostedView) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - previousHostedView?.clearSuppressReparentFocus() + if let browserSourcePanel { + // When the source is a BrowserPanel, the WKWebView portal still covers the full + // workspace rect until SwiftUI layout shrinks it. Focusing the terminal directly + // causes the first click to be swallowed by the stale portal geometry. + // Focusing the browser first lets the portal settle, then we hand off to the + // terminal on the next runloop — mirroring the user clicking browser → terminal. + focusPanel(browserSourcePanel.id, previousHostedView: previousHostedView) + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.focusPanel(newPanel.id, previousHostedView: nil) + previousHostedView?.clearSuppressReparentFocus() + } + } else { + focusPanel(newPanel.id, previousHostedView: previousHostedView) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + previousHostedView?.clearSuppressReparentFocus() + } } } else { preserveFocusAfterNonFocusSplit(