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
Draft
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
Original file line number Diff line number Diff line change
@@ -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)
}
}

Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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>()

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

editorManager?.restoreFromState(self)
utilityAreaModel?.restoreFromState(self)
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
@@ -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()
}
}
}
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)
}
}
}
119 changes: 119 additions & 0 deletions CodeEdit/Features/UtilityArea/PortsUtility/UtilityAreaPortsView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
//
// UtilityAreaPortsView.swift
// CodeEdit
//
// Created by Leonardo Larrañaga on 4/21/25.
//

import SwiftUI

struct UtilityAreaPortsView: View {

@EnvironmentObject private var utilityAreaViewModel: UtilityAreaViewModel

@EnvironmentObject private var portsManager: PortsManager

var ports: [UtilityAreaPort] {
filterText.isEmpty ? portsManager.forwardedPorts : portsManager.forwardedPorts.filter { port in
port.address.localizedCaseInsensitiveContains(filterText) ||
port.label.localizedCaseInsensitiveContains(filterText)
}
}

@State private var filterText = ""
@State private var newPortAddress = ""
var isValidPort: Bool {
do {
// swiftlint:disable:next line_length
return try Regex(#"^(?:(https?:\/\/)?(?:[a-zA-Z0-9.-]+|\[[^\]]+\]):\d{1,5}|\d{1,5})$"#).wholeMatch(in: newPortAddress) != nil
} catch {
return false
}
}

var body: some View {
UtilityAreaTabView(model: utilityAreaViewModel.tabViewModel) { _ in
Group {
if !portsManager.forwardedPorts.isEmpty {
Table(ports, selection: $portsManager.selectedPort) {
TableColumn("Port") { port in
HStack {
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)
}

if port.isLoading {
ProgressView()
.controlSize(.small)
}
}
}

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)
}
.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],
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(
"No Forwarded Ports",
description: "Add a port to access your services over the internet.",
systemImage: "powerplug"
) {
Button("Forward a Port", action: portsManager.addForwardedPort)
}
}
}
.paneToolbar {
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)
}
}
}
}
91 changes: 91 additions & 0 deletions CodeEdit/Utils/InlineEditRow.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
//
// 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<String>, isEditing: Binding<Bool>, 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
if !editedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
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()
}