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()