Skip to content

Utilty: Ports #2035

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 13 commits into
base: main
Choose a base branch
from
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -76,6 +77,7 @@ final class CodeEditSplitViewController: NSSplitViewController {
.environmentObject(statusBarViewModel)
.environmentObject(utilityAreaModel)
.environmentObject(taskManager)
.environmentObject(portsManager)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -121,6 +122,7 @@ extension CodeEditWindowController {
.environmentObject(workspaceSettingsManager)
.environmentObject(workspace)
.environmentObject(taskManager)
.environmentObject(portsManager)

settingsWindow.contentView = NSHostingView(rootView: contentView)
settingsWindow.titlebarAppearsTransparent = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnyCancellable>()

Expand Down Expand Up @@ -161,6 +163,7 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate {
workspaceURL: url
)
}
self.portsManager = PortsManager()

editorManager?.restoreFromState(self)
utilityAreaModel?.restoreFromState(self)
Expand Down
46 changes: 46 additions & 0 deletions CodeEdit/Features/Ports/PortsManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//
// 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?

@Published var showAddPortAlert = false

func getIndex(for id: UtilityAreaPort.ID?) -> Int? {
forwardedPorts.firstIndex { $0.id == id }
}

func getSelectedPort() -> UtilityAreaPort? {
forwardedPorts.first { $0.id == selectedPort }
}

func addForwardedPort() {
showAddPortAlert = true
}

func forwardPort(with address: String) {
let newPort = UtilityAreaPort(address: address)
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) {
forwardedPorts.removeAll { $0.id == port.id }
}
}
90 changes: 90 additions & 0 deletions CodeEdit/Features/UtilityArea/Models/UtilityAreaPort.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
//
// UtilityAreaPort.swift
// CodeEdit
//
// Created by Leonardo Larrañaga on 4/21/25.
//

import AppKit

/// A forwared port for the UtilityArea
final class UtilityAreaPort: Identifiable, ObservableObject {
let id: UUID
let address: String

@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

@Published var isEditingLabel = false
@Published var isLoading = false

init(address: String) {
self.id = UUID()
self.address = address
self.label = address
}

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

func copyForwadedAddress() {
guard let url = url else { return }
let pasteboard = NSPasteboard.general
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)
}
}
}
}
19 changes: 13 additions & 6 deletions CodeEdit/Features/UtilityArea/Models/UtilityAreaTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}

Expand All @@ -44,6 +49,8 @@ enum UtilityAreaTab: WorkspacePanelTab, CaseIterable {
UtilityAreaDebugView()
case .output:
UtilityAreaOutputView()
case .ports:
UtilityAreaPortsView()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
//
// UtilityAreaPortsMenu.swift
// CodeEdit
//
// Created by Leonardo Larrañaga on 4/21/25.
//

import SwiftUI

struct UtilityAreaPortsContextMenu: View {

@Binding var port: UtilityAreaPort
@ObservedObject var portsManager: PortsManager

var body: some View {
Group {
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") {
port.isEditingLabel = true
// Workaround: unselect the row to trigger the focus change
portsManager.selectedPort = nil
}
Divider()

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
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") {
portsManager.stopForwarding(port: port)
}
.keyboardShortcut(.delete, modifiers: [.command])
Button("Forward a Port", action: portsManager.addForwardedPort)
}
}
}
Loading