From ba5771022441e00cc51450646e838661160b4d57 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 10:42:08 +0000 Subject: [PATCH 1/5] Add Move to Project to the tab context menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #91. Lets a tab — with its live panes and running shells — be relocated from one project to another via the sidebar tab context menu, for the common case of starting work in the wrong project. The tab object is reused as-is so surfaces stay valid, and the destination becomes active with the moved tab selected. --- Macterm/App/AppState.swift | 23 ++++++++ Macterm/Model/Workspace.swift | 10 ++++ Macterm/Views/Sidebar.swift | 29 ++++++++-- MactermTests/App/AppStateTests.swift | 74 +++++++++++++++++++++++++ MactermTests/Model/WorkspaceTests.swift | 23 ++++++++ 5 files changed, 153 insertions(+), 6 deletions(-) diff --git a/Macterm/App/AppState.swift b/Macterm/App/AppState.swift index 3c41b8d..9e32b7d 100644 --- a/Macterm/App/AppState.swift +++ b/Macterm/App/AppState.swift @@ -336,6 +336,29 @@ final class AppState { workspaces[projectID]?.selectTab(tabID) } + /// Move a tab — with its live panes and running shells intact — from one + /// project's workspace into another's. The `TerminalTab` object is reused + /// as-is, so its surfaces stay valid (both workspaces live in the same + /// window). The destination becomes the active project with the moved tab + /// selected, so the user lands where they meant to be. No-op for a + /// same-project move or an unknown source/tab. + func moveTab(_ tabID: UUID, from sourceProjectID: UUID, to destProjectID: UUID, destPath: String) { + guard sourceProjectID != destProjectID, + let source = workspaces[sourceProjectID], + let tab = source.tabs.first(where: { $0.id == tabID }) + else { return } + logger.debug( + "moveTab: \(tabID, privacy: .public) from=\(sourceProjectID, privacy: .public) to=\(destProjectID, privacy: .public)" + ) + ensureWorkspace(projectID: destProjectID, path: destPath) + guard let dest = workspaces[destProjectID] else { return } + source.closeTab(tabID) + dest.adoptTab(tab) + activeProjectID = destProjectID + recordProjectVisit(destProjectID) + saveWorkspaces() + } + func selectNextTab(projectID: UUID) { workspaces[projectID]?.selectNextTab() } diff --git a/Macterm/Model/Workspace.swift b/Macterm/Model/Workspace.swift index 51d4cca..9369573 100644 --- a/Macterm/Model/Workspace.swift +++ b/Macterm/Model/Workspace.swift @@ -215,6 +215,16 @@ final class Workspace: Identifiable { return tab } + /// Append an existing tab — moved in from another workspace — and make it + /// active. Unlike `createTab` the `TerminalTab` (and its live panes/surfaces) + /// is reused as-is; the caller is responsible for having removed it from its + /// previous workspace first. + func adoptTab(_ tab: TerminalTab) { + tabs.append(tab) + if let current = activeTabID { tabHistory.push(current) } + activeTabID = tab.id + } + func closeTab(_ tabID: UUID) { guard let index = tabs.firstIndex(where: { $0.id == tabID }) else { return } tabs.remove(at: index) diff --git a/Macterm/Views/Sidebar.swift b/Macterm/Views/Sidebar.swift index 2c5b2a9..35ca201 100644 --- a/Macterm/Views/Sidebar.swift +++ b/Macterm/Views/Sidebar.swift @@ -28,12 +28,20 @@ struct SidebarContent: View { set: { if $0 { expandedProjects.insert(project.id) } else { expandedProjects.remove(project.id) } } )) { ForEach(Array(tabs.enumerated()), id: \.element.id) { tabIndex, tab in - SidebarTabRow(tab: tab, index: tabIndex + 1) { - appState.closeTab(tab.id, projectID: project.id) - } onRename: { newName in - tab.customTitle = newName.isEmpty ? nil : newName - appState.saveWorkspaces() - } + SidebarTabRow( + tab: tab, + index: tabIndex + 1, + moveTargets: projectStore.projects.filter { $0.id != project.id }, + onClose: { appState.closeTab(tab.id, projectID: project.id) }, + onRename: { newName in + tab.customTitle = newName.isEmpty ? nil : newName + appState.saveWorkspaces() + }, + onMoveToProject: { destination in + appState.moveTab(tab.id, from: project.id, to: destination.id, destPath: destination.path) + expandedProjects.insert(destination.id) + } + ) .tag(SidebarItem.tab(projectID: project.id, tabID: tab.id)) } .onMove { source, destination in @@ -222,8 +230,10 @@ private struct SidebarProjectRow: View { private struct SidebarTabRow: View { let tab: TerminalTab let index: Int + let moveTargets: [Project] let onClose: () -> Void let onRename: (String) -> Void + let onMoveToProject: (Project) -> Void @Environment(AppState.self) private var appState @AppStorage(Preferences.Keys.tabIconSymbol) @@ -268,6 +278,13 @@ private struct SidebarTabRow: View { } .contextMenu { Button("Rename Tab") { beginRename() } + if !moveTargets.isEmpty { + Menu("Move to Project") { + ForEach(moveTargets) { project in + Button(project.name) { onMoveToProject(project) } + } + } + } Divider() Button("Close Tab", action: onClose) } diff --git a/MactermTests/App/AppStateTests.swift b/MactermTests/App/AppStateTests.swift index 9410c08..f55a30c 100644 --- a/MactermTests/App/AppStateTests.swift +++ b/MactermTests/App/AppStateTests.swift @@ -113,6 +113,80 @@ struct AppStateTests { #expect(originalTab.splitRoot.allPanes().count == 1) } + // MARK: - Move tab between projects + + @Test + func moveTab_relocates_tab_and_activates_destination() throws { + let state = makeAppState() + let p1 = seedProject(state, name: "p1", path: "/tmp1") + let p2 = seedProject(state, name: "p2", path: "/tmp2") + let ws1 = try #require(state.workspaces[p1.id]) + // Give p1 a second tab so moving one away doesn't empty it. + let moving = ws1.createTab(projectPath: "/tmp1") + let staying = try #require(ws1.tabs.first?.id) + + state.moveTab(moving.id, from: p1.id, to: p2.id, destPath: p2.path) + + // Source lost the tab; destination gained it (object reused, surfaces intact). + #expect(ws1.tabs.map(\.id) == [staying]) + let ws2 = try #require(state.workspaces[p2.id]) + #expect(ws2.tabs.contains { $0.id == moving.id }) + // Destination is now active with the moved tab selected. + #expect(state.activeProjectID == p2.id) + #expect(ws2.activeTabID == moving.id) + } + + @Test + func moveTab_leaves_source_workspace_empty_when_moving_its_only_tab() throws { + let state = makeAppState() + let p1 = seedProject(state, name: "p1", path: "/tmp1") + let p2 = seedProject(state, name: "p2", path: "/tmp2") + let ws1 = try #require(state.workspaces[p1.id]) + let only = try #require(ws1.tabs.first?.id) + + state.moveTab(only, from: p1.id, to: p2.id, destPath: p2.path) + + #expect(ws1.tabs.isEmpty) + #expect(ws1.activeTabID == nil) + #expect(state.workspaces[p2.id]?.tabs.contains { $0.id == only } == true) + } + + @Test + func moveTab_creates_destination_workspace_when_absent() throws { + let state = makeAppState() + let p1 = seedProject(state, name: "p1", path: "/tmp1") + let ws1 = try #require(state.workspaces[p1.id]) + let tab = ws1.createTab(projectPath: "/tmp1") + // A project that's never been opened — no workspace yet. + let p2 = Project(name: "p2", path: "/tmp2", sortOrder: 1) + #expect(state.workspaces[p2.id] == nil) + + state.moveTab(tab.id, from: p1.id, to: p2.id, destPath: p2.path) + + let ws2 = try #require(state.workspaces[p2.id]) + #expect(ws2.tabs.contains { $0.id == tab.id }) + } + + @Test + func moveTab_same_project_is_noop() throws { + let state = makeAppState() + let p = seedProject(state) + let ws = try #require(state.workspaces[p.id]) + let before = ws.tabs.map(\.id) + state.moveTab(try #require(before.first), from: p.id, to: p.id, destPath: p.path) + #expect(ws.tabs.map(\.id) == before) + } + + @Test + func moveTab_unknown_tab_is_noop() throws { + let state = makeAppState() + let p1 = seedProject(state, name: "p1", path: "/tmp1") + let p2 = seedProject(state, name: "p2", path: "/tmp2") + let ws2Before = try #require(state.workspaces[p2.id]).tabs.count + state.moveTab(UUID(), from: p1.id, to: p2.id, destPath: p2.path) + #expect(state.workspaces[p2.id]?.tabs.count == ws2Before) + } + // MARK: - Focus navigation @Test diff --git a/MactermTests/Model/WorkspaceTests.swift b/MactermTests/Model/WorkspaceTests.swift index 2678a2f..c46d13b 100644 --- a/MactermTests/Model/WorkspaceTests.swift +++ b/MactermTests/Model/WorkspaceTests.swift @@ -25,6 +25,29 @@ struct WorkspaceTests { #expect(original != new.id) } + @Test + func adoptTab_appends_existing_tab_and_selects_it() { + let ws = makeWorkspace() + let original = ws.tabs[0].id + let incoming = TerminalTab(projectPath: "/elsewhere", projectID: UUID()) + ws.adoptTab(incoming) + #expect(ws.tabs.count == 2) + #expect(ws.tabs.last?.id == incoming.id) + #expect(ws.activeTabID == incoming.id) + _ = original + } + + @Test + func adoptTab_pushes_previous_active_onto_history() { + let ws = makeWorkspace() + let original = ws.tabs[0].id + let incoming = TerminalTab(projectPath: "/elsewhere", projectID: UUID()) + ws.adoptTab(incoming) + // Closing the adopted (active) tab should fall back to the prior active. + ws.closeTab(incoming.id) + #expect(ws.activeTabID == original) + } + @Test func closeTab_active_selects_most_recent_from_history() { let ws = makeWorkspace() From 7663010f992171dc70299c3c53321b37d298a153 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 10:43:45 +0000 Subject: [PATCH 2/5] Hoist try to satisfy SwiftFormat hoistTry rule --- MactermTests/App/AppStateTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MactermTests/App/AppStateTests.swift b/MactermTests/App/AppStateTests.swift index f55a30c..b6562b4 100644 --- a/MactermTests/App/AppStateTests.swift +++ b/MactermTests/App/AppStateTests.swift @@ -173,7 +173,7 @@ struct AppStateTests { let p = seedProject(state) let ws = try #require(state.workspaces[p.id]) let before = ws.tabs.map(\.id) - state.moveTab(try #require(before.first), from: p.id, to: p.id, destPath: p.path) + try state.moveTab(#require(before.first), from: p.id, to: p.id, destPath: p.path) #expect(ws.tabs.map(\.id) == before) } From 716754954bbee588d6ec1571af4f8d51231f7efa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 04:30:06 +0000 Subject: [PATCH 3/5] Support dragging a sidebar tab onto another project The merged Move to Project command only added a context-menu entry; the issue asked to drag a tab between projects directly. Make each sidebar tab row a drag source carrying its UUID and each project row a drop target, reusing the existing AppState.moveTab path so the tab's live panes and shells move intact. Mirrors the pane drag-and-drop pattern (custom UTType + DropDelegate, payload read off the drag pasteboard). --- Macterm/App/AppState.swift | 7 +++ Macterm/Info.plist | 12 +++++ Macterm/Views/Sidebar.swift | 46 +++++++++++++----- Macterm/Views/TabDragDrop.swift | 73 ++++++++++++++++++++++++++++ MactermTests/App/AppStateTests.swift | 23 +++++++++ 5 files changed, 148 insertions(+), 13 deletions(-) create mode 100644 Macterm/Views/TabDragDrop.swift diff --git a/Macterm/App/AppState.swift b/Macterm/App/AppState.swift index 9e32b7d..4e8ddc0 100644 --- a/Macterm/App/AppState.swift +++ b/Macterm/App/AppState.swift @@ -342,6 +342,13 @@ final class AppState { /// window). The destination becomes the active project with the moved tab /// selected, so the user lands where they meant to be. No-op for a /// same-project move or an unknown source/tab. + /// The project whose workspace currently contains the given tab, if any. + /// Used by sidebar drag-and-drop, where the drop knows the destination + /// project but the dragged tab only carries its own ID. + func projectID(containingTab tabID: UUID) -> UUID? { + workspaces.first { _, ws in ws.tabs.contains { $0.id == tabID } }?.key + } + func moveTab(_ tabID: UUID, from sourceProjectID: UUID, to destProjectID: UUID, destPath: String) { guard sourceProjectID != destProjectID, let source = workspaces[sourceProjectID], diff --git a/Macterm/Info.plist b/Macterm/Info.plist index 6bbb56b..7f8ba8f 100644 --- a/Macterm/Info.plist +++ b/Macterm/Info.plist @@ -82,6 +82,18 @@ UTTypeTagSpecification + + UTTypeIdentifier + com.thdxg.macterm.tab-id + UTTypeDescription + Macterm Tab Identifier + UTTypeConformsTo + + public.data + + UTTypeTagSpecification + + diff --git a/Macterm/Views/Sidebar.swift b/Macterm/Views/Sidebar.swift index 35ca201..dde547b 100644 --- a/Macterm/Views/Sidebar.swift +++ b/Macterm/Views/Sidebar.swift @@ -49,19 +49,27 @@ struct SidebarContent: View { appState.saveWorkspaces() } } label: { - SidebarProjectRow(project: project, index: projectIndex + 1) { - appState.selectProject(project) - appState.createTab(projectID: project.id, projectPath: project.path) - expandedProjects.insert(project.id) - } onRename: { - projectStore.rename(id: project.id, to: $0) - } onUnload: { - appState.unloadProject(project.id) - } onRemove: { - expandedProjects.remove(project.id) - appState.removeProject(project.id) - projectStore.remove(id: project.id) - } + SidebarProjectRow( + project: project, + index: projectIndex + 1, + onNewTab: { + appState.selectProject(project) + appState.createTab(projectID: project.id, projectPath: project.path) + expandedProjects.insert(project.id) + }, + onRename: { projectStore.rename(id: project.id, to: $0) }, + onUnload: { appState.unloadProject(project.id) }, + onRemove: { + expandedProjects.remove(project.id) + appState.removeProject(project.id) + projectStore.remove(id: project.id) + }, + onDropTab: { tabID in + guard let source = appState.projectID(containingTab: tabID), source != project.id else { return } + appState.moveTab(tabID, from: source, to: project.id, destPath: project.path) + expandedProjects.insert(project.id) + } + ) .tag(SidebarItem.project(project.id)) } } @@ -151,6 +159,8 @@ private struct SidebarProjectRow: View { let onRename: (String) -> Void let onUnload: () -> Void let onRemove: () -> Void + /// Move a dragged tab (by UUID) into this project — see TabDragDrop. + let onDropTab: (UUID) -> Void @Environment(AppState.self) private var appState @AppStorage(Preferences.Keys.projectIconSymbol) @@ -159,6 +169,8 @@ private struct SidebarProjectRow: View { private var isRenaming = false @State private var renameText = "" + @State + private var isDropTargeted = false @FocusState private var focused: Bool @@ -190,6 +202,13 @@ private struct SidebarProjectRow: View { } } } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(MactermTheme.accent.opacity(isDropTargeted ? 0.25 : 0)) + ) + .onDrop(of: [.mactermTabID], delegate: TabDropDelegate(isTargeted: $isDropTargeted, onDropTab: onDropTab)) .contextMenu { Button("New Tab", action: onNewTab) Button("Copy Path") { @@ -276,6 +295,7 @@ private struct SidebarTabRow: View { } } } + .onDrag { tabDragItemProvider(for: tab.id) } .contextMenu { Button("Rename Tab") { beginRename() } if !moveTargets.isEmpty { diff --git a/Macterm/Views/TabDragDrop.swift b/Macterm/Views/TabDragDrop.swift new file mode 100644 index 0000000..20bd956 --- /dev/null +++ b/Macterm/Views/TabDragDrop.swift @@ -0,0 +1,73 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers + +// Drag-and-drop to move a sidebar tab from one project into another. A tab row +// is a drag source carrying its UUID; every project row is a drop target. +// Dropping a tab onto a project hands off to `AppState.moveTab`, which reuses +// the `TerminalTab` (and its live surfaces/shells) as-is — only the owning +// workspace changes. This is the direct-manipulation counterpart of the +// "Move to Project" context-menu command for the same operation. + +extension UTType { + /// In-app drag payload identifying the tab being moved: its UUID bytes. + static let mactermTabID = UTType(exportedAs: "com.thdxg.macterm.tab-id") +} + +extension NSPasteboard.PasteboardType { + static let mactermTabID = NSPasteboard.PasteboardType(UTType.mactermTabID.identifier) +} + +/// The drag payload for a sidebar tab row: the tab's UUID bytes, scoped to this +/// process (the drag never leaves the app). +@MainActor +func tabDragItemProvider(for tabID: UUID) -> NSItemProvider { + let provider = NSItemProvider() + let data = withUnsafeBytes(of: tabID.uuid) { Data($0) } + provider.registerDataRepresentation( + forTypeIdentifier: UTType.mactermTabID.identifier, + visibility: .ownProcess + ) { completion in + completion(data, nil) + return nil + } + return provider +} + +/// Drop target for a sidebar project row: accepts a dragged tab and moves it +/// into this project. `isTargeted` drives the row's drop highlight. +struct TabDropDelegate: DropDelegate { + @Binding var isTargeted: Bool + /// Performs the move; receives the dragged tab's UUID. + let onDropTab: @MainActor (UUID) -> Void + + func validateDrop(info: DropInfo) -> Bool { + info.hasItemsConforming(to: [.mactermTabID]) + } + + func dropEntered(info _: DropInfo) { + isTargeted = true + } + + func dropExited(info _: DropInfo) { + isTargeted = false + } + + func dropUpdated(info _: DropInfo) -> DropProposal? { + DropProposal(operation: .move) + } + + func performDrop(info _: DropInfo) -> Bool { + isTargeted = false + // The drag never leaves the app (the payload is .ownProcess), so the + // tab ID can be read synchronously off the drag pasteboard instead of + // round-tripping through the item provider — same as PaneDropDelegate. + guard let data = NSPasteboard(name: .drag).pasteboardItems? + .compactMap({ $0.data(forType: .mactermTabID) }) + .first, data.count == 16 + else { return false } + let tabID = data.withUnsafeBytes { UUID(uuid: $0.loadUnaligned(as: uuid_t.self)) } + MainActor.assumeIsolated { onDropTab(tabID) } + return true + } +} diff --git a/MactermTests/App/AppStateTests.swift b/MactermTests/App/AppStateTests.swift index b6562b4..5cb4e6f 100644 --- a/MactermTests/App/AppStateTests.swift +++ b/MactermTests/App/AppStateTests.swift @@ -177,6 +177,29 @@ struct AppStateTests { #expect(ws.tabs.map(\.id) == before) } + @Test + func projectID_containingTab_finds_owning_project() throws { + let state = makeAppState() + let p1 = seedProject(state, name: "p1", path: "/tmp1") + let p2 = seedProject(state, name: "p2", path: "/tmp2") + let tabInP1 = try #require(state.workspaces[p1.id]?.tabs.first?.id) + let tabInP2 = try #require(state.workspaces[p2.id]?.tabs.first?.id) + #expect(state.projectID(containingTab: tabInP1) == p1.id) + #expect(state.projectID(containingTab: tabInP2) == p2.id) + #expect(state.projectID(containingTab: UUID()) == nil) + } + + @Test + func projectID_containingTab_tracks_a_moved_tab() throws { + let state = makeAppState() + let p1 = seedProject(state, name: "p1", path: "/tmp1") + let p2 = seedProject(state, name: "p2", path: "/tmp2") + let moving = try #require(state.workspaces[p1.id]).createTab(projectPath: "/tmp1") + #expect(state.projectID(containingTab: moving.id) == p1.id) + state.moveTab(moving.id, from: p1.id, to: p2.id, destPath: p2.path) + #expect(state.projectID(containingTab: moving.id) == p2.id) + } + @Test func moveTab_unknown_tab_is_noop() throws { let state = makeAppState() From adb89430b4a5b34878cd3dfa7860567147201df8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 04:34:12 +0000 Subject: [PATCH 4/5] Annotate onDropTab @MainActor to satisfy DropDelegate Sendable --- Macterm/Views/Sidebar.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Macterm/Views/Sidebar.swift b/Macterm/Views/Sidebar.swift index dde547b..d0bba8b 100644 --- a/Macterm/Views/Sidebar.swift +++ b/Macterm/Views/Sidebar.swift @@ -160,7 +160,9 @@ private struct SidebarProjectRow: View { let onUnload: () -> Void let onRemove: () -> Void /// Move a dragged tab (by UUID) into this project — see TabDragDrop. - let onDropTab: (UUID) -> Void + /// `@MainActor` so it satisfies the `@MainActor @Sendable` closure that + /// `TabDropDelegate` (a `DropDelegate`) stores, matching `PaneDropDelegate`. + let onDropTab: @MainActor (UUID) -> Void @Environment(AppState.self) private var appState @AppStorage(Preferences.Keys.projectIconSymbol) From ced2fb961207e5d77711429fee6e39f25acf6281 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 04:43:45 +0000 Subject: [PATCH 5/5] Move tab across projects via ForEach .onInsert, not .onDrop SwiftUI ignores .onDrop for views inside a List on macOS, so the project-row drop target never fired and dragging a tab between projects did nothing. Switch to the List-native .onInsert hook on each project's tab ForEach (the same family as the .onMove that already reorders tabs within a project), fed by the tab row's existing .onDrag payload. Drop the unusable TabDropDelegate. --- Macterm/Views/Sidebar.swift | 31 ++++++++--------- Macterm/Views/TabDragDrop.swift | 59 ++++++++++----------------------- 2 files changed, 31 insertions(+), 59 deletions(-) diff --git a/Macterm/Views/Sidebar.swift b/Macterm/Views/Sidebar.swift index d0bba8b..c371452 100644 --- a/Macterm/Views/Sidebar.swift +++ b/Macterm/Views/Sidebar.swift @@ -1,4 +1,5 @@ import SwiftUI +import UniformTypeIdentifiers private enum SidebarItem: Hashable { case project(UUID) @@ -48,6 +49,18 @@ struct SidebarContent: View { appState.workspaces[project.id]?.reorderTabs(fromOffsets: source, toOffset: destination) appState.saveWorkspaces() } + // A tab dragged in from a different project lands here: + // .onMove only reorders within one ForEach, so a cross- + // project drop arrives as an insert. (.onDrop is ignored + // inside a List on macOS — see TabDragDrop.) + .onInsert(of: [.mactermTabID]) { _, _ in + guard let tabID = draggedTabID(), + let source = appState.projectID(containingTab: tabID), + source != project.id + else { return } + appState.moveTab(tabID, from: source, to: project.id, destPath: project.path) + expandedProjects.insert(project.id) + } } label: { SidebarProjectRow( project: project, @@ -63,11 +76,6 @@ struct SidebarContent: View { expandedProjects.remove(project.id) appState.removeProject(project.id) projectStore.remove(id: project.id) - }, - onDropTab: { tabID in - guard let source = appState.projectID(containingTab: tabID), source != project.id else { return } - appState.moveTab(tabID, from: source, to: project.id, destPath: project.path) - expandedProjects.insert(project.id) } ) .tag(SidebarItem.project(project.id)) @@ -159,10 +167,6 @@ private struct SidebarProjectRow: View { let onRename: (String) -> Void let onUnload: () -> Void let onRemove: () -> Void - /// Move a dragged tab (by UUID) into this project — see TabDragDrop. - /// `@MainActor` so it satisfies the `@MainActor @Sendable` closure that - /// `TabDropDelegate` (a `DropDelegate`) stores, matching `PaneDropDelegate`. - let onDropTab: @MainActor (UUID) -> Void @Environment(AppState.self) private var appState @AppStorage(Preferences.Keys.projectIconSymbol) @@ -171,8 +175,6 @@ private struct SidebarProjectRow: View { private var isRenaming = false @State private var renameText = "" - @State - private var isDropTargeted = false @FocusState private var focused: Bool @@ -204,13 +206,6 @@ private struct SidebarProjectRow: View { } } } - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(MactermTheme.accent.opacity(isDropTargeted ? 0.25 : 0)) - ) - .onDrop(of: [.mactermTabID], delegate: TabDropDelegate(isTargeted: $isDropTargeted, onDropTab: onDropTab)) .contextMenu { Button("New Tab", action: onNewTab) Button("Copy Path") { diff --git a/Macterm/Views/TabDragDrop.swift b/Macterm/Views/TabDragDrop.swift index 20bd956..f13c4e5 100644 --- a/Macterm/Views/TabDragDrop.swift +++ b/Macterm/Views/TabDragDrop.swift @@ -3,11 +3,14 @@ import SwiftUI import UniformTypeIdentifiers // Drag-and-drop to move a sidebar tab from one project into another. A tab row -// is a drag source carrying its UUID; every project row is a drop target. -// Dropping a tab onto a project hands off to `AppState.moveTab`, which reuses -// the `TerminalTab` (and its live surfaces/shells) as-is — only the owning -// workspace changes. This is the direct-manipulation counterpart of the -// "Move to Project" context-menu command for the same operation. +// vends its UUID as the drag payload (`.onDrag`); each project's tab list +// accepts a tab dragged in from a different project through the ForEach +// `.onInsert` hook. We use `.onInsert` rather than `.onDrop` because SwiftUI +// ignores `.onDrop` for views inside a `List` on macOS — `.onInsert` is the +// List-native drop hook, the same family as the `.onMove` that already powers +// within-project tab reordering (`.onMove` only reorders within one ForEach, +// so it can't cross projects). The move runs through `AppState.moveTab`, +// reusing the live `TerminalTab` (and its surfaces/shells) as-is. extension UTType { /// In-app drag payload identifying the tab being moved: its UUID bytes. @@ -34,40 +37,14 @@ func tabDragItemProvider(for tabID: UUID) -> NSItemProvider { return provider } -/// Drop target for a sidebar project row: accepts a dragged tab and moves it -/// into this project. `isTargeted` drives the row's drop highlight. -struct TabDropDelegate: DropDelegate { - @Binding var isTargeted: Bool - /// Performs the move; receives the dragged tab's UUID. - let onDropTab: @MainActor (UUID) -> Void - - func validateDrop(info: DropInfo) -> Bool { - info.hasItemsConforming(to: [.mactermTabID]) - } - - func dropEntered(info _: DropInfo) { - isTargeted = true - } - - func dropExited(info _: DropInfo) { - isTargeted = false - } - - func dropUpdated(info _: DropInfo) -> DropProposal? { - DropProposal(operation: .move) - } - - func performDrop(info _: DropInfo) -> Bool { - isTargeted = false - // The drag never leaves the app (the payload is .ownProcess), so the - // tab ID can be read synchronously off the drag pasteboard instead of - // round-tripping through the item provider — same as PaneDropDelegate. - guard let data = NSPasteboard(name: .drag).pasteboardItems? - .compactMap({ $0.data(forType: .mactermTabID) }) - .first, data.count == 16 - else { return false } - let tabID = data.withUnsafeBytes { UUID(uuid: $0.loadUnaligned(as: uuid_t.self)) } - MainActor.assumeIsolated { onDropTab(tabID) } - return true - } +/// The dragged tab's UUID, read synchronously off the active drag pasteboard +/// during a drop. The drag never leaves the app, so the payload is already on +/// the `.drag` pasteboard — no need to async-load the `.onInsert` item +/// providers (the same shortcut `PaneDropDelegate` takes). +func draggedTabID() -> UUID? { + guard let data = NSPasteboard(name: .drag).pasteboardItems? + .compactMap({ $0.data(forType: .mactermTabID) }) + .first, data.count == 16 + else { return nil } + return data.withUnsafeBytes { UUID(uuid: $0.loadUnaligned(as: uuid_t.self)) } }