From 584f52fa4e9099c7e5029c2d6ed7a3e400d0f3e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonardo=20Larra=C3=B1aga?= Date: Mon, 14 Apr 2025 17:52:42 -0700 Subject: [PATCH 01/12] Disable 'Export All Custom Themes' if custom themes is empty --- .../Settings/Pages/ThemeSettings/ThemeSettingsView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsView.swift b/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsView.swift index 813479cdc..7ff0ceca7 100644 --- a/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsView.swift +++ b/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsView.swift @@ -53,6 +53,7 @@ struct ThemeSettingsView: View { } label: { Text("Export All Custom Themes...") } + .disabled(themeModel.themes.filter { !$0.isBundled }.isEmpty) } }) .padding(.horizontal, 5) From d96143c06c03f8e320d5114ef508ff12dbc0c4e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonardo=20Larra=C3=B1aga?= Date: Mon, 21 Apr 2025 18:32:20 -0700 Subject: [PATCH 02/12] Added Port tabs --- .../UtilityArea/Models/UtilityAreaTab.swift | 19 +++++++++++++------ .../PortsUtility/UtilityAreaPortsView.swift | 14 ++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) create mode 100644 CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsView.swift diff --git a/CodeEdit/Features/UtilityArea/Models/UtilityAreaTab.swift b/CodeEdit/Features/UtilityArea/Models/UtilityAreaTab.swift index 2056b6d7d..5e1c08f6a 100644 --- a/CodeEdit/Features/UtilityArea/Models/UtilityAreaTab.swift +++ b/CodeEdit/Features/UtilityArea/Models/UtilityAreaTab.swift @@ -13,26 +13,31 @@ enum UtilityAreaTab: WorkspacePanelTab, CaseIterable { case terminal case debugConsole case output + case ports var title: String { switch self { case .terminal: - return "Terminal" + "Terminal" case .debugConsole: - return "Debug Console" + "Debug Console" case .output: - return "Output" + "Output" + case .ports: + "Ports" } } var systemImage: String { switch self { case .terminal: - return "terminal" + "terminal" case .debugConsole: - return "ladybug" + "ladybug" case .output: - return "list.bullet.indent" + "list.bullet.indent" + case .ports: + "powerplug" } } @@ -44,6 +49,8 @@ enum UtilityAreaTab: WorkspacePanelTab, CaseIterable { UtilityAreaDebugView() case .output: UtilityAreaOutputView() + case .ports: + UtilityAreaPortsView() } } } diff --git a/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsView.swift b/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsView.swift new file mode 100644 index 000000000..17ece9c46 --- /dev/null +++ b/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsView.swift @@ -0,0 +1,14 @@ +// +// UtilityAreaPortsView.swift +// CodeEdit +// +// Created by Leonardo Larrañaga on 4/21/25. +// + +import SwiftUI + +struct UtilityAreaPortsView: View { + var body: some View { + Text("Ports") + } +} From a17a2e1f4ecfe125b6331bbb2940b9ffde14b262 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonardo=20Larra=C3=B1aga?= Date: Mon, 21 Apr 2025 18:51:50 -0700 Subject: [PATCH 03/12] No ports view --- .../UtilityArea/Models/UtilityAreaPort.swift | 21 ++++++++++++ .../PortsUtility/UtilityAreaPortsView.swift | 33 ++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 CodeEdit/Features/UtilityArea/Models/UtilityAreaPort.swift diff --git a/CodeEdit/Features/UtilityArea/Models/UtilityAreaPort.swift b/CodeEdit/Features/UtilityArea/Models/UtilityAreaPort.swift new file mode 100644 index 000000000..d7c3b0b90 --- /dev/null +++ b/CodeEdit/Features/UtilityArea/Models/UtilityAreaPort.swift @@ -0,0 +1,21 @@ +// +// UtilityAreaPort.swift +// CodeEdit +// +// Created by Leonardo Larrañaga on 4/21/25. +// + +import Foundation + +/// A forwared port for the UtilityArea +final class UtilityAreaPort: ObservableObject { + let id: UUID + let port: String + @Published var label: String + + init(id: UUID = UUID(), port: String) { + self.id = id + self.port = port + self.label = "" + } +} diff --git a/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsView.swift b/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsView.swift index 17ece9c46..df2be5f82 100644 --- a/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsView.swift +++ b/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsView.swift @@ -8,7 +8,38 @@ import SwiftUI struct UtilityAreaPortsView: View { + + @EnvironmentObject private var utilityAreaViewModel: UtilityAreaViewModel + + @State private var forwardedPorts = [UtilityAreaPort]() + + @State private var filterText = "" + var body: some View { - Text("Ports") + UtilityAreaTabView(model: utilityAreaViewModel.tabViewModel) { _ in + Group { + if !forwardedPorts.isEmpty { + Text("Ports") + } else { + CEContentUnavailableView( + "No Forwarded Ports", + description: "Add a port to access your services over the internet.", + systemImage: "powerplug" + ) { + Button("Forward a Port", action: forwardPort) + } + } + } + .paneToolbar { + Button("Add Port", systemImage: "plus", action: forwardPort) + Button("Remove Port", systemImage: "minus", action: {}) + Spacer() + UtilityAreaFilterTextField(title: "Filter", text: $filterText) + } + } + } + + func forwardPort() { + } } From 15a318cdd7a32bb38312e0c8b94016304331b713 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonardo=20Larra=C3=B1aga?= Date: Mon, 21 Apr 2025 19:11:07 -0700 Subject: [PATCH 04/12] Table and context menu very basic --- .../UtilityArea/Models/UtilityAreaPort.swift | 14 +++++------- .../PortsUtility/UtilityAreaPortsView.swift | 22 +++++++++++++++++-- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/CodeEdit/Features/UtilityArea/Models/UtilityAreaPort.swift b/CodeEdit/Features/UtilityArea/Models/UtilityAreaPort.swift index d7c3b0b90..90895226a 100644 --- a/CodeEdit/Features/UtilityArea/Models/UtilityAreaPort.swift +++ b/CodeEdit/Features/UtilityArea/Models/UtilityAreaPort.swift @@ -8,14 +8,12 @@ import Foundation /// A forwared port for the UtilityArea -final class UtilityAreaPort: ObservableObject { - let id: UUID - let port: String - @Published var label: String +struct UtilityAreaPort: Identifiable, Hashable { + let id = UUID() + let address: String + var label = "" - init(id: UUID = UUID(), port: String) { - self.id = id - self.port = port - self.label = "" + var url: URL? { + URL(string: address) } } diff --git a/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsView.swift b/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsView.swift index df2be5f82..abcb06220 100644 --- a/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsView.swift +++ b/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsView.swift @@ -12,6 +12,14 @@ struct UtilityAreaPortsView: View { @EnvironmentObject private var utilityAreaViewModel: UtilityAreaViewModel @State private var forwardedPorts = [UtilityAreaPort]() + var ports: [UtilityAreaPort] { + filterText.isEmpty ? forwardedPorts : forwardedPorts.filter { port in + port.address.localizedCaseInsensitiveContains(filterText) || + port.label.localizedCaseInsensitiveContains(filterText) + } + } + + @State private var selectedPort: UtilityAreaPort.ID? @State private var filterText = "" @@ -19,7 +27,16 @@ struct UtilityAreaPortsView: View { UtilityAreaTabView(model: utilityAreaViewModel.tabViewModel) { _ in Group { if !forwardedPorts.isEmpty { - Text("Ports") + Table(ports, selection: $selectedPort) { + TableColumn("Port", value: \.label) + } + .contextMenu(forSelectionType: UtilityAreaPort.ID.self) { items in + if let port = ports.first(where: { $0.id == items.first }) { + Link("Open in browser", destination: port.url!) + } else { + Text("?") + } + } } else { CEContentUnavailableView( "No Forwarded Ports", @@ -35,11 +52,12 @@ struct UtilityAreaPortsView: View { Button("Remove Port", systemImage: "minus", action: {}) Spacer() UtilityAreaFilterTextField(title: "Filter", text: $filterText) + .frame(maxWidth: 175) } } } func forwardPort() { - + forwardedPorts.append(UtilityAreaPort(address: "localhost", label: "Port \(forwardedPorts.count + 1)")) } } From c357a3766d8f7a06968af1cd5ce43a3179ec5ab1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonardo=20Larra=C3=B1aga?= Date: Mon, 21 Apr 2025 19:45:52 -0700 Subject: [PATCH 05/12] Context Menu --- .../UtilityArea/Models/UtilityAreaPort.swift | 52 +++++++++++++++++-- .../UtilityAreaPortsContextMenu.swift | 44 ++++++++++++++++ .../PortsUtility/UtilityAreaPortsView.swift | 11 ++-- 3 files changed, 99 insertions(+), 8 deletions(-) create mode 100644 CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsContextMenu.swift diff --git a/CodeEdit/Features/UtilityArea/Models/UtilityAreaPort.swift b/CodeEdit/Features/UtilityArea/Models/UtilityAreaPort.swift index 90895226a..7fa723767 100644 --- a/CodeEdit/Features/UtilityArea/Models/UtilityAreaPort.swift +++ b/CodeEdit/Features/UtilityArea/Models/UtilityAreaPort.swift @@ -8,10 +8,56 @@ import Foundation /// A forwared port for the UtilityArea -struct UtilityAreaPort: Identifiable, Hashable { - let id = UUID() +final class UtilityAreaPort: Identifiable, ObservableObject { + let id: UUID let address: String - var label = "" + + @Published var label: String + @Published var forwaredAddress = "" + @Published var runningProcess = "" + @Published var visibility = Visibility.privatePort + @Published var origin = Origin.userForwarded + @Published var portProtocol = PortProtocol.https + + init(address: String, label: String = "") { + self.id = UUID() + self.address = address + self.label = label + } + + enum Visibility: String, CaseIterable { + case publicPort + case privatePort + + var rawValue: String { + switch self { + case .publicPort: "Public" + case .privatePort: "Private" + } + } + } + + enum Origin: String { + case userForwarded + + var rawValue: String { + switch self { + case .userForwarded: "User Forwarded" + } + } + } + + enum PortProtocol: String, CaseIterable { + case http + case https + + var rawValue: String { + switch self { + case .http: "HTTP" + case .https: "HTTPS" + } + } + } var url: URL? { URL(string: address) diff --git a/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsContextMenu.swift b/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsContextMenu.swift new file mode 100644 index 000000000..96a638d79 --- /dev/null +++ b/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsContextMenu.swift @@ -0,0 +1,44 @@ +// +// UtilityAreaPortsMenu.swift +// CodeEdit +// +// Created by Leonardo Larrañaga on 4/21/25. +// + +import SwiftUI + +struct UtilityAreaPortsContextMenu: View { + + @Binding var port: UtilityAreaPort + + var body: some View { + Group { + Link("Open in Browser", destination: URL(string: port.forwaredAddress) ?? + URL(string: "https://localhost:3000")!) + Button("Preview in Editor", action: {}) + Divider() + + Button("Set Port Label", action: {}) + Divider() + + Button("Copy Local Address", action: {}) + .keyboardShortcut("c", modifiers: [.command]) + Picker("Port Visiblity", selection: $port.visibility) { + ForEach(UtilityAreaPort.Visibility.allCases, id: \.self) { visibility in + Text(visibility.rawValue) + .tag(visibility) + } + } + Picker("Change Port Protocol", selection: $port.portProtocol) { + ForEach(UtilityAreaPort.PortProtocol.allCases, id: \.self) { protocolType in + Text(protocolType.rawValue) + .tag(protocolType) + } + } + Divider() + + Button("Stop Forwarding Port", action: {}) + Button("Forward a Port", action: {}) + } + } +} diff --git a/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsView.swift b/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsView.swift index abcb06220..1031325c7 100644 --- a/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsView.swift +++ b/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsView.swift @@ -28,13 +28,14 @@ struct UtilityAreaPortsView: View { Group { if !forwardedPorts.isEmpty { Table(ports, selection: $selectedPort) { - TableColumn("Port", value: \.label) + TableColumn("Label", value: \.label) + TableColumn("Forwarded Address", value: \.forwaredAddress) + TableColumn("Visibility", value: \.visibility.rawValue) + TableColumn("Origin", value: \.origin.rawValue) } .contextMenu(forSelectionType: UtilityAreaPort.ID.self) { items in - if let port = ports.first(where: { $0.id == items.first }) { - Link("Open in browser", destination: port.url!) - } else { - Text("?") + if let id = items.first, let index = forwardedPorts.firstIndex(where: { $0.id == id }) { + UtilityAreaPortsContextMenu(port: $forwardedPorts[index]) } } } else { From d660767487de7237b7d5f11cabb6673c06c0c03c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonardo=20Larra=C3=B1aga?= Date: Mon, 21 Apr 2025 19:56:08 -0700 Subject: [PATCH 06/12] View model --- .../CodeEditSplitViewController.swift | 4 +++- .../CodeEditWindowControllerExtensions.swift | 4 +++- .../WorkspaceDocument/WorkspaceDocument.swift | 3 +++ CodeEdit/Features/Ports/PortsManager.swift | 14 ++++++++++++++ .../PortsUtility/UtilityAreaPortsView.swift | 18 +++++++++--------- 5 files changed, 32 insertions(+), 11 deletions(-) create mode 100644 CodeEdit/Features/Ports/PortsManager.swift diff --git a/CodeEdit/Features/Documents/Controllers/CodeEditSplitViewController.swift b/CodeEdit/Features/Documents/Controllers/CodeEditSplitViewController.swift index 7551c0bed..fa0126dfc 100644 --- a/CodeEdit/Features/Documents/Controllers/CodeEditSplitViewController.swift +++ b/CodeEdit/Features/Documents/Controllers/CodeEditSplitViewController.swift @@ -52,7 +52,8 @@ final class CodeEditSplitViewController: NSSplitViewController { let editorManager = workspace.editorManager, let statusBarViewModel = workspace.statusBarViewModel, let utilityAreaModel = workspace.utilityAreaModel, - let taskManager = workspace.taskManager else { + let taskManager = workspace.taskManager, + let portsManager = workspace.portsManager else { // swiftlint:disable:next line_length assertionFailure("Missing a workspace model: workspace=\(workspace == nil), navigator=\(navigatorViewModel == nil), editorManager=\(workspace?.editorManager == nil), statusBarModel=\(workspace?.statusBarViewModel == nil), utilityAreaModel=\(workspace?.utilityAreaModel == nil), taskManager=\(workspace?.taskManager == nil)") return @@ -76,6 +77,7 @@ final class CodeEditSplitViewController: NSSplitViewController { .environmentObject(statusBarViewModel) .environmentObject(utilityAreaModel) .environmentObject(taskManager) + .environmentObject(portsManager) } } diff --git a/CodeEdit/Features/Documents/Controllers/CodeEditWindowControllerExtensions.swift b/CodeEdit/Features/Documents/Controllers/CodeEditWindowControllerExtensions.swift index 88e7dbc9b..e08288bd4 100644 --- a/CodeEdit/Features/Documents/Controllers/CodeEditWindowControllerExtensions.swift +++ b/CodeEdit/Features/Documents/Controllers/CodeEditWindowControllerExtensions.swift @@ -104,7 +104,8 @@ extension CodeEditWindowController { guard let window = window, let workspace = workspace, let workspaceSettingsManager = workspace.workspaceSettingsManager, - let taskManager = workspace.taskManager + let taskManager = workspace.taskManager, + let portsManager = workspace.portsManager else { return } if let workspaceSettingsWindow, workspaceSettingsWindow.isVisible { @@ -121,6 +122,7 @@ extension CodeEditWindowController { .environmentObject(workspaceSettingsManager) .environmentObject(workspace) .environmentObject(taskManager) + .environmentObject(portsManager) settingsWindow.contentView = NSHostingView(rootView: contentView) settingsWindow.titlebarAppearsTransparent = true diff --git a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift index a47fdbba8..82d3f7058 100644 --- a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift +++ b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift @@ -45,6 +45,8 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate { var workspaceSettingsManager: CEWorkspaceSettings? var taskNotificationHandler: TaskNotificationHandler = TaskNotificationHandler() + var portsManager: PortsManager? + @Published var notificationPanel = NotificationPanelViewModel() private var cancellables = Set() @@ -161,6 +163,7 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate { workspaceURL: url ) } + self.portsManager = PortsManager() editorManager?.restoreFromState(self) utilityAreaModel?.restoreFromState(self) diff --git a/CodeEdit/Features/Ports/PortsManager.swift b/CodeEdit/Features/Ports/PortsManager.swift new file mode 100644 index 000000000..818f96a31 --- /dev/null +++ b/CodeEdit/Features/Ports/PortsManager.swift @@ -0,0 +1,14 @@ +// +// PortsManager.swift +// CodeEdit +// +// Created by Leonardo Larrañaga on 4/21/25. +// + +import SwiftUI + +/// This class manages the forwarded ports for the utility area. +class PortsManager: ObservableObject { + @Published var forwardedPorts = [UtilityAreaPort]() + @Published var selectedPort: UtilityAreaPort.ID? +} diff --git a/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsView.swift b/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsView.swift index 1031325c7..6e92647ea 100644 --- a/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsView.swift +++ b/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsView.swift @@ -11,31 +11,31 @@ struct UtilityAreaPortsView: View { @EnvironmentObject private var utilityAreaViewModel: UtilityAreaViewModel - @State private var forwardedPorts = [UtilityAreaPort]() + @EnvironmentObject private var portsManager: PortsManager + var ports: [UtilityAreaPort] { - filterText.isEmpty ? forwardedPorts : forwardedPorts.filter { port in + filterText.isEmpty ? portsManager.forwardedPorts : portsManager.forwardedPorts.filter { port in port.address.localizedCaseInsensitiveContains(filterText) || port.label.localizedCaseInsensitiveContains(filterText) } } - @State private var selectedPort: UtilityAreaPort.ID? - @State private var filterText = "" var body: some View { UtilityAreaTabView(model: utilityAreaViewModel.tabViewModel) { _ in Group { - if !forwardedPorts.isEmpty { - Table(ports, selection: $selectedPort) { + if !portsManager.forwardedPorts.isEmpty { + Table(ports, selection: $portsManager.selectedPort) { TableColumn("Label", value: \.label) TableColumn("Forwarded Address", value: \.forwaredAddress) TableColumn("Visibility", value: \.visibility.rawValue) TableColumn("Origin", value: \.origin.rawValue) } .contextMenu(forSelectionType: UtilityAreaPort.ID.self) { items in - if let id = items.first, let index = forwardedPorts.firstIndex(where: { $0.id == id }) { - UtilityAreaPortsContextMenu(port: $forwardedPorts[index]) + if let id = items.first, + let index = portsManager.forwardedPorts.firstIndex(where: { $0.id == id }) { + UtilityAreaPortsContextMenu(port: $portsManager.forwardedPorts[index]) } } } else { @@ -59,6 +59,6 @@ struct UtilityAreaPortsView: View { } func forwardPort() { - forwardedPorts.append(UtilityAreaPort(address: "localhost", label: "Port \(forwardedPorts.count + 1)")) + portsManager.forwardedPorts.append(UtilityAreaPort(address: "localhost", label: "Port \(portsManager.forwardedPorts.count + 1)")) } } From 17565de9c2a4c6d14b1852dc6574b232b3131c4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonardo=20Larra=C3=B1aga?= Date: Mon, 21 Apr 2025 20:22:04 -0700 Subject: [PATCH 07/12] Add/Remove ports from table --- CodeEdit/Features/Ports/PortsManager.swift | 20 ++++++++++ .../UtilityArea/Models/UtilityAreaPort.swift | 4 +- .../UtilityAreaPortsContextMenu.swift | 8 +++- .../PortsUtility/UtilityAreaPortsView.swift | 39 +++++++++++++++---- 4 files changed, 59 insertions(+), 12 deletions(-) diff --git a/CodeEdit/Features/Ports/PortsManager.swift b/CodeEdit/Features/Ports/PortsManager.swift index 818f96a31..38734cf78 100644 --- a/CodeEdit/Features/Ports/PortsManager.swift +++ b/CodeEdit/Features/Ports/PortsManager.swift @@ -11,4 +11,24 @@ import SwiftUI class PortsManager: ObservableObject { @Published var forwardedPorts = [UtilityAreaPort]() @Published var selectedPort: UtilityAreaPort.ID? + + @Published var showAddPortAlert = false + + func getSelectedPort() -> UtilityAreaPort? { + forwardedPorts.first { $0.id == selectedPort } + } + + func addForwardedPort() { + showAddPortAlert = true + } + + func forwardPort(with address: String) { + let newPort = UtilityAreaPort(address: address) + forwardedPorts.append(newPort) + selectedPort = newPort.id + } + + func stopForwarding(port: UtilityAreaPort) { + forwardedPorts.removeAll { $0.id == port.id } + } } diff --git a/CodeEdit/Features/UtilityArea/Models/UtilityAreaPort.swift b/CodeEdit/Features/UtilityArea/Models/UtilityAreaPort.swift index 7fa723767..62ad22ea2 100644 --- a/CodeEdit/Features/UtilityArea/Models/UtilityAreaPort.swift +++ b/CodeEdit/Features/UtilityArea/Models/UtilityAreaPort.swift @@ -19,10 +19,10 @@ final class UtilityAreaPort: Identifiable, ObservableObject { @Published var origin = Origin.userForwarded @Published var portProtocol = PortProtocol.https - init(address: String, label: String = "") { + init(address: String) { self.id = UUID() self.address = address - self.label = label + self.label = address } enum Visibility: String, CaseIterable { diff --git a/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsContextMenu.swift b/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsContextMenu.swift index 96a638d79..aafc4c442 100644 --- a/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsContextMenu.swift +++ b/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsContextMenu.swift @@ -10,6 +10,7 @@ import SwiftUI struct UtilityAreaPortsContextMenu: View { @Binding var port: UtilityAreaPort + @ObservedObject var portsManager: PortsManager var body: some View { Group { @@ -37,8 +38,11 @@ struct UtilityAreaPortsContextMenu: View { } Divider() - Button("Stop Forwarding Port", action: {}) - Button("Forward a Port", action: {}) + Button("Stop Forwarding Port") { + portsManager.stopForwarding(port: port) + } + .keyboardShortcut(.delete, modifiers: [.command]) + Button("Forward a Port", action: portsManager.addForwardedPort) } } } diff --git a/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsView.swift b/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsView.swift index 6e92647ea..84e3d1c9c 100644 --- a/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsView.swift +++ b/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsView.swift @@ -21,6 +21,15 @@ struct UtilityAreaPortsView: View { } @State private var filterText = "" + @State private var newPortAddress = "" + var isValidPort: Bool { + do { + //swiftlint:disable:next line_length + return try Regex(#"^(?:\d{1,5}|(?:[a-zA-Z0-9.-]+|\[[^\]]+\]):\d{1,5})$"#).wholeMatch(in: newPortAddress) != nil + } catch { + return false + } + } var body: some View { UtilityAreaTabView(model: utilityAreaViewModel.tabViewModel) { _ in @@ -35,7 +44,10 @@ struct UtilityAreaPortsView: View { .contextMenu(forSelectionType: UtilityAreaPort.ID.self) { items in if let id = items.first, let index = portsManager.forwardedPorts.firstIndex(where: { $0.id == id }) { - UtilityAreaPortsContextMenu(port: $portsManager.forwardedPorts[index]) + UtilityAreaPortsContextMenu( + port: $portsManager.forwardedPorts[index], + portsManager: portsManager + ) } } } else { @@ -44,21 +56,32 @@ struct UtilityAreaPortsView: View { description: "Add a port to access your services over the internet.", systemImage: "powerplug" ) { - Button("Forward a Port", action: forwardPort) + Button("Forward a Port", action: portsManager.addForwardedPort) } } } .paneToolbar { - Button("Add Port", systemImage: "plus", action: forwardPort) - Button("Remove Port", systemImage: "minus", action: {}) + Button("Add Port", systemImage: "plus", action: portsManager.addForwardedPort) + Button("Remove Port", systemImage: "minus") { + if let selectedPort = portsManager.getSelectedPort() { + portsManager.stopForwarding(port: selectedPort) + } + } Spacer() UtilityAreaFilterTextField(title: "Filter", text: $filterText) .frame(maxWidth: 175) } + .alert("Foward a Port", isPresented: $portsManager.showAddPortAlert) { + TextField("Port Number or Address", text: $newPortAddress) + Button("Cancel", role: .cancel) { + newPortAddress = "" + } + Button("Forward") { + portsManager.forwardPort(with: newPortAddress) + newPortAddress = "" + } + .disabled(!isValidPort) + } } } - - func forwardPort() { - portsManager.forwardedPorts.append(UtilityAreaPort(address: "localhost", label: "Port \(portsManager.forwardedPorts.count + 1)")) - } } From c9d83c839ffde32924db87515208b93def07ade3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonardo=20Larra=C3=B1aga?= Date: Wed, 23 Apr 2025 11:58:59 -0700 Subject: [PATCH 08/12] Ability to edit the port label directly from the table by double clicking it. --- CodeEdit/Features/Ports/PortsManager.swift | 4 + .../UtilityArea/Models/UtilityAreaPort.swift | 2 + .../PortsUtility/UtilityAreaPortsView.swift | 23 ++++- CodeEdit/Utils/InlineEditRow.swift | 89 +++++++++++++++++++ 4 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 CodeEdit/Utils/InlineEditRow.swift diff --git a/CodeEdit/Features/Ports/PortsManager.swift b/CodeEdit/Features/Ports/PortsManager.swift index 38734cf78..379274fce 100644 --- a/CodeEdit/Features/Ports/PortsManager.swift +++ b/CodeEdit/Features/Ports/PortsManager.swift @@ -14,6 +14,10 @@ class PortsManager: ObservableObject { @Published var showAddPortAlert = false + func getIndex(for id: UtilityAreaPort.ID?) -> Int? { + forwardedPorts.firstIndex { $0.id == id } + } + func getSelectedPort() -> UtilityAreaPort? { forwardedPorts.first { $0.id == selectedPort } } diff --git a/CodeEdit/Features/UtilityArea/Models/UtilityAreaPort.swift b/CodeEdit/Features/UtilityArea/Models/UtilityAreaPort.swift index 62ad22ea2..a5852c268 100644 --- a/CodeEdit/Features/UtilityArea/Models/UtilityAreaPort.swift +++ b/CodeEdit/Features/UtilityArea/Models/UtilityAreaPort.swift @@ -19,6 +19,8 @@ final class UtilityAreaPort: Identifiable, ObservableObject { @Published var origin = Origin.userForwarded @Published var portProtocol = PortProtocol.https + @Published var isEditingLabel = false + init(address: String) { self.id = UUID() self.address = address diff --git a/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsView.swift b/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsView.swift index 84e3d1c9c..f747f2cdc 100644 --- a/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsView.swift +++ b/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsView.swift @@ -24,7 +24,7 @@ struct UtilityAreaPortsView: View { @State private var newPortAddress = "" var isValidPort: Bool { do { - //swiftlint:disable:next line_length + // swiftlint:disable:next line_length return try Regex(#"^(?:\d{1,5}|(?:[a-zA-Z0-9.-]+|\[[^\]]+\]):\d{1,5})$"#).wholeMatch(in: newPortAddress) != nil } catch { return false @@ -36,7 +36,20 @@ struct UtilityAreaPortsView: View { Group { if !portsManager.forwardedPorts.isEmpty { Table(ports, selection: $portsManager.selectedPort) { - TableColumn("Label", value: \.label) + TableColumn("Port") { port in + if let index = portsManager.getIndex(for: port.id) { + InlineEditRow( + title: "Port Label", + text: $portsManager.forwardedPorts[index].label, + isEditing: $portsManager.forwardedPorts[index].isEditingLabel, + onSubmit: { + // Reselect the port after editing the label + portsManager.selectedPort = port.id + } + ) + .frame(maxWidth: .infinity, alignment: .leading) + } + } TableColumn("Forwarded Address", value: \.forwaredAddress) TableColumn("Visibility", value: \.visibility.rawValue) TableColumn("Origin", value: \.origin.rawValue) @@ -49,6 +62,12 @@ struct UtilityAreaPortsView: View { portsManager: portsManager ) } + } primaryAction: { items in + if let index = portsManager.getIndex(for: items.first) { + portsManager.forwardedPorts[index].isEditingLabel = true + // Workaround: unselect the row to trigger the focus change + portsManager.selectedPort = nil + } } } else { CEContentUnavailableView( diff --git a/CodeEdit/Utils/InlineEditRow.swift b/CodeEdit/Utils/InlineEditRow.swift new file mode 100644 index 000000000..8b6e2f68c --- /dev/null +++ b/CodeEdit/Utils/InlineEditRow.swift @@ -0,0 +1,89 @@ +// +// InlineEditRow.swift +// CodeEdit +// +// Created by Leonardo Larrañaga on 4/21/25. +// + +import SwiftUI + +/// A `View` for `Table` used for editing a `String` entry row. +struct InlineEditRow: View { + + let title: String + @Binding var text: String + @Binding var isEditing: Bool + let onSubmit: (() -> Void)? + + init(title: String, text: Binding, isEditing: Binding, onSubmit: (() -> Void)? = nil) { + self.title = title + self._text = text + self._isEditing = isEditing + self.onSubmit = onSubmit + self.focused = focused + self.editedText = editedText + } + + @FocusState private var focused: Bool + + @State var editedText: String = "" + + var body: some View { + Group { + if !isEditing { + Text(text) + } else { + TextField(title, text: $editedText) + .focused($focused) + .onSubmit(submitText) + } + } + .onChange(of: isEditing) { newValue in + // if the user is editing, select all text + if newValue { + DispatchQueue.main.async { + focused = true + NSApplication.shared.sendAction(#selector(NSResponder.selectAll(_:)), to: nil, from: nil) + } + } + } + .onChange(of: focused) { newValue in + if !newValue { + submitText() + } + } + .onAppear { + editedText = text + } + .onChange(of: text) { newValue in + // Update the edited text when the original text changes + if !isEditing { + editedText = newValue + } + } + } + + func submitText() { + // Only update the text if the user has finished editing + text = editedText + isEditing = false + onSubmit?() + } +} + +#Preview { + struct InlineEditRowPreview: View { + @State private var text: String = "Editable text" + @State private var isEditing: Bool = false + + var body: some View { + InlineEditRow(title: "Text", text: $text, isEditing: $isEditing) + .padding(50) + .onTapGesture { + isEditing = true + } + } + } + + return InlineEditRowPreview() +} From 0f6dc41b9b20d17203177359db227b5b30195161 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonardo=20Larra=C3=B1aga?= Date: Wed, 23 Apr 2025 12:02:15 -0700 Subject: [PATCH 09/12] Set Port Label Menu Item --- .../PortsUtility/UtilityAreaPortsContextMenu.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsContextMenu.swift b/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsContextMenu.swift index aafc4c442..42ab5de4b 100644 --- a/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsContextMenu.swift +++ b/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsContextMenu.swift @@ -19,7 +19,11 @@ struct UtilityAreaPortsContextMenu: View { Button("Preview in Editor", action: {}) Divider() - Button("Set Port Label", action: {}) + Button("Set Port Label") { + port.isEditingLabel = true + // Workaround: unselect the row to trigger the focus change + portsManager.selectedPort = nil + } Divider() Button("Copy Local Address", action: {}) From 086a615d7cdfee9711c5f5bf22ab532e8b846bfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonardo=20Larra=C3=B1aga?= Date: Wed, 23 Apr 2025 12:09:08 -0700 Subject: [PATCH 10/12] Prevent empty port label. Copy forwaded address. --- .../Features/UtilityArea/Models/UtilityAreaPort.swift | 9 ++++++++- .../PortsUtility/UtilityAreaPortsContextMenu.swift | 3 ++- CodeEdit/Utils/InlineEditRow.swift | 4 +++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/CodeEdit/Features/UtilityArea/Models/UtilityAreaPort.swift b/CodeEdit/Features/UtilityArea/Models/UtilityAreaPort.swift index a5852c268..ba1b82330 100644 --- a/CodeEdit/Features/UtilityArea/Models/UtilityAreaPort.swift +++ b/CodeEdit/Features/UtilityArea/Models/UtilityAreaPort.swift @@ -5,7 +5,7 @@ // Created by Leonardo Larrañaga on 4/21/25. // -import Foundation +import AppKit /// A forwared port for the UtilityArea final class UtilityAreaPort: Identifiable, ObservableObject { @@ -64,4 +64,11 @@ final class UtilityAreaPort: Identifiable, ObservableObject { var url: URL? { URL(string: address) } + + func copyForwadedAddress() { + guard let url = url else { return } + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(url.absoluteString, forType: .string) + } } diff --git a/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsContextMenu.swift b/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsContextMenu.swift index 42ab5de4b..55c9855af 100644 --- a/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsContextMenu.swift +++ b/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsContextMenu.swift @@ -17,6 +17,7 @@ struct UtilityAreaPortsContextMenu: View { Link("Open in Browser", destination: URL(string: port.forwaredAddress) ?? URL(string: "https://localhost:3000")!) Button("Preview in Editor", action: {}) + .disabled(true) Divider() Button("Set Port Label") { @@ -26,7 +27,7 @@ struct UtilityAreaPortsContextMenu: View { } Divider() - Button("Copy Local Address", action: {}) + Button("Copy Forwaded Address", action: port.copyForwadedAddress) .keyboardShortcut("c", modifiers: [.command]) Picker("Port Visiblity", selection: $port.visibility) { ForEach(UtilityAreaPort.Visibility.allCases, id: \.self) { visibility in diff --git a/CodeEdit/Utils/InlineEditRow.swift b/CodeEdit/Utils/InlineEditRow.swift index 8b6e2f68c..4dae705b3 100644 --- a/CodeEdit/Utils/InlineEditRow.swift +++ b/CodeEdit/Utils/InlineEditRow.swift @@ -65,7 +65,9 @@ struct InlineEditRow: View { func submitText() { // Only update the text if the user has finished editing - text = editedText + if !editedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + text = editedText + } isEditing = false onSubmit?() } From 1cef0ec9f75df9e703d308b65bb397ae58169a50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonardo=20Larra=C3=B1aga?= Date: Wed, 23 Apr 2025 12:16:11 -0700 Subject: [PATCH 11/12] Regex for http/https. Forwaded Address row --- CodeEdit/Features/Ports/PortsManager.swift | 1 + .../PortsUtility/UtilityAreaPortsView.swift | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CodeEdit/Features/Ports/PortsManager.swift b/CodeEdit/Features/Ports/PortsManager.swift index 379274fce..046d10761 100644 --- a/CodeEdit/Features/Ports/PortsManager.swift +++ b/CodeEdit/Features/Ports/PortsManager.swift @@ -28,6 +28,7 @@ class PortsManager: ObservableObject { func forwardPort(with address: String) { let newPort = UtilityAreaPort(address: address) + newPort.forwaredAddress = address forwardedPorts.append(newPort) selectedPort = newPort.id } diff --git a/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsView.swift b/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsView.swift index f747f2cdc..ab819a038 100644 --- a/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsView.swift +++ b/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsView.swift @@ -25,7 +25,7 @@ struct UtilityAreaPortsView: View { var isValidPort: Bool { do { // swiftlint:disable:next line_length - return try Regex(#"^(?:\d{1,5}|(?:[a-zA-Z0-9.-]+|\[[^\]]+\]):\d{1,5})$"#).wholeMatch(in: newPortAddress) != nil + return try Regex(#"^(?:(https?:\/\/)?(?:[a-zA-Z0-9.-]+|\[[^\]]+\]):\d{1,5}|\d{1,5})$"#).wholeMatch(in: newPortAddress) != nil } catch { return false } @@ -50,7 +50,13 @@ struct UtilityAreaPortsView: View { .frame(maxWidth: .infinity, alignment: .leading) } } - TableColumn("Forwarded Address", value: \.forwaredAddress) + + TableColumn("Forwarded Address") { port in + if let url = port.url { + Link(url.absoluteString, destination: url) + } + } + TableColumn("Visibility", value: \.visibility.rawValue) TableColumn("Origin", value: \.origin.rawValue) } From 9dd6c4ce77d422c1a126b3308a8ca100630e3940 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonardo=20Larra=C3=B1aga?= Date: Wed, 23 Apr 2025 12:32:09 -0700 Subject: [PATCH 12/12] Added loading indicator and connection notification --- CodeEdit/Features/Ports/PortsManager.swift | 7 +++++++ .../UtilityArea/Models/UtilityAreaPort.swift | 16 ++++++++++++++++ .../PortsUtility/UtilityAreaPortsView.swift | 9 ++++++++- 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/CodeEdit/Features/Ports/PortsManager.swift b/CodeEdit/Features/Ports/PortsManager.swift index 046d10761..e8f5591fa 100644 --- a/CodeEdit/Features/Ports/PortsManager.swift +++ b/CodeEdit/Features/Ports/PortsManager.swift @@ -31,6 +31,13 @@ class PortsManager: ObservableObject { newPort.forwaredAddress = address forwardedPorts.append(newPort) selectedPort = newPort.id + newPort.isLoading = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + DispatchQueue.main.async { + newPort.isLoading = false + newPort.notifyConnection() + } + } } func stopForwarding(port: UtilityAreaPort) { diff --git a/CodeEdit/Features/UtilityArea/Models/UtilityAreaPort.swift b/CodeEdit/Features/UtilityArea/Models/UtilityAreaPort.swift index ba1b82330..2b1832bdf 100644 --- a/CodeEdit/Features/UtilityArea/Models/UtilityAreaPort.swift +++ b/CodeEdit/Features/UtilityArea/Models/UtilityAreaPort.swift @@ -20,6 +20,7 @@ final class UtilityAreaPort: Identifiable, ObservableObject { @Published var portProtocol = PortProtocol.https @Published var isEditingLabel = false + @Published var isLoading = false init(address: String) { self.id = UUID() @@ -71,4 +72,19 @@ final class UtilityAreaPort: Identifiable, ObservableObject { pasteboard.clearContents() pasteboard.setString(url.absoluteString, forType: .string) } + + // MARK: Notifications + + func notifyConnection() { + NotificationManager.shared.post( + iconSymbol: "globe", + title: "Port Forwarded", + description: "Port \(address) is now available.", + actionButtonTitle: "Open" + ) { + if let url = self.url { + NSWorkspace.shared.open(url) + } + } + } } diff --git a/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsView.swift b/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsView.swift index ab819a038..61a4f7943 100644 --- a/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsView.swift +++ b/CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsView.swift @@ -37,7 +37,8 @@ struct UtilityAreaPortsView: View { if !portsManager.forwardedPorts.isEmpty { Table(ports, selection: $portsManager.selectedPort) { TableColumn("Port") { port in - if let index = portsManager.getIndex(for: port.id) { + HStack { + if let index = portsManager.getIndex(for: port.id) { InlineEditRow( title: "Port Label", text: $portsManager.forwardedPorts[index].label, @@ -49,6 +50,12 @@ struct UtilityAreaPortsView: View { ) .frame(maxWidth: .infinity, alignment: .leading) } + + if port.isLoading { + ProgressView() + .controlSize(.small) + } + } } TableColumn("Forwarded Address") { port in