diff --git a/CodeEdit/Features/UtilityArea/Models/UtilityAreaTerminalGroup.swift b/CodeEdit/Features/UtilityArea/Models/UtilityAreaTerminalGroup.swift new file mode 100644 index 000000000..7d2af7c7e --- /dev/null +++ b/CodeEdit/Features/UtilityArea/Models/UtilityAreaTerminalGroup.swift @@ -0,0 +1,24 @@ +// +// UtilityAreaTerminalGroup.swift +// CodeEdit +// +// Created by Gustavo Soré on 30/06/25. +// + +import Foundation + +struct UtilityAreaTerminalGroup: Identifiable, Hashable { + var id = UUID() + var name: String = "Grupo" + var terminals: [UtilityAreaTerminal] = [] + var isCollapsed: Bool = false + var userName: Bool = false + + static func == (lhs: UtilityAreaTerminalGroup, rhs: UtilityAreaTerminalGroup) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} diff --git a/CodeEdit/Features/UtilityArea/TerminalUtility/Delegates/NewGroupDropDelegate.swift b/CodeEdit/Features/UtilityArea/TerminalUtility/Delegates/NewGroupDropDelegate.swift new file mode 100644 index 000000000..07a255675 --- /dev/null +++ b/CodeEdit/Features/UtilityArea/TerminalUtility/Delegates/NewGroupDropDelegate.swift @@ -0,0 +1,63 @@ +// +// NewGroupDropDelegate.swift +// CodeEdit +// +// Created by Gustavo Soré on 30/06/25. +// + +import SwiftUI +import UniformTypeIdentifiers + +/// Drop delegate responsible for handling the case when a terminal is dropped +/// outside of any existing group — i.e., it should create a new group with the dropped terminal. +struct NewGroupDropDelegate: DropDelegate { + /// The view model that manages terminal groups and selection state. + let viewModel: UtilityAreaViewModel + + /// Validates whether the drop operation includes terminal data that this delegate can handle. + /// + /// - Parameter info: The drop information provided by the system. + /// - Returns: `true` if the drop contains a valid terminal item type. + func validateDrop(info: DropInfo) -> Bool { + info.hasItemsConforming(to: [UTType.terminal.identifier]) + } + + /// Performs the drop by creating a new group and moving the terminal into it. + /// + /// - Parameter info: The drop information containing the dragged item. + /// - Returns: `true` if the drop was successfully handled. + func performDrop(info: DropInfo) -> Bool { + // Extract the first item provider that conforms to the terminal type. + guard let item = info.itemProviders(for: [UTType.terminal.identifier]).first else { + return false + } + + // Load and decode the terminal drag information. + item.loadDataRepresentation(forTypeIdentifier: UTType.terminal.identifier) { data, _ in + guard let data = data, + let dragInfo = try? JSONDecoder().decode(TerminalDragInfo.self, from: data), + let terminal = viewModel.terminalGroups + .flatMap({ $0.terminals }) + .first(where: { $0.id == dragInfo.terminalID }) else { + return + } + + // Perform the group creation and terminal movement on the main thread. + DispatchQueue.main.async { + withAnimation(.easeInOut(duration: 0.2)) { + // Optional logic to clean up old location (if needed). + viewModel.finalizeMoveTerminal(terminal, toGroup: UUID(), before: nil) + + // Create a new group containing the dropped terminal. + viewModel.createGroup(with: [terminal]) + + // Reset drag-related state. + viewModel.dragOverTerminalID = nil + viewModel.draggedTerminalID = nil + } + } + } + + return true + } +} diff --git a/CodeEdit/Features/UtilityArea/TerminalUtility/Delegates/TerminalDropDelegate.swift b/CodeEdit/Features/UtilityArea/TerminalUtility/Delegates/TerminalDropDelegate.swift new file mode 100644 index 000000000..392a0c461 --- /dev/null +++ b/CodeEdit/Features/UtilityArea/TerminalUtility/Delegates/TerminalDropDelegate.swift @@ -0,0 +1,96 @@ +// +// TerminalDropDelegate.swift +// CodeEdit +// +// Created by Gustavo Soré on 30/06/25. +// + +import SwiftUI +import UniformTypeIdentifiers + +/// Handles drop interactions for a terminal inside a specific group, +/// allowing for reordering or moving between groups. +struct TerminalDropDelegate: DropDelegate { + /// The ID of the group where the drop target resides. + let groupID: UUID + + /// The shared view model managing terminal groups and selection state. + let viewModel: UtilityAreaViewModel + + /// The ID of the terminal that is the drop destination, or `nil` if dropping at the end. + let destinationTerminalID: UUID? + + /// Validates if the drop contains terminal data. + /// + /// - Parameter info: The current drop context. + /// - Returns: `true` if the item conforms to the terminal type. + func validateDrop(info: DropInfo) -> Bool { + info.hasItemsConforming(to: [UTType.terminal.identifier]) + } + + /// Called when the drop enters a new target. + /// Sets the drag state in the view model for UI feedback. + /// + /// - Parameter info: The drop context. + func dropEntered(info: DropInfo) { + guard let item = info.itemProviders(for: [UTType.terminal.identifier]).first else { return } + + item.loadDataRepresentation(forTypeIdentifier: UTType.terminal.identifier) { data, _ in + guard let data = data, + let dragInfo = try? JSONDecoder().decode(TerminalDragInfo.self, from: data) else { return } + + DispatchQueue.main.async { + withAnimation { + viewModel.draggedTerminalID = dragInfo.terminalID + viewModel.dragOverTerminalID = destinationTerminalID + } + } + } + } + + /// Called continuously as the drop is updated over the view. + /// Updates drag-over visual feedback. + /// + /// - Parameter info: The drop context. + /// - Returns: A drop proposal that defines the type of drop operation (e.g., move). + func dropUpdated(info: DropInfo) -> DropProposal? { + DispatchQueue.main.async { + withAnimation(.easeInOut(duration: 0.2)) { + viewModel.dragOverTerminalID = destinationTerminalID + } + } + + return DropProposal(operation: .move) + } + + /// Called when the drop is performed. + /// Decodes the dragged terminal and triggers its relocation in the model. + /// + /// - Parameter info: The drop context with the drag payload. + /// - Returns: `true` if the drop was handled successfully. + func performDrop(info: DropInfo) -> Bool { + guard let item = info.itemProviders(for: [UTType.terminal.identifier]).first else { return false } + + item.loadDataRepresentation(forTypeIdentifier: UTType.terminal.identifier) { data, _ in + guard let data = data, + let dragInfo = try? JSONDecoder().decode(TerminalDragInfo.self, from: data), + let terminal = viewModel.terminalGroups + .flatMap({ $0.terminals }) + .first(where: { $0.id == dragInfo.terminalID }) else { return } + + DispatchQueue.main.async { + withAnimation(.easeInOut(duration: 0.2)) { + viewModel.finalizeMoveTerminal( + terminal, + toGroup: groupID, + before: destinationTerminalID + ) + viewModel.dragOverTerminalID = nil + viewModel.draggedTerminalID = nil + } + } + } + + return true + } +} diff --git a/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalGroupView.swift b/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalGroupView.swift new file mode 100644 index 000000000..a3cacfb1c --- /dev/null +++ b/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalGroupView.swift @@ -0,0 +1,224 @@ +// +// UtilityAreaTerminalGroupView.swift +// CodeEdit +// +// Created by Gustavo Soré on 29/06/25. +// + +import SwiftUI +import UniformTypeIdentifiers + +/// A view that displays a terminal group with a header and a list of its terminals. +/// Supports editing the group name, collapsing, drag-and-drop, and inline terminal row management. +struct UtilityAreaTerminalGroupView: View { + /// The index of the group within the terminalGroups array. + let index: Int + + /// Whether this group is currently selected in the UI. + let isGroupSelected: Bool + + /// The shared view model that manages terminal groups and their state. + @EnvironmentObject private var utilityAreaViewModel: UtilityAreaViewModel + + /// Manages focus on a specific terminal row for keyboard navigation or editing. + @FocusState private var focusedTerminalID: UUID? + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + + // MARK: - Group Header + + HStack(spacing: 4) { + Image(systemName: "square.on.square") + .font(.system(size: 14, weight: .medium)) + .frame(width: 20, height: 20) + .foregroundStyle(.primary.opacity(0.6)) + + // Editable group name when in edit mode + if utilityAreaViewModel.editingGroupID == utilityAreaViewModel.terminalGroups[index].id { + TextField("", text: Binding( + get: { utilityAreaViewModel.terminalGroups[index].name }, + set: { newTitle in + guard !newTitle.trimmingCharacters(in: .whitespaces).isEmpty else { return } + utilityAreaViewModel.terminalGroups[index].name = newTitle + utilityAreaViewModel.terminalGroups[index].userName = true + } + )) + .textFieldStyle(.plain) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.black) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background(Color.white) + .cornerRadius(4) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color.gray.opacity(0.3), lineWidth: 1) + ) + .onSubmit { + utilityAreaViewModel.editingGroupID = nil + } + } else { + // Display group name normally + Text( + utilityAreaViewModel.terminalGroups[index].name.isEmpty + ? "terminals" + : utilityAreaViewModel.terminalGroups[index].name + ) + .lineLimit(1) + .truncationMode(.middle) + .font(.system(size: 13, weight: .regular)) + .foregroundColor(.secondary) + .contentShape(Rectangle()) + .simultaneousGesture( + TapGesture(count: 2) + .onEnded { + utilityAreaViewModel.editingGroupID = utilityAreaViewModel.terminalGroups[index].id + } + ) + } + + Spacer() + + // Expand/collapse toggle + Image( + systemName: utilityAreaViewModel.terminalGroups[index].isCollapsed + ? "chevron.right" + : "chevron.down" + ) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.secondary) + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .contentShape(Rectangle()) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.25)) { + utilityAreaViewModel.terminalGroups[index].isCollapsed.toggle() + } + } + .onDrag { + // Optional: dragging the entire group (stubbed terminal ID) + let dragInfo = TerminalDragInfo(terminalID: .init()) + let provider = NSItemProvider() + do { + let data = try JSONEncoder().encode(dragInfo) + provider.registerDataRepresentation( + forTypeIdentifier: UTType.terminal.identifier, + visibility: .all + ) { completion in + completion(data, nil) + return nil + } + } catch { + print("❌ Failed to encode dragInfo: \(error)") + } + return provider + } + + // MARK: - Terminal Rows (if group is expanded) + + if !utilityAreaViewModel.terminalGroups[index].isCollapsed { + VStack(spacing: 0) { + ForEach(utilityAreaViewModel.terminalGroups[index].terminals, id: \.id) { terminal in + VStack(spacing: 0) { + UtilityAreaTerminalRowView( + terminal: terminal, + focusedTerminalID: $focusedTerminalID + ) + .onDrag { + utilityAreaViewModel.draggedTerminalID = terminal.id + + let dragInfo = TerminalDragInfo(terminalID: terminal.id) + let provider = NSItemProvider() + guard let data = try? JSONEncoder().encode(dragInfo) else { + return provider + } + provider.registerDataRepresentation( + forTypeIdentifier: UTType.terminal.identifier, + visibility: .all + ) { completion in + completion(data, nil) + return nil + } + return provider + } + .onDrop( + of: [UTType.terminal.identifier], + delegate: TerminalDropDelegate( + groupID: utilityAreaViewModel.terminalGroups[index].id, + viewModel: utilityAreaViewModel, + destinationTerminalID: terminal.id + ) + ) + .transition(.opacity.combined(with: .move(edge: .top))) + .animation( + .easeInOut(duration: 0.2), + value: utilityAreaViewModel.terminalGroups[index].isCollapsed + ) + } + } + } + .padding(.bottom, 8) + .padding(.leading, 16) + .onDrop(of: [UTType.terminal.identifier], delegate: TerminalDropDelegate( + groupID: utilityAreaViewModel.terminalGroups[index].id, + viewModel: utilityAreaViewModel, + destinationTerminalID: focusedTerminalID + )) + } + } + .cornerRadius(8) + } +} + +// MARK: - Preview + +/// Preview provider for `UtilityAreaTerminalGroupView`, showing a mock terminal group with two terminals. +struct TerminalGroupViewPreviews: PreviewProvider { + static var previews: some View { + let terminal = UtilityAreaTerminal( + id: UUID(), + url: URL(fileURLWithPath: "/mock"), + title: "Terminal Preview", + shell: .zsh + ) + + let terminal2 = UtilityAreaTerminal( + id: UUID(), + url: URL(fileURLWithPath: "/mock"), + title: "Terminal Preview", + shell: .zsh + ) + + let utilityAreaViewModel = UtilityAreaViewModel() + utilityAreaViewModel.terminalGroups = [ + UtilityAreaTerminalGroup(name: "Preview Group", terminals: [terminal, terminal2]) + ] + utilityAreaViewModel.selectedTerminals = [terminal.id] + + let tabViewModel = UtilityAreaTabViewModel() + + let workspace = WorkspaceDocument() + workspace.setValue(URL(string: "file:///mock/path")!, forKey: "fileURL") + + return TerminalGroupViewPreviewWrapper() + .environmentObject(utilityAreaViewModel) + .environmentObject(tabViewModel) + .environmentObject(workspace) + .frame(width: 320) + } +} + +// Wrapper view to render the preview group. +private struct TerminalGroupViewPreviewWrapper: View { + @EnvironmentObject var utilityAreaViewModel: UtilityAreaViewModel + @FocusState private var focusedTerminalID: UUID? + + var body: some View { + UtilityAreaTerminalGroupView( + index: 0, + isGroupSelected: false + ) + } +} diff --git a/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalRowView.swift b/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalRowView.swift new file mode 100644 index 000000000..fcdab70a5 --- /dev/null +++ b/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalRowView.swift @@ -0,0 +1,135 @@ +// +// UtilityAreaTerminalSidebar.swift +// CodeEdit +// +// Created by Khan Winter on 8/19/24. +// + +import SwiftUI +import UniformTypeIdentifiers + +/// A single row representing a terminal within a terminal group. +/// Includes icon, title, selection handling, hover actions, and delete button. +struct UtilityAreaTerminalRowView: View { + /// The terminal instance represented by this row. + let terminal: UtilityAreaTerminal + + /// Focus binding used for keyboard interactions or editing. + @FocusState.Binding var focusedTerminalID: UUID? + + /// View model that manages the terminal groups and selection state. + @EnvironmentObject var utilityAreaViewModel: UtilityAreaViewModel + + /// Tracks whether the mouse is currently hovering this row. + @State private var isHovering = false + + /// Computed property to check if the terminal is currently selected. + var isSelected: Bool { + utilityAreaViewModel.selectedTerminals.contains(terminal.id) + } + + var body: some View { + HStack(spacing: 8) { + // Terminal icon + Image(systemName: "terminal") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(isSelected ? Color.white : Color.secondary) + .frame(width: 20, height: 20) + + // Terminal title + terminalTitleView() + + Spacer() + + // Close button shown only on hover + if isHovering { + Button { + utilityAreaViewModel.removeTerminals([terminal.id]) + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + .font(.system(size: 12)) + .padding(.trailing, 4) + } + .buttonStyle(.borderless) + } + } + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + // Background changes on selection or drag-over + RoundedRectangle(cornerRadius: 6) + .fill( + isSelected ? Color.blue : + utilityAreaViewModel.dragOverTerminalID == terminal.id ? Color.blue.opacity(0.15) : .clear + ) + ) + .contentShape(Rectangle()) // Increases tappable area + .onHover { hovering in + isHovering = hovering + } + .simultaneousGesture( + TapGesture(count: 1).onEnded { + utilityAreaViewModel.selectedTerminals = [terminal.id] + } + ) + .animation(.easeInOut(duration: 0.15), value: isHovering) + } + + /// Returns a view displaying the terminal's title with styling depending on selection state. + @ViewBuilder + private func terminalTitleView() -> some View { + Text(terminal.title.isEmpty ? "Terminal" : terminal.title) + .lineLimit(1) + .truncationMode(.middle) + .font(.system(size: 13, weight: isSelected ? .semibold : .regular)) + .foregroundColor(isSelected ? .white : .secondary) + .contentShape(Rectangle()) + } +} + +// MARK: - Preview + +/// Preview provider for `UtilityAreaTerminalRowView` with sample data. +#Preview { + UtilityAreaTerminalTabPreviewWrapper() +} + +/// Wrapper view for rendering the terminal row in Xcode Preview with mock data and environment. +private struct UtilityAreaTerminalTabPreviewWrapper: View { + @StateObject private var viewModel = UtilityAreaViewModel() + @StateObject private var tabViewModel = UtilityAreaTabViewModel() + @FocusState private var focusedTerminalID: UUID? + + private let terminal = UtilityAreaTerminal( + id: UUID(), + url: URL(fileURLWithPath: "/mock"), + title: "Terminal Preview", + shell: .zsh + ) + + private let workspace: WorkspaceDocument = { + let workspace = WorkspaceDocument() + workspace.setValue(URL(string: "file:///mock/path")!, forKey: "fileURL") + return workspace + }() + + init() { + viewModel.terminalGroups = [ + UtilityAreaTerminalGroup(name: "Preview Group", terminals: [terminal]) + ] + viewModel.selectedTerminals = [terminal.id] + } + + var body: some View { + UtilityAreaTerminalRowView( + terminal: terminal, + focusedTerminalID: $focusedTerminalID + ) + .environmentObject(viewModel) + .environmentObject(tabViewModel) + .environmentObject(workspace) + .frame(width: 280) + .padding() + } +} diff --git a/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalSidebar.swift b/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalSidebar.swift index 1c61d8bb4..1fb0db799 100644 --- a/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalSidebar.swift +++ b/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalSidebar.swift @@ -6,33 +6,93 @@ // import SwiftUI +import UniformTypeIdentifiers -/// The view that displays the list of available terminals in the utility area. -/// See ``UtilityAreaTerminalView`` for use. +// MARK: - UTType Extension + +/// Custom type identifier used for drag-and-drop functionality involving terminal items. +extension UTType { + static let terminal = UTType(exportedAs: "dev.codeedit.terminal") +} + +// MARK: - TerminalDragInfo + +/// Represents the information used when dragging a terminal in the sidebar. +struct TerminalDragInfo: Codable { + let terminalID: UUID +} + +// MARK: - UtilityAreaTerminalSidebar + +/// A SwiftUI view that displays the list of available terminals in the utility area. +/// Supports single terminal rows, terminal groups, drag and drop functionality, +/// context menus for creating terminals, and a custom toolbar. struct UtilityAreaTerminalSidebar: View { + /// The current workspace document environment object. @EnvironmentObject private var workspace: WorkspaceDocument + + /// The view model that manages terminal groups and terminals in the utility area. @EnvironmentObject private var utilityAreaViewModel: UtilityAreaViewModel + /// Focus state for determining which terminal (if any) is currently focused. + @FocusState private var focusedTerminalID: UUID? + var body: some View { - List(selection: $utilityAreaViewModel.selectedTerminals) { - ForEach(utilityAreaViewModel.terminals, id: \.self.id) { terminal in - UtilityAreaTerminalTab( - terminal: terminal, - removeTerminals: utilityAreaViewModel.removeTerminals, - isSelected: utilityAreaViewModel.selectedTerminals.contains(terminal.id), - selectedIDs: utilityAreaViewModel.selectedTerminals - ) - .tag(terminal.id) - .listRowSeparator(.hidden) - } - .onMove { [weak utilityAreaViewModel] (source, destination) in - utilityAreaViewModel?.reorderTerminals(from: source, to: destination) + ScrollView { + VStack(alignment: .leading, spacing: 0) { + // Iterate over terminal groups to display each accordingly + ForEach(Array(utilityAreaViewModel.terminalGroups.enumerated()), id: \.element.id) { index, group in + if group.terminals.count == 1 { + let terminal = group.terminals[0] + UtilityAreaTerminalRowView( + terminal: terminal, + focusedTerminalID: $focusedTerminalID + ) + .onDrag { + utilityAreaViewModel.draggedTerminalID = terminal.id + + let dragInfo = TerminalDragInfo(terminalID: terminal.id) + let provider = NSItemProvider() + guard let data = try? JSONEncoder().encode(dragInfo) else { + return provider + } + provider.registerDataRepresentation( + forTypeIdentifier: UTType.terminal.identifier, + visibility: .all + ) { completion in + completion(data, nil) + return nil + } + return provider + } + .onDrop( + of: [UTType.terminal.identifier], + delegate: TerminalDropDelegate( + groupID: group.id, + viewModel: utilityAreaViewModel, + destinationTerminalID: terminal.id + ) + ) + .transition(.opacity.combined(with: .move(edge: .top))) + .animation(.easeInOut(duration: 0.2), value: group.isCollapsed) + } else { + // Display a terminal group with collapsible behavior + UtilityAreaTerminalGroupView( + index: index, + isGroupSelected: true + ) + } + } } + .padding(.top) } - .focusedObject(utilityAreaViewModel) - .listStyle(.automatic) - .accentColor(.secondary) + .onDrop( + of: [UTType.terminal.identifier], + delegate: NewGroupDropDelegate(viewModel: utilityAreaViewModel) + ) + .background(Color(NSColor.controlBackgroundColor)) .contextMenu { + // Context menu for creating new terminals Button("New Terminal") { utilityAreaViewModel.addTerminal(rootURL: workspace.fileURL) } @@ -48,33 +108,67 @@ struct UtilityAreaTerminalSidebar: View { } } } - .onChange(of: utilityAreaViewModel.terminals) { newValue in - if newValue.isEmpty { - utilityAreaViewModel.addTerminal(rootURL: workspace.fileURL) - } - } .paneToolbar { PaneToolbarSection { + // Toolbar button for adding a new terminal Button { utilityAreaViewModel.addTerminal(rootURL: workspace.fileURL) } label: { Image(systemName: "plus") } + + // Toolbar button for removing selected terminals Button { utilityAreaViewModel.removeTerminals(utilityAreaViewModel.selectedTerminals) } label: { Image(systemName: "minus") } - .disabled(utilityAreaViewModel.terminals.count <= 1) - .opacity(utilityAreaViewModel.terminals.count <= 1 ? 0.5 : 1) + .disabled(utilityAreaViewModel.terminalGroups.flatMap { $0.terminals }.count <= 1) + .opacity(utilityAreaViewModel.terminalGroups.flatMap { $0.terminals }.count <= 1 ? 0.5 : 1) } Spacer() } .accessibilityElement(children: .contain) .accessibilityLabel("Terminals") + .background( + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + focusedTerminalID = nil + } + ) } } -#Preview { - UtilityAreaTerminalSidebar() +// MARK: - Preview + +/// Preview provider for `UtilityAreaTerminalSidebar`. +struct UtilityAreaTerminalSidebarPreviews: PreviewProvider { + static var previews: some View { + // Sample terminal for preview + let terminal = UtilityAreaTerminal( + id: UUID(), + url: URL(string: "https://example.com")!, + title: "Terminal 1", + shell: .zsh + ) + + // Mock view model with one group + let utilityAreaViewModel = UtilityAreaViewModel() + utilityAreaViewModel.terminalGroups = [ + UtilityAreaTerminalGroup(name: "Group", terminals: [terminal]) + ] + utilityAreaViewModel.selectedTerminals = [terminal.id] + + // Mock tab view model and workspace + let tabViewModel = UtilityAreaTabViewModel() + let workspace = WorkspaceDocument() + workspace.setValue(URL(string: "file:///mock/path")!, forKey: "fileURL") + + return UtilityAreaTerminalSidebar() + .environmentObject(utilityAreaViewModel) + .environmentObject(tabViewModel) + .environmentObject(workspace) + .frame(width: 300, height: 400) + } } diff --git a/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalTab.swift b/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalTab.swift deleted file mode 100644 index 2ee68eb73..000000000 --- a/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalTab.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// UtilityAreaTerminalTab.swift -// CodeEdit -// -// Created by Austin Condiff on 5/26/23. -// - -import SwiftUI - -struct UtilityAreaTerminalTab: View { - @ObservedObject var terminal: UtilityAreaTerminal - - var removeTerminals: (_ ids: Set) -> Void - - var isSelected: Bool - - var selectedIDs: Set - - @FocusState private var isFocused: Bool - - var body: some View { - let terminalTitle = Binding( - get: { - self.terminal.title - }, set: { - if $0.trimmingCharacters(in: .whitespaces) == "" && !isFocused { - self.terminal.title = self.terminal.terminalTitle - self.terminal.customTitle = false - } else { - self.terminal.title = $0 - self.terminal.customTitle = true - } - } - ) - - Label { - if #available(macOS 14, *) { - // Fix the icon misplacement issue introduced since macOS 14 - TextField("Name", text: terminalTitle) - .focused($isFocused) - } else { - // A padding is needed for macOS 13 - TextField("Name", text: terminalTitle) - .focused($isFocused) - .padding(.leading, -8) - } - } icon: { - Image(systemName: "terminal") - .accessibilityHidden(true) - } - .contextMenu { - Button("Rename...") { - isFocused = true - } - - if selectedIDs.contains(terminal.id) && selectedIDs.count > 1 { - Button("Kill Terminals") { - removeTerminals(selectedIDs) - } - } else { - Button("Kill Terminal") { - removeTerminals([terminal.id]) - } - } - } - } -} diff --git a/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalView.swift b/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalView.swift index 998ed620b..ea3c42a0b 100644 --- a/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalView.swift +++ b/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalView.swift @@ -91,39 +91,82 @@ struct UtilityAreaTerminalView: View { var body: some View { UtilityAreaTabView(model: utilityAreaViewModel.tabViewModel) { tabState in ZStack { - // Keeps the sidebar from changing sizes because TerminalEmulatorView takes a µs to load in - HStack { Spacer() } + if let selectedTerminal = getSelectedTerminal(), + let group = utilityAreaViewModel.terminalGroups.first(where: { + $0.terminals.contains(selectedTerminal) + }) { - if let selectedTerminal = getSelectedTerminal() { GeometryReader { geometry in let containerHeight = geometry.size.height + let containerWidth = geometry.size.width let totalFontHeight = fontTotalHeight(nsFont: font).rounded(.up) let constrainedHeight = containerHeight - containerHeight.truncatingRemainder( dividingBy: totalFontHeight ) - VStack(spacing: 0) { - Spacer(minLength: 0).frame(minHeight: 0) - TerminalEmulatorView( - url: selectedTerminal.url, - terminalID: selectedTerminal.id, - shellType: selectedTerminal.shell, - onTitleChange: { [weak selectedTerminal] newTitle in - guard let id = selectedTerminal?.id else { return } - // This can be called whenever, even in a view update so it needs to be dispatched. - DispatchQueue.main.async { [weak utilityAreaViewModel] in - utilityAreaViewModel?.updateTerminal(id, title: newTitle) + + if group.terminals.count == 1 { + VStack(spacing: 0) { + TerminalEmulatorView( + url: selectedTerminal.url, + terminalID: selectedTerminal.id, + shellType: selectedTerminal.shell, + onTitleChange: { [weak selectedTerminal] newTitle in + guard let id = selectedTerminal?.id else { return } + DispatchQueue.main.async { [weak utilityAreaViewModel] in + utilityAreaViewModel?.updateTerminal(id, title: newTitle) + } + } + ) + .frame(height: max(0, constrainedHeight - 1)) + .id(selectedTerminal.id) + .padding(.horizontal, 4) + } + } else { + VStack { + ScrollView(.horizontal, showsIndicators: true) { + HStack(spacing: 0.5) { + Rectangle() + .frame(width: 2) + .foregroundStyle(.gray.opacity(0.2)) + ForEach(group.terminals, id: \.id) { terminal in + TerminalEmulatorView( + url: terminal.url, + terminalID: terminal.id, + shellType: terminal.shell, + onTitleChange: { [weak terminal] newTitle in + guard let id = terminal?.id else { return } + DispatchQueue.main.async { [weak utilityAreaViewModel] in + utilityAreaViewModel?.updateTerminal(id, title: newTitle) + } + } + ) + .frame(height: max(0, constrainedHeight - 1)) + .frame(minWidth: 400, maxWidth: .infinity) + .id(terminal.id) + .padding(.horizontal, 8) + + Rectangle() + .frame(width: 2) + .foregroundStyle(.gray.opacity(0.2)) + } } + .frame(minWidth: containerWidth) + .frame(maxWidth: .infinity, alignment: .leading) } - ) - .frame(height: max(0, constrainedHeight - 1)) - .id(selectedTerminal.id) + + Rectangle() + .frame(height: 2) + .foregroundStyle(.gray.opacity(0.2)) + } } } + } else { CEContentUnavailableView("No Selection") } } .padding(.horizontal, 10) + .padding(.bottom, 10) .paneToolbar { PaneToolbarSection { UtilityAreaTerminalPicker( @@ -158,7 +201,7 @@ struct UtilityAreaTerminalView: View { } .colorScheme(terminalColorScheme) } leadingSidebar: { _ in - UtilityAreaTerminalSidebar() + UtilityAreaTerminalSidebarWrapper(viewModel: utilityAreaViewModel) } .onAppear { guard let workspaceURL = workspace.fileURL else { @@ -183,3 +226,11 @@ struct UtilityAreaTerminalView: View { } } } + +struct UtilityAreaTerminalSidebarWrapper: View { + @ObservedObject var viewModel: UtilityAreaViewModel + + var body: some View { + return UtilityAreaTerminalSidebar() + } +} diff --git a/CodeEdit/Features/UtilityArea/ViewModels/UtilityAreaViewModel.swift b/CodeEdit/Features/UtilityArea/ViewModels/UtilityAreaViewModel.swift index 0cc075464..809c345da 100644 --- a/CodeEdit/Features/UtilityArea/ViewModels/UtilityAreaViewModel.swift +++ b/CodeEdit/Features/UtilityArea/ViewModels/UtilityAreaViewModel.swift @@ -6,50 +6,165 @@ // import SwiftUI +import UniformTypeIdentifiers -/// # UtilityAreaViewModel -/// -/// A model class to host and manage data for the Utility area. +/// View model responsible for managing terminal groups, individual terminals, +/// selection state, drag-and-drop operations, and utility panel configuration. class UtilityAreaViewModel: ObservableObject { + // MARK: - UI State + + /// Currently selected tab in the utility area. @Published var selectedTab: UtilityAreaTab? = .terminal + /// Flat list of all terminals, derived from `terminalGroups`. @Published var terminals: [UtilityAreaTerminal] = [] - @Published var selectedTerminals: Set = [] + /// List of terminal groups. + /// Automatically updates the flat `terminals` array when changed. + @Published var terminalGroups: [UtilityAreaTerminalGroup] = [] { + didSet { + self.terminals = terminalGroups.flatMap { $0.terminals } + } + } + + /// Set of selected terminal IDs. + @Published var selectedTerminals: Set = [] + + /// ID of the terminal currently hovered as a drop target. + @Published var dragOverTerminalID: UUID? - /// Indicates whether debugger is collapse or not + /// ID of the terminal being dragged. + @Published var draggedTerminalID: UUID? + + /// Whether the utility area is currently collapsed. @Published var isCollapsed: Bool = false - /// Indicates whether collapse animation should be enabled when utility area is toggled + /// Whether the panel collapse/expand action should animate. @Published var animateCollapse: Bool = true - /// Returns true when the drawer is visible + /// Whether the utility area is maximized. @Published var isMaximized: Bool = false - /// The current height of the drawer. Zero if hidden + /// Current height of the utility area panel. @Published var currentHeight: Double = 0 - /// The tab bar items for the UtilityAreaView + /// ID of the terminal currently being edited (e.g. for inline title editing). + @Published var editingTerminalID: UUID? + + /// Available tabs in the utility area. @Published var tabItems: [UtilityAreaTab] = UtilityAreaTab.allCases - /// The tab bar view model for UtilityAreaTabView + /// View model for the current tab (e.g. terminal tab). @Published var tabViewModel = UtilityAreaTabViewModel() - // MARK: - State Restoration + /// ID of the group currently being edited (e.g. renaming). + @Published var editingGroupID: UUID? + + /// Focus state for managing terminal keyboard focus. + @FocusState private var focusedTerminalID: UUID? + + // MARK: - Drag-and-Drop Support + + /// Previews a terminal move by temporarily updating the groups' array structure. + func previewMoveTerminal(_ terminalID: UUID, toGroup groupID: UUID, before destinationID: UUID?) { + guard let currentGroupIndex = terminalGroups.firstIndex(where: { + $0.terminals.contains(where: { $0.id == terminalID }) + }), + let currentTerminalIndex = terminalGroups[currentGroupIndex] + .terminals.firstIndex(where: { $0.id == terminalID }) else { + return + } + + let currentGroupID = terminalGroups[currentGroupIndex].id + if currentGroupID == groupID, + let destID = destinationID, + terminalGroups[currentGroupIndex].terminals.firstIndex(where: { $0.id == destID }) == currentTerminalIndex { + return + } + + let terminal = terminalGroups[currentGroupIndex].terminals[currentTerminalIndex] + terminalGroups[currentGroupIndex].terminals.remove(at: currentTerminalIndex) + terminalGroups[currentGroupIndex].terminals = terminalGroups[currentGroupIndex].terminals + + if let targetIndex = terminalGroups.firstIndex(where: { $0.id == groupID }) { + var group = terminalGroups[targetIndex] + if let destID = destinationID, + let destIndex = group.terminals.firstIndex(where: { $0.id == destID }) { + group.terminals.insert(terminal, at: destIndex) + } else { + group.terminals.append(terminal) + } + terminalGroups[targetIndex] = group + } + } + + /// Finalizes a terminal move across or within groups, updating the actual structure. + func finalizeMoveTerminal(_ terminal: UtilityAreaTerminal, toGroup groupID: UUID, before destinationID: UUID?) { + let alreadyInGroup = terminalGroups.contains { group in + group.id == groupID && + group.terminals.count == 1 && + group.terminals.first?.id == terminal.id + } + + guard !alreadyInGroup else { return } + + // Remove terminal from all groups + for index in terminalGroups.indices { + terminalGroups[index].terminals.removeAll { $0.id == terminal.id } + } + + // Remove empty groups + terminalGroups.removeAll { $0.terminals.isEmpty } + + // Insert into new group + guard let groupIndex = terminalGroups.firstIndex(where: { $0.id == groupID }) else { + renameGroups() + return + } + + if let destinationID, + let destinationIndex = terminalGroups[groupIndex].terminals.firstIndex(where: { $0.id == destinationID }) { + terminalGroups[groupIndex].terminals.insert(terminal, at: destinationIndex) + } else { + terminalGroups[groupIndex].terminals.append(terminal) + } + + // Update selection + if !selectedTerminals.contains(terminal.id) { + selectedTerminals = [terminal.id] + } + + renameGroups() + } + /// Removes a terminal from all groups by ID and returns it. + private func removeTerminal(withID id: UUID) -> UtilityAreaTerminal? { + for index in terminalGroups.indices { + if let terminalIndex = terminalGroups[index].terminals.firstIndex(where: { $0.id == id }) { + return terminalGroups[index].terminals.remove(at: terminalIndex) + } + } + return nil + } + + // MARK: - Panel State Restoration + + /// Restores panel state from the workspace object (collapsed, height, maximized). func restoreFromState(_ workspace: WorkspaceDocument) { isCollapsed = workspace.getFromWorkspaceState(.utilityAreaCollapsed) as? Bool ?? false currentHeight = workspace.getFromWorkspaceState(.utilityAreaHeight) as? Double ?? 300.0 isMaximized = workspace.getFromWorkspaceState(.utilityAreaMaximized) as? Bool ?? false } + /// Persists current panel state into the workspace object. func saveRestorationState(_ workspace: WorkspaceDocument) { workspace.addToWorkspaceState(key: .utilityAreaCollapsed, value: isCollapsed) workspace.addToWorkspaceState(key: .utilityAreaHeight, value: currentHeight) workspace.addToWorkspaceState(key: .utilityAreaMaximized, value: isMaximized) } + /// Toggles panel collapse with optional animation. func togglePanel(animation: Bool = true) { self.animateCollapse = animation self.isMaximized = false @@ -58,103 +173,109 @@ class UtilityAreaViewModel: ObservableObject { // MARK: - Terminal Management - /// Removes all terminals included in the given set and selects a new terminal if the selection was modified. - /// The new selection is either the same selection minus the ids removed, or if that's empty the last terminal. - /// - Parameter ids: A set of all terminal ids to remove. + /// Removes terminals by their IDs and updates groups and selection. func removeTerminals(_ ids: Set) { - for (idx, terminal) in terminals.enumerated().reversed() - where ids.contains(terminal.id) { - TerminalCache.shared.removeCachedView(terminal.id) - terminals.remove(at: idx) + for index in terminalGroups.indices { + terminalGroups[index].terminals.removeAll { ids.contains($0.id) } } + terminalGroups.removeAll { $0.terminals.isEmpty } - var newSelection = selectedTerminals.subtracting(ids) - - if newSelection.isEmpty, let terminal = terminals.last { - newSelection = [terminal.id] + selectedTerminals.subtract(ids) + if selectedTerminals.isEmpty, + let last = terminalGroups.last?.terminals.last { + selectedTerminals = [last.id] } - - selectedTerminals = newSelection + renameGroups() } - /// Update a terminal's title. - /// - Parameters: - /// - id: The id of the terminal to update. - /// - title: The title to set. If left `nil`, will set the terminal's - /// ``UtilityAreaTerminal/customTitle`` to `false`. + /// Updates a terminal's title, or resets it if `nil`. func updateTerminal(_ id: UUID, title: String?) { - guard let terminal = terminals.first(where: { $0.id == id }) else { return } - if let newTitle = title { - if !terminal.customTitle { - terminal.title = newTitle + for index in terminalGroups.indices { + if let terminalIndex = terminalGroups[index].terminals.firstIndex(where: { $0.id == id }) { + if let newTitle = title { + terminalGroups[index].terminals[terminalIndex].title = newTitle + } else { + terminalGroups[index].terminals[terminalIndex].customTitle = false + } + break } - terminal.terminalTitle = newTitle - } else { - terminal.customTitle = false } } - /// Create a new terminal if there are no existing terminals. - /// Will not perform any action if terminals exist in the ``terminals`` array. - /// - Parameter workspaceURL: The base url of the workspace, to initialize terminals.l + /// Initializes a default terminal if none exist. func initializeTerminals(workspaceURL: URL) { - guard terminals.isEmpty else { return } + guard terminalGroups.flatMap({ $0.terminals }).isEmpty else { return } addTerminal(rootURL: workspaceURL) } - /// Add a new terminal to the workspace and selects it. - /// - Parameters: - /// - shell: The shell to use, `nil` if auto-detect the default shell. - /// - rootURL: The url to start the new terminal at. If left `nil` defaults to the user's home directory. - func addTerminal(shell: Shell? = nil, rootURL: URL?) { - let id = UUID() - - terminals.append( - UtilityAreaTerminal( - id: id, - url: rootURL ?? URL(filePath: "~/"), - title: shell?.rawValue ?? "terminal", - shell: shell - ) + /// Adds a new terminal, optionally to a specific group and with a specific shell. + func addTerminal(to groupID: UUID? = nil, shell: Shell? = nil, rootURL: URL?) { + let newTerminal = UtilityAreaTerminal( + id: UUID(), + url: rootURL ?? URL(filePath: "~/"), + title: shell?.rawValue ?? "terminal", + shell: shell ) - selectedTerminals = [id] + if let groupID, let index = terminalGroups.firstIndex(where: { $0.id == groupID }) { + terminalGroups[index].terminals.append(newTerminal) + } else { + terminalGroups.append(.init(name: "2 Terminals", terminals: [newTerminal])) + } + + selectedTerminals = [newTerminal.id] + renameGroups() } - /// Replaces the terminal with a given ID, killing the shell and restarting it at the same directory. - /// - /// Terminals being replaced will have the `SIGKILL` signal sent to the running shell. The new terminal will - /// inherit the same `url` and `shell` parameters from the old one. - /// - Parameter replacing: The ID of a terminal to replace with a new terminal. + /// Replaces a terminal with a new instance, useful for restarting. func replaceTerminal(_ replacing: UUID) { - guard let index = terminals.firstIndex(where: { $0.id == replacing }) else { - return + for index in terminalGroups.indices { + if let idx = terminalGroups[index].terminals.firstIndex(where: { $0.id == replacing }) { + let url = terminalGroups[index].terminals[idx].url + let shell = terminalGroups[index].terminals[idx].shell + if let shellPid = TerminalCache.shared.getTerminalView(replacing)?.process.shellPid { + kill(shellPid, SIGKILL) + } + let newTerminal = UtilityAreaTerminal( + id: UUID(), + url: url, + title: shell?.rawValue ?? "terminal", + shell: shell + ) + terminalGroups[index].terminals[idx] = newTerminal + TerminalCache.shared.removeCachedView(replacing) + selectedTerminals = [newTerminal.id] + break + } } + } - let id = UUID() - let url = terminals[index].url - let shell = terminals[index].shell - if let shellPid = TerminalCache.shared.getTerminalView(replacing)?.process.shellPid { - kill(shellPid, SIGKILL) - } + /// Reorders terminals in the flat `terminals` list (UI only). + func reorderTerminals(from source: IndexSet, to destination: Int) { + terminals.move(fromOffsets: source, toOffset: destination) + } - terminals[index] = UtilityAreaTerminal( - id: id, - url: url, - title: shell?.rawValue ?? "terminal", - shell: shell - ) - TerminalCache.shared.removeCachedView(replacing) + /// Moves a terminal to a specific group and index. + func moveTerminal(_ terminal: UtilityAreaTerminal, toGroup targetGroupID: UUID, at index: Int) { + for index in terminalGroups.indices { + terminalGroups[index].terminals.removeAll { $0.id == terminal.id } + } + if let idx = terminalGroups.firstIndex(where: { $0.id == targetGroupID }) { + terminalGroups[idx].terminals.insert(terminal, at: index) + } + } - selectedTerminals = [id] - return + /// Creates a new terminal group with the given terminals. + func createGroup(with terminals: [UtilityAreaTerminal]) { + terminalGroups.append(.init(name: "\(terminalGroups.count) Terminals", terminals: terminals)) } - /// Reorders terminals in the ``utilityAreaViewModel``. - /// - Parameters: - /// - source: The source indices. - /// - destination: The destination indices. - func reorderTerminals(from source: IndexSet, to destination: Int) { - terminals.move(fromOffsets: source, toOffset: destination) + // MARK: - PRIVATE METHODS + + /// Rename all terminal groups that not has user name specifed. + private func renameGroups() { + for index in terminalGroups.indices where !terminalGroups[index].userName { + terminalGroups[index].name = "\(terminalGroups[index].terminals.count) Terminals" + } } } diff --git a/CodeEdit/Info.plist b/CodeEdit/Info.plist index 962625428..9b12ec2a8 100644 --- a/CodeEdit/Info.plist +++ b/CodeEdit/Info.plist @@ -1288,5 +1288,25 @@ https://github.com/CodeEditApp/CodeEdit/releases/latest/download/appcast.xml SUPublicEDKey /vAnxnK9wj4IqnUt6wS9EN3Ug69zHb+S/Pb9CyZuwa0= + UTExportedTypeDeclarations + + + UTTypeIdentifier + dev.codeedit.terminal + UTTypeConformsTo + + public.data + + UTTypeDescription + Terminal Drag Info + UTTypeTagSpecification + + public.filename-extension + + term + + + +