Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Macterm/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
12 changes: 12 additions & 0 deletions Macterm/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,18 @@
<key>UTTypeTagSpecification</key>
<dict/>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>com.thdxg.macterm.tab-id</string>
<key>UTTypeDescription</key>
<string>Macterm Tab Identifier</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
<key>UTTypeTagSpecification</key>
<dict/>
</dict>
</array>
</dict>
</plist>
41 changes: 29 additions & 12 deletions Macterm/Views/Sidebar.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import SwiftUI
import UniformTypeIdentifiers

private enum SidebarItem: Hashable {
case project(UUID)
Expand Down Expand Up @@ -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))
}
}
Expand Down Expand Up @@ -276,6 +292,7 @@ private struct SidebarTabRow: View {
}
}
}
.onDrag { tabDragItemProvider(for: tab.id) }
.contextMenu {
Button("Rename Tab") { beginRename() }
if !moveTargets.isEmpty {
Expand Down
50 changes: 50 additions & 0 deletions Macterm/Views/TabDragDrop.swift
Original file line number Diff line number Diff line change
@@ -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)) }
}
23 changes: 23 additions & 0 deletions MactermTests/App/AppStateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down