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..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,20 +49,35 @@ struct SidebarContent: View { appState.workspaces[project.id]?.reorderTabs(fromOffsets: source, toOffset: destination) appState.saveWorkspaces() } - } label: { - SidebarProjectRow(project: project, index: projectIndex + 1) { - appState.selectProject(project) - appState.createTab(projectID: project.id, projectPath: project.path) + // 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) - } 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) } + } label: { + 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) + } + ) .tag(SidebarItem.project(project.id)) } } @@ -276,6 +292,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..f13c4e5 --- /dev/null +++ b/Macterm/Views/TabDragDrop.swift @@ -0,0 +1,50 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers + +// Drag-and-drop to move a sidebar tab from one project into another. A tab row +// 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. + 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 +} + +/// 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)) } +} 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()