diff --git a/README.md b/README.md
index 3fbac06..fdf1808 100644
--- a/README.md
+++ b/README.md
@@ -64,7 +64,7 @@ Click anywhere in the command you're typing to put the cursor there, drag to sel
-Tabs and nested splits in one window. Click the branch in the status bar or in the prompt itself to switch or create branches with a fuzzy search. Build your prompt from chips (directory, branch, changes, duration, exit status, Python environment, Kubernetes context) in a visual editor, or keep your own PS1. Quit and reopen, and your windows, tabs and splits come back, each pane in its folder, showing what it showed, with its commands. Closed a tab by mistake? ⇧⌘T. Before anything closes, Codec tells you exactly what's still running in it.
+Tabs and nested splits in one window. Click the branch in the status bar or in the prompt itself to switch or create branches with a fuzzy search. Build your prompt from chips (directory, branch, changes, duration, exit status, Python environment, Kubernetes context) in a visual editor, or keep your own PS1. Quit and reopen, and your windows, tabs and splits come back, each pane in its folder, showing what it showed, with its commands. Closed a tab by mistake? ⇧⌘T. Before anything closes, Codec tells you exactly what's still running in it. The status bar shows the ports a pane's programs listen on, a click from the browser, and the Ports tab (⇧⌘O) lists them all, with names you give them and the way to each one's pane; when `npm run dev` dies because the port is taken, Codec tells you which pane has it.
→ [Tabs and panes](docs/tabs-and-panes.md) · [Prompt and status bar](docs/prompt-and-status-bar.md)
diff --git a/Sources/CodecCore/CommandTimeline.swift b/Sources/CodecCore/CommandTimeline.swift
index 72bae46..124d115 100644
--- a/Sources/CodecCore/CommandTimeline.swift
+++ b/Sources/CodecCore/CommandTimeline.swift
@@ -84,6 +84,8 @@ public struct TimelineEntry: Identifiable, Codable, Equatable, Sendable {
public var usualDuration: TimeInterval?
/// The Python environment and tool versions it ran with, when there were any.
public var toolchain: Toolchain?
+ /// It failed because a port was taken by a program in one of Codec's panes: which, and whose.
+ public var portConflict: PortConflict?
public init(id: Int, command: String, screenCommand: String? = nil, directory: String, started: Date,
block: CommandBlocks.Block?) {
@@ -180,6 +182,12 @@ public struct CommandTimeline: Codable, Equatable, Sendable {
trimOutput()
}
+ /// A failed command found a port taken, and it's known by whom.
+ public mutating func setPortConflict(_ conflict: PortConflict, of id: Int) {
+ guard let index = entries.lastIndex(where: { $0.id == id }) else { return }
+ entries[index].portConflict = conflict
+ }
+
/// The shell went away mid-command: whatever was running won't report back.
public mutating func abandonRunning() {
guard let index = entries.indices.last, entries[index].isRunning else { return }
diff --git a/Sources/CodecCore/FailureExplanation.swift b/Sources/CodecCore/FailureExplanation.swift
index 2ad59f8..540e8c0 100644
--- a/Sources/CodecCore/FailureExplanation.swift
+++ b/Sources/CodecCore/FailureExplanation.swift
@@ -10,16 +10,20 @@ public struct FailureReport: Equatable, Sendable {
public var output: String
/// The Python environment and tool versions it ran with (`Toolchain.summary`), empty when unknown.
public var tools: String
+ /// What Codec found out for itself about the failure (a port taken by a program in another
+ /// pane), empty when there's nothing.
+ public var known: String
/// Characters of output to send: about half the model's context, leaving room for its answer.
public static let outputBudget = 6_000
- public init(command: String, directory: String, exitCode: Int32, output: String, tools: String = "") {
+ public init(command: String, directory: String, exitCode: Int32, output: String, tools: String = "", known: String = "") {
self.command = command
self.directory = directory
self.exitCode = exitCode
self.output = output
self.tools = tools
+ self.known = known
}
/// The report on a command that failed with an exit status; nil for one that worked, is still
@@ -27,7 +31,8 @@ public struct FailureReport: Equatable, Sendable {
public init?(entry: TimelineEntry, output: String?, budget: Int = outputBudget) {
guard case .failed(let code) = entry.status, code >= 0 else { return nil }
self.init(command: entry.command, directory: (entry.directory as NSString).abbreviatingWithTildeInPath,
- exitCode: code, output: Self.excerpt(output ?? "", budget: budget), tools: entry.toolchain?.summary ?? "")
+ exitCode: code, output: Self.excerpt(output ?? "", budget: budget), tools: entry.toolchain?.summary ?? "",
+ known: entry.portConflict.map { "\($0.sentence()), a program running in one of this terminal’s panes." } ?? "")
}
public var prompt: String {
@@ -35,7 +40,7 @@ public struct FailureReport: Equatable, Sendable {
Command: \(command)
Folder: \(directory)
\(tools.isEmpty ? "" : "Tools: \(tools)\n")Exit status: \(exitCode)
- Output:
+ \(known.isEmpty ? "" : "Known: \(known)\n")Output:
\(output.isEmpty ? "(nothing)" : output)
"""
}
@@ -166,6 +171,7 @@ public struct OnDeviceExplainer: FailureExplaining {
the folder it ran in, sometimes the Python environment and tool versions it ran with, its exit \
status and the end of what it printed; lines before "…" are earlier errors. A tool marked "not \
installed" was asked for by the project but isn't there, so another version or none ran instead. \
+ A line starting "Known:" is a fact the terminal checked for itself: rely on it, and say it. \
Be brief and concrete. Quote the error message if there is one. Don't invent file names, options, \
flags or packages that aren't in the command or output; in particular, don't suggest running the \
same command again with new options. If the output doesn't say why, say so. \
diff --git a/Sources/CodecCore/ListeningPorts.swift b/Sources/CodecCore/ListeningPorts.swift
new file mode 100644
index 0000000..280f95b
--- /dev/null
+++ b/Sources/CodecCore/ListeningPorts.swift
@@ -0,0 +1,196 @@
+import Foundation
+
+/// A TCP port a program started from a pane is listening on: a dev server, a debugger, a database.
+public struct ListeningPort: Equatable, Sendable, Identifiable {
+ public var port: Int
+ /// The program holding it, as `ProcessInspector.name` has it.
+ public var program: String
+ public var pid: pid_t
+ /// It belongs to the command in the foreground, not to a job left in the background.
+ public var foreground: Bool
+ /// The address the program printed for it (`http://localhost:3000/app`), when it printed one.
+ public var address: String?
+ /// What the user called the process ("Storefront"), for as long as it runs.
+ public var name: String?
+
+ public init(port: Int, program: String, pid: pid_t = 0, foreground: Bool = false, address: String? = nil,
+ name: String? = nil) {
+ self.port = port
+ self.program = program
+ self.pid = pid
+ self.foreground = foreground
+ self.address = address
+ self.name = name
+ }
+
+ public var id: Int { port }
+
+ /// What to call it: the name it was given, or else its program's.
+ public var displayName: String { name ?? program }
+
+ /// ":3000".
+ public var label: String { ":\(port)" }
+
+ /// What to open in the browser: the address the program printed, or else plain HTTP on this Mac.
+ public var url: URL {
+ address.flatMap(URL.init(string:)) ?? URL(string: "http://localhost:\(port)")!
+ }
+}
+
+public enum ListeningPorts {
+ /// The ports the programs under `shell` listen on, lowest first, each once: a server that forks
+ /// workers shares its socket with them, and the one nearest the shell is named.
+ public static func scan(shell: pid_t, foregroundGroup: pid_t?,
+ descendants: (pid_t) -> [pid_t] = { ProcessInspector.descendants(of: $0) },
+ ports: (pid_t) -> [Int] = ProcessInspector.listeningPorts(of:),
+ group: (pid_t) -> pid_t? = ProcessInspector.processGroup(of:),
+ name: (pid_t) -> String? = ProcessInspector.name(of:)) -> [ListeningPort] {
+ var found: [ListeningPort] = []
+ for pid in descendants(shell) {
+ let listening = ports(pid).filter { port in !found.contains { $0.port == port } }
+ guard !listening.isEmpty else { continue }
+ let program = name(pid) ?? "pid \(pid)"
+ let foreground = foregroundGroup != nil && group(pid) == foregroundGroup
+ found += listening.map { ListeningPort(port: $0, program: program, pid: pid, foreground: foreground) }
+ }
+ return found.sorted { $0.port < $1.port }
+ }
+
+ /// `scanned`, with what's already known of the processes still there: the address found for a
+ /// port the same process still holds, and the name its process was given, on every port it has.
+ public static func merging(_ scanned: [ListeningPort], keepingAddressesOf known: [ListeningPort]) -> [ListeningPort] {
+ scanned.map { port in
+ var port = port
+ port.address = known.first { $0.port == port.port && $0.pid == port.pid }?.address
+ port.name = known.first { $0.pid == port.pid && $0.name != nil }?.name
+ return port
+ }
+ }
+
+ /// `ports`, with the process holding `port` called `name` on every port it has; a blank name, or
+ /// none, takes its name away.
+ public static func naming(_ port: ListeningPort, _ name: String?, in ports: [ListeningPort]) -> [ListeningPort] {
+ let trimmed = name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ return ports.map { other in
+ guard other.pid == port.pid else { return other }
+ var other = other
+ other.name = trimmed.isEmpty ? nil : trimmed
+ return other
+ }
+ }
+
+ /// ":3000 :9229", and "+2" for those past `limit`, so many ports don't take over the status bar.
+ public static func label(_ ports: [ListeningPort], limit: Int = 3) -> String {
+ var parts = ports.prefix(limit).map(\.label)
+ if ports.count > limit { parts.append("+\(ports.count - limit)") }
+ return parts.joined(separator: " ")
+ }
+
+ /// "listening on :3000", "listening on :3000 and :9229", "listening on :3000, :3001 and :9229";
+ /// saying who when asked, "listening on :3000 (node)"; with a capital to stand on a line of its own.
+ public static func phrase(_ ports: [ListeningPort], namingPrograms: Bool = false, capitalized: Bool = false) -> String {
+ let labels = ports.map { namingPrograms ? "\($0.label) (\($0.displayName))" : $0.label }
+ let list = labels.count > 1 ? labels.dropLast().joined(separator: ", ") + " and " + labels.last! : labels.first ?? ""
+ return (capitalized ? "Listening on " : "listening on ") + list
+ }
+
+ /// Hosts that mean this Mac. The last two are what a server bound to every address prints.
+ private static let localHosts = #"localhost|[a-z0-9-]+(?:\.[a-z0-9-]+)*\.localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0|\[::\]"#
+ private static let addressPattern = try! NSRegularExpression(
+ pattern: #"\bhttps?://(?:"# + localHosts + #"):([0-9]{1,5})(?![0-9])[^\s"'<>`\\]*"#, options: [.caseInsensitive])
+
+ /// The first address in `text` for `port` on this Mac, as a program announces where it's serving
+ /// (`Local: http://localhost:3000/app`), scheme and path included. An address every interface
+ /// answers on (`0.0.0.0`) is given as `localhost`, which a browser can open.
+ public static func address(for port: Int, in text: String) -> String? {
+ let string = text as NSString
+ for match in addressPattern.matches(in: text, range: NSRange(location: 0, length: string.length))
+ where Int(string.substring(with: match.range(at: 1))) == port {
+ var address = string.substring(with: match.range)
+ // Punctuation of the sentence it's in, not of the address.
+ while let last = address.last, ".,;:!?)]}".contains(last) { address.removeLast() }
+ for any in ["0.0.0.0", "[::]"] {
+ if let host = address.range(of: "://\(any):") {
+ address.replaceSubrange(host, with: "://localhost:")
+ }
+ }
+ if URL(string: address) != nil { return address }
+ }
+ return nil
+ }
+}
+
+/// A command failed because a port it wanted was taken, and Codec knows by whom: a program running
+/// in one of its panes.
+public struct PortConflict: Codable, Equatable, Sendable {
+ public var port: Int
+ /// The program that held it.
+ public var program: String
+ /// The pane it ran in, by its session's id. Means nothing once Codec has quit.
+ public var pane: String
+
+ public init(port: Int, program: String, pane: String) {
+ self.port = port
+ self.program = program
+ self.pane = pane
+ }
+
+ /// "Port 3000 is in use by “node”", and where when that's known; "was", once it has let go of it.
+ public func sentence(stillHeld: Bool = true, place: String? = nil) -> String {
+ let subject = "Port \(port) \(stillHeld ? "is" : "was") in use by “\(program)”"
+ guard stillHeld, let place, !place.isEmpty else { return subject }
+ return "\(subject) in \(place)"
+ }
+
+ /// What failing to bind looks like, whatever printed it: `EADDRINUSE` (Node), "Address already in
+ /// use" (C, Python, Go, Ruby, Java), `AddrInUse` (Rust), "Port 5000 is in use" (Flask), "That
+ /// port is already in use" (Django).
+ private static let marker = try! NSRegularExpression(
+ pattern: #"EADDRINUSE|ADDRINUSE|address (?:is )?(?:already )?in use|\bport\b.*\bin use\b"#, options: [.caseInsensitive])
+
+ /// How a port is written on such a line, most telling first: "port 3000"; after an address
+ /// (`127.0.0.1:3000`, `:::3000`, ` :8080`), which a time of day (`12:30:45`) isn't; Python's
+ /// `('127.0.0.1', 8000)`.
+ private static let portsInOutput = [
+ #"\bport[ =:#]*([0-9]{1,5})(?![0-9])"#,
+ #"(?:(?:[0-9]{1,3}\.){3}[0-9]{1,3}|localhost|\]|::|\*|[\s("'=]|^):([0-9]{1,5})(?![0-9.])"#,
+ #"['"], ?([0-9]{1,5})\)"#,
+ ].map { try! NSRegularExpression(pattern: $0, options: [.caseInsensitive]) }
+
+ /// The same in a command: `--port 3000`, `-p 3000`, `PORT=3000`, `localhost:3000`.
+ private static let portsInCommand = [
+ #"(?:^|\s)(?:--?[a-z-]*port|-p)[ =]([0-9]{1,5})(?![0-9])"#,
+ #"\b[A-Z_]*PORT=([0-9]{1,5})(?![0-9])"#,
+ #"(?:(?:[0-9]{1,3}\.){3}[0-9]{1,3}|localhost|\]|::|\*|[\s("'=]|^):([0-9]{1,5})(?![0-9.])"#,
+ ].map { try! NSRegularExpression(pattern: $0, options: [.caseInsensitive]) }
+
+ /// The port a failed command found taken, from what it printed, or from the command itself when
+ /// the error names none (`python -m http.server 8000`). Nil when the output doesn't say a port
+ /// was taken, or which one can't be told.
+ public static func port(inOutput output: String, command: String) -> Int? {
+ var refused = false
+ for line in output.split(separator: "\n") {
+ let line = String(line)
+ guard marker.firstMatch(in: line, range: NSRange(location: 0, length: (line as NSString).length)) != nil else { continue }
+ refused = true
+ if let port = firstPort(in: line, among: portsInOutput) { return port }
+ }
+ guard refused else { return nil }
+ if let port = firstPort(in: command, among: portsInCommand) { return port }
+ // A number on its own, when there's only one it could be: `nc -l 8080`, `http.server 8000`.
+ let numbers = command.split(whereSeparator: \.isWhitespace).compactMap { Int($0) }.filter(isPort)
+ return numbers.count == 1 ? numbers[0] : nil
+ }
+
+ private static func isPort(_ number: Int) -> Bool { (1...65_535).contains(number) }
+
+ private static func firstPort(in text: String, among patterns: [NSRegularExpression]) -> Int? {
+ let string = text as NSString
+ for pattern in patterns {
+ for match in pattern.matches(in: text, range: NSRange(location: 0, length: string.length)) {
+ if let port = Int(string.substring(with: match.range(at: 1))), isPort(port) { return port }
+ }
+ }
+ return nil
+ }
+}
diff --git a/Sources/CodecCore/ProcessInspector.swift b/Sources/CodecCore/ProcessInspector.swift
index 5cf09e4..b9aec47 100644
--- a/Sources/CodecCore/ProcessInspector.swift
+++ b/Sources/CodecCore/ProcessInspector.swift
@@ -20,9 +20,21 @@ public enum RunningPrograms {
return names
}
- /// "“make”", "“make” and “npm”", "“make”, “npm” and “ssh”".
- public static func list(_ names: [String]) -> String {
- let quoted = names.map { "“\($0)”" }
+ /// `names`, then the programs holding `ports` that aren't among them: a server is often started
+ /// by something else (`npm` runs `node`), and it's the one worth naming.
+ public static func programs(_ names: [String], listening ports: [ListeningPort]) -> [String] {
+ ports.reduce(into: names) { names, port in
+ if !names.contains(port.program) { names.append(port.program) }
+ }
+ }
+
+ /// "“make”", "“make” and “npm”", "“make”, “npm” and “ssh”". A program that's listening says on
+ /// what: "“npm” and “node” (listening on :3000)".
+ public static func list(_ names: [String], listening ports: [ListeningPort] = []) -> String {
+ let quoted = programs(names, listening: ports).map { name in
+ let held = ports.filter { $0.program == name }
+ return held.isEmpty ? "“\(name)”" : "“\(name)” (\(ListeningPorts.phrase(held)))"
+ }
guard quoted.count > 1 else { return quoted.first ?? "" }
return quoted.dropLast().joined(separator: ", ") + " and " + quoted.last!
}
@@ -52,6 +64,57 @@ public enum ProcessInspector {
return buffer.prefix(min(Int(count), buffer.count)).filter { $0 > 0 }
}
+ /// Every process `pid` started that's still running, and those they started in turn, nearest
+ /// first: a dev server is rarely the shell's own child (`npm` runs `sh`, which runs `node`).
+ public static func descendants(of pid: pid_t, limit: Int = 512) -> [pid_t] {
+ var found: [pid_t] = []
+ var seen: Set = [pid]
+ var parents = [pid]
+ while !parents.isEmpty, found.count < limit {
+ var next: [pid_t] = []
+ for parent in parents {
+ for child in children(of: parent) where seen.insert(child).inserted { next.append(child) }
+ }
+ found += next
+ parents = next
+ }
+ return Array(found.prefix(limit))
+ }
+
+ /// The process group `pid` is in: the shell puts each command, with what it starts, in its own.
+ public static func processGroup(of pid: pid_t) -> pid_t? {
+ var info = proc_bsdinfo()
+ let size = Int32(MemoryLayout.size)
+ guard pid > 0, proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, size) == size else { return nil }
+ return pid_t(info.pbi_pgid)
+ }
+
+ /// The TCP ports `pid` is listening on, each once, as the kernel reports its sockets. Works for
+ /// the user's own processes; empty for anyone else's.
+ public static func listeningPorts(of pid: pid_t) -> [Int] {
+ guard pid > 0 else { return [] }
+ let stride = MemoryLayout.stride
+ let needed = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, nil, 0)
+ guard needed > 0 else { return [] }
+ // Room for a few more, opened between the two calls.
+ var descriptors = [proc_fdinfo](repeating: proc_fdinfo(), count: Int(needed) / stride + 16)
+ let used = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, &descriptors, Int32(descriptors.count * stride))
+ guard used > 0 else { return [] }
+ var ports: [Int] = []
+ let size = Int32(MemoryLayout.size)
+ for descriptor in descriptors.prefix(Int(used) / stride) where descriptor.proc_fdtype == UInt32(PROX_FDTYPE_SOCKET) {
+ var info = socket_fdinfo()
+ guard proc_pidfdinfo(pid, descriptor.proc_fd, PROC_PIDFDSOCKETINFO, &info, size) == size,
+ info.psi.soi_kind == Int32(SOCKINFO_TCP) else { continue }
+ let tcp = info.psi.soi_proto.pri_tcp
+ guard tcp.tcpsi_state == TSI_S_LISTEN else { continue }
+ // In network byte order, in the low 16 bits.
+ let port = Int(UInt16(bigEndian: UInt16(truncatingIfNeeded: tcp.tcpsi_ini.insi_lport)))
+ if port > 0, !ports.contains(port) { ports.append(port) }
+ }
+ return ports
+ }
+
public static func workingDirectory(of pid: pid_t) -> String? {
var info = proc_vnodepathinfo()
let size = Int32(MemoryLayout.size)
diff --git a/Sources/CodecCore/SplitLayout.swift b/Sources/CodecCore/SplitLayout.swift
index 99985b2..a44b3ce 100644
--- a/Sources/CodecCore/SplitLayout.swift
+++ b/Sources/CodecCore/SplitLayout.swift
@@ -68,6 +68,25 @@ public indirect enum SplitLayout {
return all[(index + (forward ? 1 : all.count - 1)) % all.count]
}
+ /// Where `target` is in the layout, in words, for telling someone which pane is meant: "left",
+ /// "bottom right", "middle". From the outermost split along each axis, so panes nested deeper on
+ /// the same side share a name. Nil for a layout of one pane, or one without `target`.
+ public func position(of target: Leaf) -> String? {
+ var words: [SplitAxis: String] = [:]
+ var node = self
+ while case .split(let axis, let children) = node {
+ guard let index = children.firstIndex(where: { $0.contains(target) }) else { return nil }
+ if words[axis] == nil {
+ let ends = axis == .horizontal ? ("left", "right") : ("top", "bottom")
+ words[axis] = index == 0 ? ends.0 : index == children.count - 1 ? ends.1 : "middle"
+ }
+ node = children[index]
+ }
+ guard node.isLeaf(target), !words.isEmpty else { return nil }
+ let both = [words[.vertical], words[.horizontal]].compactMap { $0 }
+ return both == ["middle", "middle"] ? "middle" : both.joined(separator: " ")
+ }
+
/// Whether both layouts hold the same leaves in the same arrangement.
public func isSameShape(as other: SplitLayout) -> Bool {
switch (self, other) {
diff --git a/Sources/CodecUI/AppDelegate.swift b/Sources/CodecUI/AppDelegate.swift
index 88b8293..2fd100e 100644
--- a/Sources/CodecUI/AppDelegate.swift
+++ b/Sources/CodecUI/AppDelegate.swift
@@ -18,6 +18,8 @@ public enum CodecApp {
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation, AppCoordinating {
private let environment: AppEnvironment
private(set) var windows: [TerminalWindowController] = []
+ /// Fills in Window › Listening Ports; menus don't keep their delegates.
+ private lazy var listeningPortsMenu = ListeningPortsMenu(directory: environment.panes)
private var settingsWindow: NSWindow?
/// Nil outside a bundled app with a version (tests, `swift run`).
private(set) var updater: Updater?
@@ -34,7 +36,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation,
func applicationDidFinishLaunching(_ notification: Notification) {
NSWindow.allowsAutomaticWindowTabbing = false
- NSApp.mainMenu = MainMenu.build()
+ NSApp.mainMenu = MainMenu.build(listeningPorts: listeningPortsMenu)
if let notifier = environment.notifier as? Notifier {
notifier.activate()
notifier.onOpen = { [weak self] id in
@@ -82,14 +84,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation,
}
return .terminateCancel
}
- var programs: [String] = []
- for window in windows {
- for program in window.runningPrograms where !programs.contains(program) { programs.append(program) }
- }
- guard !programs.isEmpty else { return .terminateNow }
+ let running = windows.flatMap(\.allPanes).running
+ guard !running.programs.isEmpty else { return .terminateNow }
let alert = NSAlert()
alert.messageText = "Quit Codec?"
- alert.informativeText = "\(RunningPrograms.list(programs)) \(programs.count == 1 ? "is" : "are") still running. Quitting will terminate \(programs.count == 1 ? "it" : "them")."
+ alert.informativeText = "\(running.list) \(running.count == 1 ? "is" : "are") still running. Quitting will terminate \(running.count == 1 ? "it" : "them")."
alert.addButton(withTitle: "Quit")
alert.addButton(withTitle: "Cancel")
return alert.runModal() == .alertFirstButtonReturn ? .terminateNow : .terminateCancel
diff --git a/Sources/CodecUI/AppEnvironment.swift b/Sources/CodecUI/AppEnvironment.swift
index 8083615..ed86d28 100644
--- a/Sources/CodecUI/AppEnvironment.swift
+++ b/Sources/CodecUI/AppEnvironment.swift
@@ -24,6 +24,55 @@ protocol AppCoordinating: AnyObject {
func attentionDidChange()
}
+/// The panes that are open, across every window: a command that finds a port taken can be told
+/// which of them holds it. Publishes when what any of them listens on changes.
+@MainActor
+final class PaneDirectory: ObservableObject {
+ private struct Entry {
+ weak var pane: Pane?
+ }
+
+ private var entries: [Entry] = []
+
+ var panes: [Pane] { entries.compactMap(\.pane) }
+
+ func add(_ pane: Pane) {
+ entries.removeAll { $0.pane == nil }
+ entries.append(Entry(pane: pane))
+ }
+
+ func remove(_ pane: Pane) {
+ entries.removeAll { $0.pane == nil || $0.pane === pane }
+ objectWillChange.send()
+ }
+
+ func pane(id: String) -> Pane? {
+ panes.first { $0.session.id.uuidString == id }
+ }
+
+ /// The pane with a program listening on `port` right now, and that program: every pane looks
+ /// again first, as the one asking has just been refused the port.
+ func holder(of port: Int) -> (pane: Pane, port: ListeningPort)? {
+ for pane in panes {
+ pane.refreshListeningPorts()
+ if let held = pane.session.listeningPorts.first(where: { $0.port == port }) { return (pane, held) }
+ }
+ return nil
+ }
+
+ /// A pane's ports changed.
+ func portsDidChange() {
+ objectWillChange.send()
+ }
+
+ /// Tabs or panes were added, moved or closed in some window: where the listening panes are may
+ /// read differently now. Nothing to tell when nothing listens.
+ func placesDidChange() {
+ guard panes.contains(where: { !$0.session.listeningPorts.isEmpty }) else { return }
+ objectWillChange.send()
+ }
+}
+
/// The services a window and its panes depend on. The app builds one with the real implementations;
/// tests build one with fakes.
@MainActor
@@ -42,6 +91,8 @@ final class AppEnvironment {
let paneArchive: PaneArchive
/// Tabs closed lately, for ⇧⌘T, from any window.
let closedTabs = ClosedTabs()
+ /// Every pane there is, in any window, for finding the one whose program holds a port.
+ let panes = PaneDirectory()
/// Explains failed commands; nil where there's nothing to do it with.
let explainer: (any FailureExplaining)?
weak var coordinator: (any AppCoordinating)?
diff --git a/Sources/CodecUI/CommandOutputViewer.swift b/Sources/CodecUI/CommandOutputViewer.swift
index 4129869..1331c9b 100644
--- a/Sources/CodecUI/CommandOutputViewer.swift
+++ b/Sources/CodecUI/CommandOutputViewer.swift
@@ -13,6 +13,8 @@ final class CommandOutputViewer: NSViewController {
var saveToIntel: () -> Void = {}
/// Puts a command on the command line.
var insertCommand: (String) -> Void = { _ in }
+ /// Goes to the pane whose program holds the port the command wanted.
+ var goToPane: () -> Void = {}
}
let entry: TimelineEntry
@@ -21,13 +23,16 @@ final class CommandOutputViewer: NSViewController {
private let query: String
private let theme: CodecTheme
private let codeFont: NSFont
+ /// The port it found taken, and by whom, when that's why it failed.
+ let portConflict: Pane.PortConflictStatus?
let actions: Actions
private let canInsert: Bool
let explanation: FailureExplanationModel
let textView = OutputTextView()
init(entry: TimelineEntry, output: String?, inScrollback: Bool, query: String, theme: CodecTheme, codeFont: NSFont,
- actions: Actions, canInsert: Bool, explanation: FailureExplanationModel) {
+ portConflict: Pane.PortConflictStatus? = nil, actions: Actions, canInsert: Bool, explanation: FailureExplanationModel) {
+ self.portConflict = portConflict
self.entry = entry
self.output = output
self.inScrollback = inScrollback
@@ -48,7 +53,7 @@ final class CommandOutputViewer: NSViewController {
override func loadView() {
let header = NSHostingView(rootView: OutputHeader(
entry: entry, theme: theme, codeFont: codeFont, inScrollback: inScrollback, canInsert: canInsert,
- hasOutput: output != nil, note: note, actions: actions, explanation: explanation,
+ hasOutput: output != nil, note: note, portConflict: portConflict, actions: actions, explanation: explanation,
copy: { [weak self] in
guard let output = self?.output else { return }
NSPasteboard.general.clearContents()
@@ -185,6 +190,7 @@ private struct OutputHeader: View {
let canInsert: Bool
let hasOutput: Bool
let note: String?
+ let portConflict: Pane.PortConflictStatus?
let actions: CommandOutputViewer.Actions
@ObservedObject var explanation: FailureExplanationModel
var copy: () -> Void
@@ -214,6 +220,20 @@ private struct OutputHeader: View {
.truncationMode(.tail)
.help("Ran with \(toolchain.summary)")
}
+ if let portConflict {
+ HStack(spacing: 8) {
+ Label(portConflict.text, systemImage: "network")
+ .font(.system(size: 12, weight: .medium))
+ .foregroundStyle(theme.alertColor)
+ .lineLimit(2)
+ if portConflict.holder != nil {
+ Button("Go to Pane", systemImage: "arrow.right.square", action: actions.goToPane)
+ .controlSize(.small)
+ .buttonStyle(.glass)
+ .help("Show the pane whose program has the port")
+ }
+ }
+ }
HStack(spacing: 8) {
Button("Go to Command", systemImage: "arrow.up.left.and.arrow.down.right", action: actions.goTo)
.disabled(!inScrollback)
diff --git a/Sources/CodecUI/CommandTimelineSidebar.swift b/Sources/CodecUI/CommandTimelineSidebar.swift
index c83d0c8..d109606 100644
--- a/Sources/CodecUI/CommandTimelineSidebar.swift
+++ b/Sources/CodecUI/CommandTimelineSidebar.swift
@@ -44,6 +44,11 @@ final class PaneTimeline: ObservableObject {
value.setOutput(output, of: id)
}
+ func setPortConflict(_ conflict: PortConflict, of id: Int) {
+ guard value.entry(id: id)?.portConflict != conflict else { return }
+ value.setPortConflict(conflict, of: id)
+ }
+
func abandonRunning() {
guard running != nil else { return }
value.abandonRunning()
@@ -108,6 +113,7 @@ final class CommandTimelineSidebar: NSView {
private var theme: CodecTheme
private var codeFont: NSFont
private var subscription: AnyCancellable?
+ private var portsSubscription: AnyCancellable?
private var clock: Timer?
/// Where the scrollback starts, as of the last reload: commands above it are only in the kept output.
private var scrollbackStart: Int?
@@ -249,9 +255,32 @@ final class CommandTimelineSidebar: NSView {
subscription = pane?.timeline.objectWillChange
.receive(on: RunLoop.main)
.sink { [weak self] _ in MainActor.assumeIsolated { self?.reload() } }
+ portsSubscription = pane?.panes.objectWillChange
+ .receive(on: RunLoop.main)
+ .sink { [weak self] _ in MainActor.assumeIsolated { self?.portsDidChange() } }
reload(scrollToEnd: true)
}
+ /// What some pane's programs listen on changed: the running command's row says what it's
+ /// listening on, and a row about a port that was taken offers its holder only while there's one.
+ private func portsDidChange() {
+ let conflicts = IndexSet(rows.indices.filter { rows[$0].entry?.portConflict != nil })
+ if !conflicts.isEmpty {
+ NSAnimationContext.runAnimationGroup { context in
+ context.duration = 0
+ table.noteHeightOfRows(withIndexesChanged: conflicts)
+ }
+ }
+ refreshVisibleRows()
+ }
+
+ /// Goes to the pane whose program holds the port a failed command wanted.
+ func goToPortHolder(of entry: TimelineEntry) {
+ guard let holder = pane?.portConflictStatus(of: entry)?.holder else { return NSSound.beep() }
+ outputPopover?.close()
+ holder.host?.reveal(holder)
+ }
+
private var query: String { searchField.stringValue.trimmingCharacters(in: .whitespaces) }
var selectedEntry: TimelineEntry? {
@@ -336,8 +365,12 @@ final class CommandTimelineSidebar: NSView {
theme: theme)
case .entry(let entry):
guard let cell = view as? TimelineCell else { return }
- cell.show(entry, query: query, inScrollback: isInScrollback(entry), theme: theme, codeFont: codeFont)
+ // A running command is the one in the foreground: jobs left in the background listen for themselves.
+ cell.show(entry, query: query, inScrollback: isInScrollback(entry), theme: theme, codeFont: codeFont,
+ listening: entry.isRunning ? pane?.session.listeningPorts.filter(\.foreground) ?? [] : [],
+ conflict: pane?.portConflictStatus(of: entry))
cell.onSave = { [weak self] in self?.saveToIntel(entry) }
+ cell.onGoToPane = { [weak self] in self?.goToPortHolder(of: entry) }
}
}
@@ -424,7 +457,7 @@ final class CommandTimelineSidebar: NSView {
let explanation = FailureExplanationModel(entry: entry, output: output, explainer: explainer())
let viewer = CommandOutputViewer(
entry: entry, output: output, inScrollback: inScrollback, query: query,
- theme: theme, codeFont: codeFont,
+ theme: theme, codeFont: codeFont, portConflict: pane.portConflictStatus(of: entry),
actions: .init(
goTo: { [weak self, weak popover] in
popover?.performClose(nil)
@@ -443,7 +476,8 @@ final class CommandTimelineSidebar: NSView {
insertCommand: { [weak self, weak popover] command in
popover?.performClose(nil)
self?.insert(command, run: false)
- }),
+ },
+ goToPane: { [weak self] in self?.goToPortHolder(of: entry) }),
canInsert: pane.isAtCommandLine, explanation: explanation)
popover.contentViewController = viewer
outputPopover = popover
@@ -461,6 +495,14 @@ final class CommandTimelineSidebar: NSView {
}]
}
+ /// Go to the pane holding the port a failed command wanted, while it still does.
+ private func portHolderItems(for entry: TimelineEntry) -> [NSMenuItem] {
+ guard let conflict = entry.portConflict, pane?.portConflictStatus(of: entry)?.holder != nil else { return [] }
+ return [ClosureMenuItem("Go to Pane Listening on :\(conflict.port)", image: "network") { [weak self] in
+ self?.goToPortHolder(of: entry)
+ }]
+ }
+
private func insert(_ command: String, run: Bool) {
guard let pane else { return }
if pane.replaceCommandLine(with: command, run: run) { focusTerminal() } else { NSSound.beep() }
@@ -512,7 +554,10 @@ extension CommandTimelineSidebar: NSTableViewDataSource, NSTableViewDelegate {
}
func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat {
- rows[row].entry == nil ? 22 : 40
+ guard let entry = rows[row].entry else { return 22 }
+ // A command that found its port taken says by whom, on up to two lines, over Go to Pane.
+ guard let conflict = pane?.portConflictStatus(of: entry) else { return TimelineCell.height }
+ return conflict.holder == nil ? TimelineCell.heightSayingWhy : TimelineCell.heightSayingWhyAndWhere
}
func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool {
@@ -574,7 +619,7 @@ extension CommandTimelineSidebar: NSMenuDelegate {
ClosureMenuItem("Show Output", image: "text.alignleft", enabled: hasOutput) { [weak self] in
self?.showOutput(of: entry, row: row)
},
- ] + explainItems(for: entry, row: row) + [
+ ] + explainItems(for: entry, row: row) + portHolderItems(for: entry) + [
.separator(),
ClosureMenuItem("Save to Intel…", image: "bookmark") { [weak self] in self?.saveToIntel(entry) },
.separator(),
@@ -613,25 +658,20 @@ final class TimelineHeaderModel: ObservableObject {
var onClear: () -> Void = {}
}
-/// "Commands", the pane they're from, and the list's options.
+/// The pane the commands are from, and the list's options. The tab above it says they're commands.
struct TimelineHeader: View {
@ObservedObject var model: TimelineHeaderModel
let theme: CodecTheme
var body: some View {
HStack(spacing: 6) {
- Image(systemName: "list.bullet.rectangle")
+ Image(systemName: "terminal")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.secondary)
- Text("Commands")
+ Text(model.paneTitle.isEmpty ? "Commands" : model.paneTitle)
.font(.system(size: 12, weight: .semibold))
- if !model.paneTitle.isEmpty {
- Text(model.paneTitle)
- .font(.system(size: 11))
- .foregroundStyle(.secondary)
- .lineLimit(1)
- .truncationMode(.middle)
- }
+ .lineLimit(1)
+ .truncationMode(.middle)
Spacer(minLength: 4)
TimelineHeaderButton(systemImage: model.failedOnly ? "exclamationmark.triangle.fill" : "exclamationmark.triangle",
help: model.failedOnly ? "Show All Commands" : "Show Failed Commands Only",
@@ -837,6 +877,14 @@ final class TimelineCell: NSTableCellView {
private let saveButton = NSButton()
/// How far along a running command likely is, from how long it usually takes.
private let progress = ProgressLine()
+ /// A row's height; with a second line of detail, for why it failed; and with Go to Pane under that.
+ static let height: CGFloat = 40
+ static let heightSayingWhy: CGFloat = 54
+ static let heightSayingWhyAndWhere: CGFloat = 78
+
+ /// Go to Pane, under a command that found its port taken by a program in another pane.
+ private let goButton = NSButton(title: "Go to Pane", target: nil, action: nil)
+ var onGoToPane: (() -> Void)?
/// Save to Intel, from the button that shows over the row while the mouse is on it.
var onSave: (() -> Void)?
private var hovering = false {
@@ -876,7 +924,13 @@ final class TimelineCell: NSTableCellView {
archived.toolTip = "No longer in the terminal’s scrollback. Codec kept its output: double-click or press Space to see it."
archived.setContentCompressionResistancePriority(.required, for: .horizontal)
progress.isHidden = true
- for view in [dot, command, time, detail, duration, archived, saveButton, progress] {
+ goButton.controlSize = .small
+ goButton.bezelStyle = .accessoryBarAction
+ goButton.font = .systemFont(ofSize: 11)
+ goButton.target = self
+ goButton.action = #selector(goToPane)
+ goButton.isHidden = true
+ for view in [dot, command, time, detail, duration, archived, saveButton, progress, goButton] {
view.translatesAutoresizingMaskIntoConstraints = false
addSubview(view)
}
@@ -908,6 +962,8 @@ final class TimelineCell: NSTableCellView {
saveButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4),
saveButton.widthAnchor.constraint(equalToConstant: 22),
saveButton.heightAnchor.constraint(equalToConstant: 18),
+ goButton.leadingAnchor.constraint(equalTo: command.leadingAnchor),
+ goButton.topAnchor.constraint(equalTo: detail.bottomAnchor, constant: 4),
])
}
@@ -915,6 +971,10 @@ final class TimelineCell: NSTableCellView {
onSave?()
}
+ @objc private func goToPane() {
+ onGoToPane?()
+ }
+
override func updateTrackingAreas() {
super.updateTrackingAreas()
trackingAreas.forEach(removeTrackingArea)
@@ -937,7 +997,8 @@ final class TimelineCell: NSTableCellView {
fatalError("init(coder:) is not supported")
}
- func show(_ entry: TimelineEntry, query: String, inScrollback: Bool, theme: CodecTheme, codeFont: NSFont) {
+ func show(_ entry: TimelineEntry, query: String, inScrollback: Bool, theme: CodecTheme, codeFont: NSFont,
+ listening: [ListeningPort] = [], conflict: Pane.PortConflictStatus? = nil) {
command.attributedStringValue = ShellHighlighting.attributed(entry.command, theme: theme, font: codeFont, size: 12)
let calendar = Calendar.current
time.stringValue = calendar.isDateInToday(entry.started)
@@ -983,6 +1044,8 @@ final class TimelineCell: NSTableCellView {
} else if entry.isRunning, let usualText, !entry.fullScreen {
detailText = usualText.text
if usualText.over { detailColor = theme.warning }
+ } else if entry.isRunning, !listening.isEmpty, !entry.fullScreen {
+ detailText = ListeningPorts.phrase(listening, capitalized: true)
} else if entry.isRunning {
detailText = entry.fullScreen ? "Running full screen…" : "Running…"
} else if entry.fullScreen {
@@ -994,12 +1057,27 @@ final class TimelineCell: NSTableCellView {
detailText = "No output"
detailColor = .tertiaryLabelColor
}
- if case .failed(let code) = entry.status, code >= 0, entry.output?.firstLine(matching: query) == nil {
+ let matched = entry.output?.firstLine(matching: query) != nil
+ if let conflict, !matched {
+ // Why it failed says more than the last line it printed.
+ detailText = conflict.text
+ detailColor = .labelColor
+ } else if case .failed(let code) = entry.status, code >= 0, !matched {
detailText = "Exit \(code) · " + detailText
}
+ // A sentence, where the other rows have a line of output: in the font for prose, and with a
+ // second line rather than being cut.
+ let sentence = conflict != nil && !matched
+ detail.maximumNumberOfLines = sentence ? 2 : 1
+ detail.usesSingleLineMode = !sentence
+ detail.cell?.wraps = sentence
+ detail.lineBreakMode = sentence ? .byWordWrapping : .byTruncatingTail
+ detail.cell?.truncatesLastVisibleLine = true
detail.stringValue = detailText
- detail.font = NSFont(descriptor: codeFont.fontDescriptor, size: 10.5) ?? .monospacedSystemFont(ofSize: 10.5, weight: .regular)
+ detail.font = sentence ? .systemFont(ofSize: 11)
+ : NSFont(descriptor: codeFont.fontDescriptor, size: 10.5) ?? .monospacedSystemFont(ofSize: 10.5, weight: .regular)
detail.textColor = detailColor
+ goButton.isHidden = conflict?.holder == nil
archived.isHidden = inScrollback || entry.isRunning || entry.fullScreen
let color: NSColor
@@ -1022,9 +1100,9 @@ final class TimelineCell: NSTableCellView {
} else {
dot.layer?.removeAnimation(forKey: "pulse")
}
- toolTip = entry.command
+ toolTip = [entry.command, conflict?.text].compactMap { $0 }.joined(separator: "\n")
updateSaveButton()
- setAccessibilityLabel(spokenDescription(entry, detail: detailText))
+ setAccessibilityLabel(spokenDescription(entry, detail: conflict?.text ?? detailText))
}
private func spokenDescription(_ entry: TimelineEntry, detail: String) -> String {
@@ -1041,6 +1119,15 @@ final class TimelineCell: NSTableCellView {
var shown: (detail: String, duration: String, durationColor: NSColor?, progress: Double?) {
(detail.stringValue, duration.stringValue, duration.textColor, progress.isHidden ? nil : progress.shownFraction)
}
+
+ /// Whether the row offers Go to Pane, for tests.
+ var offersGoToPane: Bool { !goButton.isHidden }
+
+ /// Presses Go to Pane, for tests.
+ func pressGoToPane() {
+ guard !goButton.isHidden else { return }
+ _ = goButton.sendAction(goButton.action, to: goButton.target)
+ }
}
/// Commands colored as shell code, for AppKit labels.
diff --git a/Sources/CodecUI/ListeningPortsMenu.swift b/Sources/CodecUI/ListeningPortsMenu.swift
new file mode 100644
index 0000000..a1b9125
--- /dev/null
+++ b/Sources/CodecUI/ListeningPortsMenu.swift
@@ -0,0 +1,101 @@
+import AppKit
+import CodecCore
+
+/// A port some pane's program is listening on, and where that pane is: what Window › Listening Ports
+/// and the command palette list.
+@MainActor
+struct PortLocation {
+ let port: ListeningPort
+ let pane: Pane
+ /// "Tab 2 › left pane".
+ let place: String
+
+ /// ":3000 node — Tab 2 › left pane", with the name the process was given in place of its program's.
+ var title: String { "\(port.label) \(port.displayName) — \(place)" }
+
+ func goToPane() {
+ pane.host?.reveal(pane)
+ }
+
+ func openInBrowser() {
+ pane.open(port)
+ }
+}
+
+/// The ports listened on in one window's panes.
+@MainActor
+struct ListeningWindow {
+ let host: (any PaneHost)?
+ /// What the window is called.
+ let title: String?
+ var ports: [PortLocation]
+}
+
+extension PaneDirectory {
+ /// Every port the panes' programs are listening on: a window at a time, in the order the windows
+ /// were first seen, each window's lowest port first. Every pane looks afresh first, unless told
+ /// what's already known will do.
+ func listening(refreshing: Bool = true) -> [ListeningWindow] {
+ var windows: [ListeningWindow] = []
+ for pane in panes {
+ if refreshing { pane.refreshListeningPorts() }
+ guard !pane.session.listeningPorts.isEmpty else { continue }
+ let place = pane.host?.place(of: pane) ?? pane.session.title
+ let found = pane.session.listeningPorts.map { PortLocation(port: $0, pane: pane, place: place) }
+ if let index = windows.firstIndex(where: { $0.host === pane.host }) {
+ windows[index].ports += found
+ } else {
+ windows.append(ListeningWindow(host: pane.host, title: pane.host?.windowTitle, ports: found))
+ }
+ }
+ for index in windows.indices { windows[index].ports.sort { $0.port.port < $1.port.port } }
+ return windows
+ }
+}
+
+/// Window › Listening Ports: every port the programs in any pane are listening on, filled in as the
+/// menu opens. Choosing one goes to its pane; with ⌥, it opens in the browser instead.
+@MainActor
+final class ListeningPortsMenu: NSObject, NSMenuDelegate {
+ static let title = "Listening Ports"
+
+ private let directory: PaneDirectory
+
+ init(directory: PaneDirectory) {
+ self.directory = directory
+ }
+
+ func menuNeedsUpdate(_ menu: NSMenu) {
+ menu.removeAllItems()
+ items().forEach(menu.addItem)
+ }
+
+ /// None of its items has a key: without this, every key pressed with ⌘ would fill the menu in,
+ /// asking the kernel about every pane, to look for one.
+ func menuHasKeyEquivalent(_ menu: NSMenu, for event: NSEvent, target: AutoreleasingUnsafeMutablePointer,
+ action: UnsafeMutablePointer) -> Bool {
+ false
+ }
+
+ /// The ports, under their window's name when they're in more than one; or a line saying there are none.
+ func items() -> [NSMenuItem] {
+ let windows = directory.listening()
+ guard !windows.isEmpty else {
+ return [NSMenuItem(title: "No Listening Ports", action: nil, keyEquivalent: "")]
+ }
+ var items: [NSMenuItem] = []
+ for window in windows {
+ if windows.count > 1 { items.append(.sectionHeader(title: window.title ?? "Window")) }
+ for location in window.ports {
+ let go = ClosureMenuItem(location.title, image: "network") { location.goToPane() }
+ go.toolTip = "Go to the pane. Hold ⌥ to open \(location.port.url.absoluteString) in the browser."
+ let open = ClosureMenuItem("Open \(location.port.url.absoluteString)", modifiers: .option, image: "safari") {
+ location.openInBrowser()
+ }
+ open.isAlternate = true
+ items += [go, open]
+ }
+ }
+ return items
+ }
+}
diff --git a/Sources/CodecUI/MainMenu.swift b/Sources/CodecUI/MainMenu.swift
index f0015a2..31a21fc 100644
--- a/Sources/CodecUI/MainMenu.swift
+++ b/Sources/CodecUI/MainMenu.swift
@@ -5,13 +5,14 @@ import AppKit
enum MainMenu {
private typealias W = TerminalWindowController
- static func build() -> NSMenu {
+ /// `listeningPorts` fills in Window › Listening Ports as it opens; the caller keeps it alive.
+ static func build(listeningPorts: ListeningPortsMenu? = nil) -> NSMenu {
let main = NSMenu()
main.addItem(submenu(appMenu()))
main.addItem(submenu(shellMenu()))
main.addItem(submenu(editMenu()))
main.addItem(submenu(viewMenu()))
- let window = windowMenu()
+ let window = windowMenu(listeningPorts: listeningPorts)
main.addItem(submenu(window))
NSApp.windowsMenu = window
let help = NSMenu(title: "Help")
@@ -132,6 +133,7 @@ enum MainMenu {
menu.addItem(item("Toggle File Tree", #selector(W.toggleFileTree(_:)), "e", [.command, .shift]))
menu.addItem(item("Intel", #selector(W.toggleIntel(_:)), "i", [.command, .shift]))
menu.addItem(item("Commands", #selector(W.toggleCommandTimeline(_:)), "l", [.command, .shift]))
+ menu.addItem(item("Listening Ports", #selector(W.togglePorts(_:)), "o", [.command, .shift]))
menu.addItem(item("Command History…", #selector(W.showCommandHistory(_:)), "y"))
menu.addItem(item("Toggle Markdown Preview", #selector(W.toggleMarkdownPreview(_:)), "v", [.command, .shift]))
menu.addItem(.separator())
@@ -150,7 +152,7 @@ enum MainMenu {
return menu
}
- private static func windowMenu() -> NSMenu {
+ private static func windowMenu(listeningPorts: ListeningPortsMenu?) -> NSMenu {
let menu = NSMenu(title: "Window")
menu.addItem(item("Minimize", #selector(NSWindow.performMiniaturize(_:)), "m"))
menu.addItem(item("Zoom", #selector(NSWindow.performZoom(_:))))
@@ -184,6 +186,11 @@ enum MainMenu {
panesMenu.addItem(item("Below", #selector(W.focusPaneDown(_:)), arrow(NSDownArrowFunctionKey), [.command, .option]))
panes.submenu = panesMenu
menu.addItem(panes)
+ let ports = NSMenuItem(title: ListeningPortsMenu.title, action: nil, keyEquivalent: "")
+ let portsMenu = NSMenu(title: ListeningPortsMenu.title)
+ portsMenu.delegate = listeningPorts
+ ports.submenu = portsMenu
+ menu.addItem(ports)
menu.addItem(.separator())
menu.addItem(item("Bring All to Front", #selector(NSApplication.arrangeInFront(_:))))
return menu
diff --git a/Sources/CodecUI/Pane.swift b/Sources/CodecUI/Pane.swift
index 16d73ba..b7cee7a 100644
--- a/Sources/CodecUI/Pane.swift
+++ b/Sources/CodecUI/Pane.swift
@@ -21,6 +21,12 @@ protocol PaneHost: AnyObject {
func explainFailure(of entry: TimelineEntry, in pane: Pane)
/// ⌘-click on the name of a file in the terminal: show it in the editor, or a folder in Finder.
func openFile(_ url: URL, from pane: Pane)
+ /// Where `pane` is among the window's tabs, in words: "Tab 2 › left pane".
+ func place(of pane: Pane) -> String?
+ /// Brings `pane` to the front: its window, its tab, and the focus.
+ func reveal(_ pane: Pane)
+ /// What the window is called, for telling windows apart in a list.
+ var windowTitle: String? { get }
}
/// One shell in one pane: owns the process, the terminal view and the pane's `Session`.
@@ -190,6 +196,7 @@ final class Pane: LocalProcessTerminalViewDelegate {
applyPreferences()
session.directory = directory ?? NSHomeDirectory()
if let contents { restore(contents) }
+ environment.panes.add(self)
start()
}
@@ -309,6 +316,8 @@ final class Pane: LocalProcessTerminalViewDelegate {
venvObserver = nil
if terminal.process.running { terminal.terminate() }
environment.notifier.clear(id: session.id.uuidString)
+ session.listeningPorts = []
+ environment.panes.remove(self)
}
/// What closing this pane would terminate: the program in the foreground and any background jobs.
@@ -322,6 +331,19 @@ final class Pane: LocalProcessTerminalViewDelegate {
/// Closing this pane would end something besides the shell.
var hasRunningJob: Bool { !runningPrograms.isEmpty }
+ /// What closing some panes would terminate, for asking first.
+ struct Running {
+ /// Each program once, as the shells have them.
+ var programs: [String] = []
+ /// The ports they, or what they started, are listening on.
+ var ports: [ListeningPort] = []
+
+ /// How many programs `list` names.
+ var count: Int { RunningPrograms.programs(programs, listening: ports).count }
+ /// "“npm” and “node” (listening on :3000)".
+ var list: String { RunningPrograms.list(programs, listening: ports) }
+ }
+
// MARK: - Polling
private func startTimer() {
@@ -342,8 +364,12 @@ final class Pane: LocalProcessTerminalViewDelegate {
if printed { view.finder.contentChanged() }
if printed { refreshCommandSeparators() }
if printed, openableNames != nil { refreshFileLink(modifiers: fileLinkModifiers, rescan: true) }
+ if printed { printedSincePortScan = true }
ticks += 1
- if ticks % 4 == 0 { pollProcess() }
+ if ticks % 4 == 0 {
+ pollProcess()
+ pollPorts()
+ }
if session.running != nil, timeline.running?.fullScreen == false,
terminal.terminalInputStateSnapshot()?.isAlternateBuffer == true {
timeline.markRunningFullScreen()
@@ -371,6 +397,114 @@ final class Pane: LocalProcessTerminalViewDelegate {
if branch != session.gitBranch { session.gitBranch = branch }
}
+ // MARK: - Listening ports
+
+ /// How often to look at what the pane's programs listen on, in seconds.
+ static let portScanInterval = 3
+ /// How long after a port shows up its program's output is read for the address it serves at:
+ /// programs say so as they start, and what they print later (requests, mostly) isn't that.
+ static let portAddressWindow: TimeInterval = 20
+
+ private var secondsSincePortScan = 0
+ private var printedSincePortScan = false
+ /// When each port was first seen.
+ private var portsFirstSeen: [Int: Date] = [:]
+ /// Opens an address in the browser; tests look at what would open instead.
+ lazy var openURL: (URL) -> Void = { NSWorkspace.shared.open($0) }
+
+ /// Every pane there is, this one included.
+ var panes: PaneDirectory { environment.panes }
+
+ /// Every second: looks at the ports every few seconds, and within a second of something being
+ /// printed, which is when a server says it's ready. Costs one call to the kernel while the shell
+ /// has started nothing.
+ private func pollPorts() {
+ secondsSincePortScan += 1
+ guard secondsSincePortScan >= Self.portScanInterval || printedSincePortScan else { return }
+ refreshListeningPorts()
+ }
+
+ /// Asks the kernel which TCP ports the programs under the shell listen on, and reads what was
+ /// printed lately for the address of those that are new.
+ func refreshListeningPorts(at date: Date = Date()) {
+ secondsSincePortScan = 0
+ printedSincePortScan = false
+ guard !isShutDown, session.exitStatus == nil, terminal.process.running else { return setListeningPorts([]) }
+ let scanned = ListeningPorts.scan(shell: terminal.process.shellPid,
+ foregroundGroup: ProcessInspector.foregroundProcess(ptyFD: terminal.process.childfd))
+ var ports = ListeningPorts.merging(scanned, keepingAddressesOf: session.listeningPorts)
+ portsFirstSeen = portsFirstSeen.filter { seen in ports.contains { $0.port == seen.key } }
+ for port in ports where portsFirstSeen[port.port] == nil { portsFirstSeen[port.port] = date }
+ let unannounced = ports.indices.filter { index in
+ ports[index].address == nil
+ && date.timeIntervalSince(portsFirstSeen[ports[index].port] ?? date) < Self.portAddressWindow
+ }
+ if !unannounced.isEmpty, let printed = recentOutput() {
+ for index in unannounced { ports[index].address = ListeningPorts.address(for: ports[index].port, in: printed) }
+ }
+ setListeningPorts(ports)
+ }
+
+ private func setListeningPorts(_ ports: [ListeningPort]) {
+ if ports.isEmpty { portsFirstSeen = [:] }
+ guard ports != session.listeningPorts else { return }
+ session.listeningPorts = ports
+ environment.panes.portsDidChange()
+ }
+
+ /// The last lines printed, up to the cursor: the running command's, when it printed fewer than `rows`.
+ private func recentOutput(rows: Int = 300) -> String? {
+ guard terminal.terminalInputStateSnapshot()?.isAlternateBuffer == false,
+ let live = terminal.terminalContentSnapshot(region: .history(maximumScrollbackRows: 0)) else { return nil }
+ let end = live.cursorRow + 1
+ let start = max(timeline.running?.block?.output ?? 0, end - rows, 0)
+ return lines(of: start.. PortConflictStatus? {
+ guard var conflict = entry.portConflict else { return nil }
+ guard let holder = environment.panes.pane(id: conflict.pane),
+ let held = holder.session.listeningPorts.first(where: { $0.port == conflict.port }) else {
+ return PortConflictStatus(text: conflict.sentence(stillHeld: false))
+ }
+ // By the name its process goes by now, if it was given one.
+ conflict.program = held.displayName
+ if holder === self { return PortConflictStatus(text: conflict.sentence(place: "this pane")) }
+ let elsewhere = holder.host !== host
+ let place = holder.host?.place(of: holder).map { elsewhere ? "another window, \($0)" : $0 }
+ return PortConflictStatus(text: conflict.sentence(place: place ?? (elsewhere ? "another window" : "another pane")),
+ holder: holder)
+ }
+
+ /// A command failed: if what it printed says a port was taken, finds the pane whose program has it.
+ private func recordPortConflict(of id: Int, output: String) {
+ guard let entry = timeline.value.entry(id: id), case .failed(let code) = entry.status, code > 0,
+ let port = PortConflict.port(inOutput: output, command: entry.command),
+ let holder = environment.panes.holder(of: port) else { return }
+ timeline.setPortConflict(PortConflict(port: port, program: holder.port.program, pane: holder.pane.session.id.uuidString),
+ of: id)
+ }
+
// MARK: - Shell integration
func handle(_ event: IntegrationEvent) {
@@ -402,6 +536,8 @@ final class Pane: LocalProcessTerminalViewDelegate {
runningDirectory = session.directory
promptIsDrawn = false
waitingPromptRow = nil
+ // A server takes a moment to start listening: look a couple of seconds from now.
+ secondsSincePortScan = Self.portScanInterval - 2
default:
break
}
@@ -435,6 +571,8 @@ final class Pane: LocalProcessTerminalViewDelegate {
let finished = timeline.running
let usual = finished?.usualDuration
timeline.finish(exitCode: exitCode, duration: duration, output: nil)
+ // What it was listening on is gone with it.
+ refreshListeningPorts()
// Its output runs up to the next prompt, found once that's drawn.
if let finished, finished.block != nil, !finished.fullScreen { outputToCapture = finished.id }
if preferences.recordHistory {
@@ -917,6 +1055,7 @@ final class Pane: LocalProcessTerminalViewDelegate {
let (lines, lostEarlier) = lines(of: block.output.. Void
+
+ var body: some View {
+ HStack(spacing: 2) {
+ tab(.commands, title: "Commands", systemImage: "list.bullet.rectangle", shortcut: "⇧⌘L")
+ tab(.ports, title: "Ports", systemImage: "network", shortcut: "⇧⌘O")
+ Spacer(minLength: 0)
+ }
+ .padding(.horizontal, 8)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color(nsColor: theme.foreground).opacity(theme.isDark ? 0.025 : 0.035))
+ .overlay(alignment: .bottom) { Rectangle().fill(theme.dividerColor).frame(height: 1) }
+ .overlay(alignment: .leading) { Rectangle().fill(theme.dividerColor).frame(width: 1) }
+ }
+
+ private func tab(_ tab: InspectorTab, title: String, systemImage: String, shortcut: String) -> some View {
+ let selected = model.inspectorTab == tab
+ return Button { select(tab) } label: {
+ Label(title, systemImage: systemImage)
+ .font(.system(size: 11.5, weight: selected ? .semibold : .regular))
+ .padding(.horizontal, 8)
+ .frame(height: 22)
+ .background(RoundedRectangle(cornerRadius: 6).fill(Color.primary.opacity(selected ? 0.09 : 0)))
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .foregroundStyle(selected ? .primary : .secondary)
+ .help("\(title) (\(shortcut))")
+ .accessibilityAddTraits(selected ? .isSelected : [])
+ }
+}
+
+/// What the Ports tab lists: every port the programs in any pane are listening on, this window's
+/// first. Follows the panes only while the tab is showing.
+@MainActor
+final class PortsModel: ObservableObject {
+ struct Row: Identifiable {
+ let id: String
+ let location: PortLocation
+
+ var port: ListeningPort { location.port }
+
+ /// Under the name: where the pane is, after the program when the process goes by a name of the user's.
+ var detail: String {
+ port.name == nil ? location.place : "\(port.program) · \(location.place)"
+ }
+ }
+
+ struct Section: Identifiable {
+ let id: Int
+ /// The window the ports are in, when that needs saying: they're not all in this one.
+ let title: String?
+ let rows: [Row]
+ }
+
+ @Published private(set) var sections: [Section] = []
+ /// The row whose process is being given a name.
+ @Published var naming: String?
+
+ private let directory: PaneDirectory
+ /// The window the list is in.
+ weak var host: (any PaneHost)?
+ private var subscription: AnyCancellable?
+
+ init(directory: PaneDirectory) {
+ self.directory = directory
+ }
+
+ var rows: [Row] { sections.flatMap(\.rows) }
+
+ /// Whether the tab is showing: it keeps up with the panes only then.
+ var isActive = false {
+ didSet {
+ guard isActive != oldValue else { return }
+ // Sent once the change is made, so what's read here is already the new state.
+ subscription = isActive ? directory.objectWillChange.sink { [weak self] _ in
+ MainActor.assumeIsolated { self?.reload() }
+ } : nil
+ if isActive { reload(refreshing: true) } else { naming = nil }
+ }
+ }
+
+ /// Lists the ports again; `refreshing` has every pane look at what it's listening on first.
+ func reload(refreshing: Bool = false) {
+ let windows = directory.listening(refreshing: refreshing)
+ let ordered = windows.filter { $0.host === host } + windows.filter { $0.host !== host }
+ let titled = ordered.contains { $0.host !== host }
+ sections = ordered.enumerated().map { index, window in
+ Section(id: index, title: titled ? (window.host === host ? "This Window" : window.title ?? "Another Window") : nil,
+ rows: window.ports.map { Row(id: "\($0.pane.session.id.uuidString):\($0.port.port)", location: $0) })
+ }
+ if let naming, !rows.contains(where: { $0.id == naming }) { self.naming = nil }
+ }
+
+ func goToPane(_ row: Row) {
+ row.location.goToPane()
+ }
+
+ func openInBrowser(_ row: Row) {
+ row.location.openInBrowser()
+ }
+
+ func copyAddress(_ row: Row) {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(row.port.url.absoluteString, forType: .string)
+ }
+
+ /// Calls the row's process `name` for as long as it runs; a blank name takes its name away, and
+ /// nil leaves it as it was.
+ func finishNaming(_ row: Row, as name: String?) {
+ naming = nil
+ if let name { row.location.pane.name(row.port, as: name) }
+ }
+}
+
+/// The Ports tab of the right sidebar: what's listening in every pane, with the way to each pane.
+final class PortsSidebar: NSView {
+ let model: PortsModel
+ /// The user dragged the leading edge to this width.
+ var onResize: ((CGFloat) -> Void)?
+
+ private let list: NSHostingView
+ private let divider = NSView()
+ private let resizeHandle = SidebarResizeHandle()
+
+ init(model: PortsModel, theme: CodecTheme) {
+ self.model = model
+ list = NSHostingView(rootView: PortsList(model: model, theme: theme))
+ super.init(frame: .zero)
+ wantsLayer = true
+ divider.wantsLayer = true
+ // The window sizes the sidebar, not what's listed in it.
+ list.sizingOptions = []
+ resizeHandle.onDrag = { [weak self] delta in
+ guard let self else { return }
+ self.onResize?(self.frame.width - delta)
+ }
+ for view in [list, divider, resizeHandle] as [NSView] {
+ view.translatesAutoresizingMaskIntoConstraints = false
+ addSubview(view)
+ }
+ NSLayoutConstraint.activate([
+ divider.topAnchor.constraint(equalTo: topAnchor),
+ divider.bottomAnchor.constraint(equalTo: bottomAnchor),
+ divider.leadingAnchor.constraint(equalTo: leadingAnchor),
+ divider.widthAnchor.constraint(equalToConstant: 1),
+ resizeHandle.topAnchor.constraint(equalTo: topAnchor),
+ resizeHandle.bottomAnchor.constraint(equalTo: bottomAnchor),
+ resizeHandle.centerXAnchor.constraint(equalTo: divider.centerXAnchor),
+ resizeHandle.widthAnchor.constraint(equalToConstant: 8),
+ list.topAnchor.constraint(equalTo: topAnchor),
+ list.leadingAnchor.constraint(equalTo: divider.trailingAnchor),
+ list.trailingAnchor.constraint(equalTo: trailingAnchor),
+ list.bottomAnchor.constraint(equalTo: bottomAnchor),
+ ])
+ apply(theme: theme)
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) is not supported")
+ }
+
+ func apply(theme: CodecTheme) {
+ list.rootView = PortsList(model: model, theme: theme)
+ layer?.backgroundColor = theme.foreground.withAlphaComponent(theme.isDark ? 0.025 : 0.035).cgColor
+ divider.layer?.backgroundColor = theme.divider.cgColor
+ }
+}
+
+/// The list: a heading, then each port with who's listening and where, a window at a time.
+struct PortsList: View {
+ @ObservedObject var model: PortsModel
+ let theme: CodecTheme
+
+ var body: some View {
+ VStack(spacing: 0) {
+ HStack(spacing: 6) {
+ Image(systemName: "network")
+ .font(.system(size: 11, weight: .medium))
+ .foregroundStyle(.secondary)
+ Text("Listening")
+ .font(.system(size: 12, weight: .semibold))
+ Text(Self.count(model.rows.count))
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ Spacer(minLength: 4)
+ }
+ .padding(.leading, 12)
+ .frame(height: 34)
+ if model.sections.isEmpty {
+ Text("Nothing is listening. Start a server in any pane, and its port shows up here.")
+ .font(.system(size: 12))
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 20)
+ .padding(.top, 28)
+ Spacer(minLength: 0)
+ } else {
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 2) {
+ ForEach(model.sections) { section in
+ if let title = section.title {
+ Label(title, systemImage: "macwindow")
+ .font(.system(size: 11, weight: .semibold))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .padding(.horizontal, 10)
+ .padding(.top, 8)
+ .padding(.bottom, 2)
+ }
+ ForEach(section.rows) { row in
+ PortRow(model: model, row: row, theme: theme)
+ }
+ }
+ }
+ .padding(.bottom, 8)
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
+ }
+
+ /// "1 port", "3 ports"; nothing for none, which the list says in full.
+ static func count(_ count: Int) -> String {
+ count == 0 ? "" : count == 1 ? "1 port" : "\(count) ports"
+ }
+}
+
+/// One port: its number, the name its process goes by, and where its pane is. Click to go there.
+private struct PortRow: View {
+ @ObservedObject var model: PortsModel
+ let row: PortsModel.Row
+ let theme: CodecTheme
+ @State private var hovering = false
+
+ var body: some View {
+ let naming = model.naming == row.id
+ HStack(alignment: .firstTextBaseline, spacing: 8) {
+ Text(row.port.label)
+ .font(.system(size: 12, weight: .semibold, design: .monospaced))
+ .foregroundStyle(theme.accentColor)
+ VStack(alignment: .leading, spacing: 2) {
+ if naming {
+ InlineNameEditor(initialName: row.port.name ?? "", placeholder: row.port.program, label: "Process name",
+ onFinish: { model.finishNaming(row, as: $0) })
+ } else {
+ Text(row.port.displayName)
+ .font(.system(size: 12, weight: .medium))
+ .lineLimit(1)
+ .truncationMode(.middle)
+ }
+ Text(row.detail)
+ .font(.system(size: 10.5))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ }
+ Spacer(minLength: 4)
+ if hovering, !naming {
+ RowButton(systemImage: "pencil", help: row.port.name == nil ? "Name This Process" : "Rename") { model.naming = row.id }
+ RowButton(systemImage: "safari", help: "Open \(row.port.url.absoluteString)") { model.openInBrowser(row) }
+ }
+ }
+ .padding(.horizontal, 10)
+ .padding(.vertical, 5)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(RoundedRectangle(cornerRadius: 6).fill(Color.primary.opacity(hovering ? 0.07 : 0)).padding(.horizontal, 6))
+ .contentShape(Rectangle())
+ .onTapGesture { if !naming { model.goToPane(row) } }
+ .onHover { hovering = $0 }
+ .contextMenu {
+ Button("Go to Pane") { model.goToPane(row) }
+ Button("Open in Browser") { model.openInBrowser(row) }
+ Button("Copy Address") { model.copyAddress(row) }
+ Divider()
+ Button(row.port.name == nil ? "Name Process…" : "Rename…") { model.naming = row.id }
+ if row.port.name != nil {
+ Button("Remove Name") { model.finishNaming(row, as: "") }
+ }
+ }
+ .help("Go to the pane: \(row.port.program) is listening on \(row.port.label) in \(row.location.place)")
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel("Port \(row.port.port), \(row.port.displayName), \(row.detail)")
+ .accessibilityAddTraits(.isButton)
+ }
+}
+
+private struct RowButton: View {
+ let systemImage: String
+ let help: String
+ var action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ Image(systemName: systemImage)
+ .font(.system(size: 11, weight: .medium))
+ .frame(width: 20, height: 18)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .foregroundStyle(.secondary)
+ .help(help)
+ .accessibilityLabel(help)
+ }
+}
diff --git a/Sources/CodecUI/Session.swift b/Sources/CodecUI/Session.swift
index 71d9a6a..8a958a6 100644
--- a/Sources/CodecUI/Session.swift
+++ b/Sources/CodecUI/Session.swift
@@ -49,6 +49,8 @@ final class Session: ObservableObject {
@Published var toolchain = Toolchain()
/// A virtual environment found where the shell is, waiting for the user to say whether to activate it.
@Published var pendingVenv: Toolchain.PythonEnvironment?
+ /// The TCP ports the programs started from the pane listen on, lowest first.
+ @Published var listeningPorts: [ListeningPort] = []
var abbreviatedDirectory: String {
(directory as NSString).abbreviatingWithTildeInPath
diff --git a/Sources/CodecUI/StatusBar.swift b/Sources/CodecUI/StatusBar.swift
index d45ea10..280c2a8 100644
--- a/Sources/CodecUI/StatusBar.swift
+++ b/Sources/CodecUI/StatusBar.swift
@@ -49,6 +49,7 @@ private struct TabStatus: View {
SessionStatus(session: pane.session, theme: theme,
onBranchChanged: { [weak pane] in pane?.gitBranchDidChange() },
run: { [weak pane] command in pane?.runAtPrompt(command) ?? false },
+ open: { [weak pane] port in pane?.open(port) },
explain: explain.map { explain in { [weak pane] in if let pane { explain(pane) } } })
.id(ObjectIdentifier(pane))
}
@@ -60,6 +61,8 @@ private struct SessionStatus: View {
var onBranchChanged: () -> Void
/// Runs a command at the pane's prompt; false when the shell is busy.
var run: (String) -> Bool
+ /// Opens what a port serves in the browser.
+ var open: (ListeningPort) -> Void
/// Explain, beside a failed command; nil when explaining isn't offered.
var explain: (() -> Void)?
@State private var choosingBranch = false
@@ -85,6 +88,7 @@ private struct SessionStatus: View {
}
}
ToolchainStatus(session: session, theme: theme, run: run)
+ PortsStatus(ports: session.listeningPorts, open: open)
commandStatus
}
}
@@ -132,6 +136,51 @@ private struct SessionStatus: View {
}
}
+/// The ports the pane's programs listen on. One opens in the browser at a click; several make a menu.
+struct PortsStatus: View {
+ let ports: [ListeningPort]
+ var open: (ListeningPort) -> Void
+
+ var body: some View {
+ if ports.count == 1, let port = ports.first {
+ Button { open(port) } label: {
+ Chip(systemImage: "network", text: port.label)
+ .contentShape(Capsule())
+ }
+ .buttonStyle(.plain)
+ .fixedSize()
+ .help(Self.help(for: port))
+ .accessibilityLabel(Self.help(for: port))
+ } else if !ports.isEmpty {
+ Menu {
+ ForEach(ports) { port in
+ Button(Self.menuTitle(for: port)) { open(port) }
+ }
+ } label: {
+ Chip(systemImage: "network", text: ListeningPorts.label(ports), trailingSymbol: "chevron.up.chevron.down")
+ .contentShape(Capsule())
+ }
+ .menuStyle(.button)
+ .buttonStyle(.plain)
+ .menuIndicator(.hidden)
+ .fixedSize()
+ .help(ListeningPorts.phrase(ports, namingPrograms: true, capitalized: true))
+ .accessibilityLabel("\(ListeningPorts.phrase(ports, capitalized: true)). Open in the browser")
+ }
+ }
+
+ /// "Open http://localhost:3000/app: “node” is listening on :3000".
+ static func help(for port: ListeningPort) -> String {
+ "Open \(port.url.absoluteString): “\(port.displayName)” is listening on \(port.label)"
+ }
+
+ /// "localhost:3000/app — node": the address without its scheme unless it's not plain HTTP.
+ static func menuTitle(for port: ListeningPort) -> String {
+ let address = port.url.absoluteString
+ return "\(address.hasPrefix("http://") ? String(address.dropFirst(7)) : address) — \(port.displayName)"
+ }
+}
+
private struct Chip: View {
let systemImage: String
let text: String
diff --git a/Sources/CodecUI/TerminalTab.swift b/Sources/CodecUI/TerminalTab.swift
index 9e35f0e..51d44c3 100644
--- a/Sources/CodecUI/TerminalTab.swift
+++ b/Sources/CodecUI/TerminalTab.swift
@@ -97,6 +97,16 @@ final class TerminalTab: ObservableObject, Identifiable {
/// The title the tab has without a custom name: what's running in its focused pane.
var automaticTitle: String { focused.session.title }
+ /// What shows when the pointer rests on the tab: where its focused pane is (and what it's running,
+ /// when the tab has a name of its own), then what its panes' programs are listening on.
+ var toolTip: String {
+ let directory = focused.session.abbreviatedDirectory
+ let ports = panes.flatMap(\.session.listeningPorts)
+ let lines = [customTitle == nil ? directory : "\(automaticTitle) — \(directory)",
+ ports.isEmpty ? nil : ListeningPorts.phrase(ports, namingPrograms: true, capitalized: true)]
+ return lines.compactMap { $0 }.joined(separator: "\n")
+ }
+
/// Names the tab. Blank names (or nil) go back to the automatic title.
func rename(to name: String?) {
let trimmed = name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
diff --git a/Sources/CodecUI/TerminalWindowController.swift b/Sources/CodecUI/TerminalWindowController.swift
index 9f8506f..f4a1924 100644
--- a/Sources/CodecUI/TerminalWindowController.swift
+++ b/Sources/CodecUI/TerminalWindowController.swift
@@ -37,6 +37,12 @@ final class TerminalWindowController: NSWindowController, NSWindowDelegate, NSMe
private(set) var timelineSidebar: CommandTimelineSidebar!
private var timelineWidthConstraint: NSLayoutConstraint!
private static let timelineVisibleKey = "CommandsVisible"
+ /// The right sidebar: the Commands and Ports tabs above the pane's commands or what's listening.
+ private let inspector = NSView()
+ private var inspectorBar: NSHostingView!
+ private(set) var portsSidebar: PortsSidebar!
+ let ports: PortsModel
+ private static let inspectorTabKey = "InspectorTab"
let intel: IntelLibrary
private(set) var intelSidebar: IntelSidebar!
/// The left sidebar: the Files and Intel tabs above the file tree or the Intel list.
@@ -52,6 +58,7 @@ final class TerminalWindowController: NSWindowController, NSWindowDelegate, NSMe
self.environment = environment
fileTree = FileTreeModel(defaults: environment.preferences.stateDefaults)
intel = IntelLibrary(personalFolder: environment.intelFolder)
+ ports = PortsModel(directory: environment.panes)
let window = NSWindow(
contentRect: NSRect(origin: .zero, size: Self.defaultSize(font: environment.preferences.font())),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
@@ -80,7 +87,8 @@ final class TerminalWindowController: NSWindowController, NSWindowDelegate, NSMe
observeState()
model.navigatorTab = preferences.stateDefaults.string(forKey: Self.navigatorTabKey).flatMap(NavigatorTab.init) ?? .files
setNavigatorVisible(preferences.stateDefaults.bool(forKey: Self.fileTreeVisibleKey))
- setTimelineVisible(preferences.stateDefaults.bool(forKey: Self.timelineVisibleKey))
+ model.inspectorTab = preferences.stateDefaults.string(forKey: Self.inspectorTabKey).flatMap(InspectorTab.init) ?? .commands
+ setInspectorVisible(preferences.stateDefaults.bool(forKey: Self.timelineVisibleKey))
}
@available(*, unavailable)
@@ -118,7 +126,10 @@ final class TerminalWindowController: NSWindowController, NSWindowDelegate, NSMe
guard let self else { return }
self.setNavigatorVisible(!self.model.fileTreeVisible)
},
- toggleTimeline: { [weak self] in self?.toggleCommandTimeline(nil) },
+ toggleTimeline: { [weak self] in
+ guard let self else { return }
+ self.setInspectorVisible(!self.model.timelineVisible)
+ },
beginRenaming: { [weak self] in self?.beginRenaming($0) },
finishRenaming: { [weak self] in self?.finishRenaming($0, to: $1) })))
tabBar.movesWindow = false
@@ -166,7 +177,30 @@ final class TerminalWindowController: NSWindowController, NSWindowDelegate, NSMe
return self.environment.explainer
}
timelineSidebar.isHidden = true
- timelineWidthConstraint = timelineSidebar.widthAnchor.constraint(equalToConstant: 0)
+ ports.host = self
+ portsSidebar = PortsSidebar(model: ports, theme: preferences.theme)
+ portsSidebar.onResize = { [weak self] width in self?.resizeTimeline(to: width) }
+ portsSidebar.isHidden = true
+ inspectorBar = NSHostingView(rootView: InspectorBar(model: model, theme: preferences.theme, select: { _ in }))
+ inspectorBar.rootView = makeInspectorBar()
+ for view in [inspectorBar!, timelineSidebar!, portsSidebar!] as [NSView] {
+ view.translatesAutoresizingMaskIntoConstraints = false
+ inspector.addSubview(view)
+ }
+ let underTheBar: [NSLayoutConstraint] = [timelineSidebar!, portsSidebar!].flatMap { view -> [NSLayoutConstraint] in [
+ view.topAnchor.constraint(equalTo: inspectorBar.bottomAnchor),
+ view.leadingAnchor.constraint(equalTo: inspector.leadingAnchor),
+ view.trailingAnchor.constraint(equalTo: inspector.trailingAnchor),
+ view.bottomAnchor.constraint(equalTo: inspector.bottomAnchor),
+ ] }
+ NSLayoutConstraint.activate(underTheBar + [
+ inspectorBar.topAnchor.constraint(equalTo: inspector.topAnchor),
+ inspectorBar.leadingAnchor.constraint(equalTo: inspector.leadingAnchor),
+ inspectorBar.trailingAnchor.constraint(equalTo: inspector.trailingAnchor),
+ inspectorBar.heightAnchor.constraint(equalToConstant: 30),
+ ])
+ inspector.isHidden = true
+ timelineWidthConstraint = inspector.widthAnchor.constraint(equalToConstant: 0)
let storedTimelineWidth = preferences.stateDefaults.double(forKey: Self.timelineWidthKey)
timelineWidth = storedTimelineWidth > 0
? min(max(CGFloat(storedTimelineWidth), CommandTimelineSidebar.widthRange.lowerBound), CommandTimelineSidebar.widthRange.upperBound)
@@ -180,7 +214,7 @@ final class TerminalWindowController: NSWindowController, NSWindowDelegate, NSMe
editorWidth = editorPanel.widthAnchor.constraint(equalToConstant: 0)
editor.confirmClose = { [weak self] document, done in self?.confirmClosing(document, then: done) ?? done(true) }
- for view in [backdrop, tint, tabBar!, navigator, editorPanel!, paneContainer, timelineSidebar!, statusBar!, palette!] as [NSView] {
+ for view in [backdrop, tint, tabBar!, navigator, editorPanel!, paneContainer, inspector, statusBar!, palette!] as [NSView] {
view.translatesAutoresizingMaskIntoConstraints = false
content.addSubview(view)
}
@@ -188,9 +222,9 @@ final class TerminalWindowController: NSWindowController, NSWindowDelegate, NSMe
let filling: [NSLayoutConstraint] = backdrop.constraintsFilling(content) + tint.constraintsFilling(content)
+ palette.constraintsFilling(content)
let commands: [NSLayoutConstraint] = [
- timelineSidebar.topAnchor.constraint(equalTo: tabBar.bottomAnchor),
- timelineSidebar.trailingAnchor.constraint(equalTo: content.trailingAnchor),
- timelineSidebar.bottomAnchor.constraint(equalTo: statusBar.topAnchor),
+ inspector.topAnchor.constraint(equalTo: tabBar.bottomAnchor),
+ inspector.trailingAnchor.constraint(equalTo: content.trailingAnchor),
+ inspector.bottomAnchor.constraint(equalTo: statusBar.topAnchor),
timelineWidthConstraint,
]
NSLayoutConstraint.activate(filling + commands)
@@ -209,7 +243,7 @@ final class TerminalWindowController: NSWindowController, NSWindowDelegate, NSMe
editorPanel.bottomAnchor.constraint(equalTo: statusBar.topAnchor),
editorWidth,
paneContainer.leadingAnchor.constraint(equalTo: editorPanel.trailingAnchor),
- paneContainer.trailingAnchor.constraint(equalTo: timelineSidebar.leadingAnchor),
+ paneContainer.trailingAnchor.constraint(equalTo: inspector.leadingAnchor),
paneContainer.bottomAnchor.constraint(equalTo: statusBar.topAnchor),
statusBar.leadingAnchor.constraint(equalTo: content.leadingAnchor),
statusBar.trailingAnchor.constraint(equalTo: content.trailingAnchor),
@@ -246,6 +280,8 @@ final class TerminalWindowController: NSWindowController, NSWindowDelegate, NSMe
intelSidebar?.applyTheme(theme)
if navigatorBar != nil { navigatorBar.rootView = makeNavigatorBar() }
timelineSidebar?.apply(theme: theme, codeFont: preferences.font())
+ portsSidebar?.apply(theme: theme)
+ if inspectorBar != nil { inspectorBar.rootView = makeInspectorBar() }
editorPanel?.apply(theme: theme, font: preferences.font())
allPanes.forEach { $0.applyPreferences() }
installSelectedTab(force: true)
@@ -281,7 +317,7 @@ final class TerminalWindowController: NSWindowController, NSWindowDelegate, NSMe
focusedPaneCameToFront()
updateWindowTitle()
followFocusedDirectory()
- if model.timelineVisible { timelineSidebar.follow(focusedPane) }
+ if model.timelineVisible, model.inspectorTab == .commands { timelineSidebar.follow(focusedPane) }
}
private func updateWindowTitle() {
@@ -487,13 +523,40 @@ final class TerminalWindowController: NSWindowController, NSWindowDelegate, NSMe
private var timelineWidth: CGFloat = CommandTimelineSidebar.defaultWidth
+ /// Shows the Commands sidebar, or hides the right sidebar.
func setTimelineVisible(_ visible: Bool) {
- if !visible { giveTerminalTheKeyboard(ifIn: timelineSidebar) }
+ setInspectorVisible(visible, tab: visible ? .commands : nil)
+ }
+
+ /// Shows or hides the right sidebar, on `tab` when given or the tab it was on.
+ func setInspectorVisible(_ visible: Bool, tab: InspectorTab? = nil) {
+ if let tab, tab != model.inspectorTab {
+ model.inspectorTab = tab
+ preferences.stateDefaults.set(tab.rawValue, forKey: Self.inspectorTabKey)
+ }
+ let shown = visible ? model.inspectorTab : nil
+ // Before hiding it: AppKit would leave the keyboard with the window, not the terminal.
+ if shown != .commands { giveTerminalTheKeyboard(ifIn: timelineSidebar) }
+ if shown != .ports { giveTerminalTheKeyboard(ifIn: portsSidebar) }
model.timelineVisible = visible
preferences.stateDefaults.set(visible, forKey: Self.timelineVisibleKey)
- timelineSidebar.isHidden = !visible
+ inspector.isHidden = !visible
+ timelineSidebar.isHidden = shown != .commands
+ portsSidebar.isHidden = shown != .ports
timelineWidthConstraint.constant = visible ? fittingTimelineWidth : 0
- timelineSidebar.follow(visible ? focusedPane : nil)
+ timelineSidebar.follow(shown == .commands ? focusedPane : nil)
+ ports.isActive = shown == .ports
+ }
+
+ private func makeInspectorBar() -> InspectorBar {
+ InspectorBar(model: model, theme: preferences.theme) { [weak self] tab in
+ guard let self else { return }
+ self.setInspectorVisible(true, tab: tab)
+ if tab == .commands {
+ self.window?.layoutIfNeeded()
+ self.timelineSidebar.focusList()
+ }
+ }
}
/// The sidebar's width, narrower than the user made it when the window can't leave the terminal room
@@ -512,11 +575,16 @@ final class TerminalWindowController: NSWindowController, NSWindowDelegate, NSMe
/// ⇧⌘L: shows the Commands sidebar with the keyboard on it, or hides it.
@objc func toggleCommandTimeline(_ sender: Any?) {
- setTimelineVisible(!model.timelineVisible)
- if model.timelineVisible {
- window?.layoutIfNeeded()
- timelineSidebar.focusList()
- }
+ if model.timelineVisible, model.inspectorTab == .commands { return setInspectorVisible(false) }
+ setInspectorVisible(true, tab: .commands)
+ window?.layoutIfNeeded()
+ timelineSidebar.focusList()
+ }
+
+ /// ⇧⌘O: shows what's listening in every pane, or hides it when it's showing.
+ @objc func togglePorts(_ sender: Any?) {
+ if model.timelineVisible, model.inspectorTab == .ports { return setInspectorVisible(false) }
+ setInspectorVisible(true, tab: .ports)
}
/// Keeps the tree on the focused pane's directory.
@@ -710,6 +778,7 @@ final class TerminalWindowController: NSWindowController, NSWindowDelegate, NSMe
guard let self, let tab else { return }
// Its splits, its name or a pane's directory may have changed.
self.onStateChange?()
+ self.environment.panes.placesDidChange()
guard tab === self.model.selected else { return }
self.updateWindowTitle()
}
@@ -781,7 +850,13 @@ final class TerminalWindowController: NSWindowController, NSWindowDelegate, NSMe
private func observeState() {
model.objectWillChange
.receive(on: RunLoop.main)
- .sink { [weak self] _ in MainActor.assumeIsolated { self?.onStateChange?() } }
+ .sink { [weak self] _ in
+ MainActor.assumeIsolated {
+ self?.onStateChange?()
+ // Tabs came, went or moved: "Tab 2" may be another tab now.
+ self?.environment.panes.placesDidChange()
+ }
+ }
.store(in: &cancellables)
for name in [NSWindow.didMoveNotification, NSWindow.didResizeNotification] {
NotificationCenter.default.publisher(for: name, object: window)
@@ -806,15 +881,25 @@ final class TerminalWindowController: NSWindowController, NSWindowDelegate, NSMe
/// Shows the pane with this session ID. Returns false if it isn't in this window.
func reveal(sessionID: String) -> Bool {
- for tab in model.tabs {
- if let pane = tab.panes.first(where: { $0.session.id.uuidString == sessionID }) {
- select(tab)
- tab.focus(pane)
- window?.makeKeyAndOrderFront(nil)
- return true
- }
- }
- return false
+ guard let pane = allPanes.first(where: { $0.session.id.uuidString == sessionID }) else { return false }
+ reveal(pane)
+ return true
+ }
+
+ func reveal(_ pane: Pane) {
+ guard let tab = model.tabs.first(where: { $0.layout.contains(pane) }) else { return }
+ if tab !== model.selected { select(tab) }
+ tab.focus(pane)
+ window?.makeKeyAndOrderFront(nil)
+ window?.makeFirstResponder(pane.view.preferredResponder)
+ }
+
+ var windowTitle: String? { window?.title }
+
+ func place(of pane: Pane) -> String? {
+ guard let index = model.tabs.firstIndex(where: { $0.layout.contains(pane) }) else { return nil }
+ let tab = "Tab \(index + 1)"
+ return model.tabs[index].layout.position(of: pane).map { "\(tab) › \($0) pane" } ?? tab
}
private func remove(_ pane: Pane) {
@@ -875,17 +960,14 @@ final class TerminalWindowController: NSWindowController, NSWindowDelegate, NSMe
/// Asks before closing panes that have programs running in them.
private func confirmClosing(_ panes: [Pane], what: String, then close: @escaping () -> Void) {
- var programs: [String] = []
- for pane in panes {
- for program in pane.runningPrograms where !programs.contains(program) { programs.append(program) }
- }
- guard !programs.isEmpty, let window else {
+ let running = panes.running
+ guard !running.programs.isEmpty, let window else {
close()
return
}
let alert = NSAlert()
alert.messageText = "Close \(what)?"
- alert.informativeText = "\(RunningPrograms.list(programs)) \(programs.count == 1 ? "is" : "are") still running and will be terminated."
+ alert.informativeText = "\(running.list) \(running.count == 1 ? "is" : "are") still running and will be terminated."
alert.addButton(withTitle: "Close")
alert.addButton(withTitle: "Cancel")
alert.beginSheetModal(for: window) { response in
@@ -893,15 +975,6 @@ final class TerminalWindowController: NSWindowController, NSWindowDelegate, NSMe
}
}
- /// Everything the window's panes would terminate if it closed, each program once.
- var runningPrograms: [String] {
- var programs: [String] = []
- for pane in allPanes {
- for program in pane.runningPrograms where !programs.contains(program) { programs.append(program) }
- }
- return programs
- }
-
var hasRunningJobs: Bool {
allPanes.contains(where: \.hasRunningJob)
}
@@ -963,7 +1036,7 @@ final class TerminalWindowController: NSWindowController, NSWindowDelegate, NSMe
guard let tab = model.tabs.first(where: { $0.layout.contains(pane) }) else { return }
if tab !== model.selected { select(tab) }
tab.focus(pane)
- if !model.timelineVisible { setTimelineVisible(true) }
+ if !model.timelineVisible || model.inspectorTab != .commands { setTimelineVisible(true) }
window?.layoutIfNeeded()
let row = timelineSidebar.rows.firstIndex { $0.entry?.id == entry.id }
if let row {
@@ -1375,8 +1448,10 @@ final class TerminalWindowController: NSWindowController, NSWindowDelegate, NSMe
.init(id: "new-intel-doc", title: "New Intel Document…", systemImage: "doc.text") { [weak self] in
self?.newIntel(.doc)
},
- .init(id: "commands", title: model.timelineVisible ? "Hide Commands Sidebar" : "Show Commands Sidebar", shortcut: "⇧⌘L",
- systemImage: "sidebar.right") { [weak self] in self?.toggleCommandTimeline(nil) },
+ .init(id: "commands", title: model.timelineVisible && model.inspectorTab == .commands ? "Hide Commands Sidebar" : "Show Commands Sidebar",
+ shortcut: "⇧⌘L", systemImage: "sidebar.right") { [weak self] in self?.toggleCommandTimeline(nil) },
+ .init(id: "ports", title: model.timelineVisible && model.inspectorTab == .ports ? "Hide Listening Ports" : "Show Listening Ports",
+ shortcut: "⇧⌘O", systemImage: "network") { [weak self] in self?.togglePorts(nil) },
.init(id: "command-history", title: "Search Command History", shortcut: "⌘Y", systemImage: "clock.arrow.circlepath") { [weak self] in
self?.showCommandHistory(nil)
},
@@ -1417,6 +1492,20 @@ final class TerminalWindowController: NSWindowController, NSWindowDelegate, NSMe
self?.environment.coordinator?.showSettings()
},
]
+ // What's listening, in any window: type a port to go to its pane, or to open it.
+ for location in environment.panes.listening().flatMap(\.ports) {
+ let id = "\(location.port.port)-\(location.pane.session.id.uuidString)"
+ let elsewhere = location.pane.host !== self
+ let place = (elsewhere ? "another window, " : "") + location.place
+ commands.append(.init(id: "port-\(id)", title: "Go to \(location.port.label) — \(location.port.program), \(place)",
+ systemImage: "network") {
+ location.goToPane()
+ })
+ commands.append(.init(id: "open-port-\(id)", title: "Open \(location.port.url.absoluteString) in the Browser",
+ systemImage: "safari") {
+ location.openInBrowser()
+ })
+ }
let preferences = self.preferences
for theme in CodecTheme.all {
commands.append(.init(id: "theme-\(theme.id)", title: "Theme: \(theme.name)", systemImage: "paintpalette") {
diff --git a/Sources/CodecUI/WindowChrome.swift b/Sources/CodecUI/WindowChrome.swift
index b4efe4d..97f346a 100644
--- a/Sources/CodecUI/WindowChrome.swift
+++ b/Sources/CodecUI/WindowChrome.swift
@@ -15,6 +15,8 @@ final class WindowModel: ObservableObject {
@Published var timelineVisible = false
/// The left sidebar's tab; `fileTreeVisible` says whether it's showing.
@Published var navigatorTab: NavigatorTab = .files
+ /// The right sidebar's tab; `timelineVisible` says whether it's showing.
+ @Published var inspectorTab: InspectorTab = .commands
var selected: TerminalTab? { tabs.first { $0.id == selectedID } }
}
@@ -84,7 +86,7 @@ struct TabBar: View {
BarIconButton(systemImage: "plus", help: "New Tab (⌘T)", action: actions.newTab)
Spacer(minLength: 24)
BarIconButton(systemImage: "command", help: "Command Palette (⇧⌘P)", action: actions.showPalette)
- BarIconButton(systemImage: "sidebar.right", help: model.timelineVisible ? "Hide Commands (⇧⌘L)" : "Show Commands (⇧⌘L)",
+ BarIconButton(systemImage: "sidebar.right", help: model.timelineVisible ? "Hide Commands and Ports" : "Show Commands and Ports",
active: model.timelineVisible, action: actions.toggleTimeline)
}
.padding(.leading, model.fullScreen ? 10 : 84)
@@ -206,7 +208,6 @@ private struct TabButton: View {
@State private var hovering = false
var body: some View {
- let session = tab.focused.session
let attention = tab.panes.contains { $0.session.needsAttention }
let unseen = !selected && tab.panes.contains { $0.session.unseenOutput }
@@ -225,8 +226,8 @@ private struct TabButton: View {
.frame(width: 12)
if renaming {
- TabNameEditor(initialName: tab.customTitle ?? "", placeholder: tab.automaticTitle,
- onFinish: { actions.finishRenaming(tab, $0) })
+ InlineNameEditor(initialName: tab.customTitle ?? "", placeholder: tab.automaticTitle, label: "Tab name",
+ onFinish: { actions.finishRenaming(tab, $0) })
.frame(maxWidth: .infinity, alignment: .leading)
} else {
Text(tab.title)
@@ -283,7 +284,7 @@ private struct TabButton: View {
Divider()
Button("Close Tab") { actions.close(tab) }
}
- .help(tab.customTitle == nil ? session.abbreviatedDirectory : "\(tab.automaticTitle) — \(session.abbreviatedDirectory)")
+ .help(tab.toolTip)
.accessibilityElement(children: .combine)
.accessibilityAddTraits(selected ? [.isButton, .isSelected] : .isButton)
}
@@ -325,17 +326,20 @@ struct UsualProgressBar: View {
}
}
-/// Inline editor for a tab's name. Return saves, Escape cancels, clicking elsewhere saves.
-/// A blank name goes back to the automatic title, shown as the placeholder.
-private struct TabNameEditor: View {
+/// Inline editor for a name the user gives: a tab's, or a listening process's. Return saves, Escape
+/// cancels, clicking elsewhere saves. A blank name goes back to the automatic one, shown as the placeholder.
+struct InlineNameEditor: View {
let placeholder: String
+ /// What it's the name of, for VoiceOver: "Tab name".
+ let label: String
var onFinish: (String?) -> Void
@State private var text: String
@State private var finished = false
@FocusState private var focused: Bool
- init(initialName: String, placeholder: String, onFinish: @escaping (String?) -> Void) {
+ init(initialName: String, placeholder: String, label: String, onFinish: @escaping (String?) -> Void) {
self.placeholder = placeholder
+ self.label = label
self.onFinish = onFinish
_text = State(initialValue: initialName)
}
@@ -351,7 +355,7 @@ private struct TabNameEditor: View {
if !isFocused { finish(text) }
}
.task { focused = true }
- .accessibilityLabel("Tab name")
+ .accessibilityLabel(label)
}
private func finish(_ name: String?) {
diff --git a/Tests/CodecCoreTests/ListeningPortsTests.swift b/Tests/CodecCoreTests/ListeningPortsTests.swift
new file mode 100644
index 0000000..3291fae
--- /dev/null
+++ b/Tests/CodecCoreTests/ListeningPortsTests.swift
@@ -0,0 +1,229 @@
+import Testing
+import Foundation
+@testable import CodecCore
+
+@Suite("Listening ports")
+struct ListeningPortsTests {
+ /// A shell (10) running npm (11), which runs sh (12), which runs node (13) and a worker it forked
+ /// (14); and a job in the background (20).
+ private let children: [pid_t: [pid_t]] = [10: [11, 20], 11: [12], 12: [13], 13: [14]]
+ private let names: [pid_t: String] = [11: "npm", 12: "sh", 13: "node", 14: "node", 20: "python3.13"]
+ private let ports: [pid_t: [Int]] = [13: [9229, 3000], 14: [3000], 20: [8000]]
+ private let groups: [pid_t: pid_t] = [11: 11, 12: 11, 13: 11, 14: 11, 20: 20]
+
+ private func scan(foreground: pid_t?) -> [ListeningPort] {
+ ListeningPorts.scan(shell: 10, foregroundGroup: foreground,
+ descendants: { shell in
+ var found: [pid_t] = [], next = children[shell] ?? []
+ while !next.isEmpty {
+ found += next
+ next = next.flatMap { children[$0] ?? [] }
+ }
+ return found
+ },
+ ports: { ports[$0] ?? [] }, group: { groups[$0] }, name: { names[$0] })
+ }
+
+ @Test func findsWhatTheShellsProgramsListenOn() {
+ // Lowest first, each once, held by the process nearest the shell, and whether it's the command in front.
+ #expect(scan(foreground: 11) == [
+ ListeningPort(port: 3000, program: "node", pid: 13, foreground: true),
+ ListeningPort(port: 8000, program: "python3.13", pid: 20, foreground: false),
+ ListeningPort(port: 9229, program: "node", pid: 13, foreground: true),
+ ])
+ // At the prompt, the shell is in front: everything listening is a job in the background.
+ #expect(scan(foreground: 10).allSatisfy { !$0.foreground })
+ #expect(scan(foreground: nil).map(\.port) == [3000, 8000, 9229])
+ }
+
+ @Test func keepsAnAddressWhileTheSameProcessHoldsThePort() {
+ let known = [ListeningPort(port: 3000, program: "node", pid: 13, address: "http://localhost:3000/app"),
+ ListeningPort(port: 8000, program: "python", pid: 20, address: "http://localhost:8000")]
+ let scanned = [ListeningPort(port: 3000, program: "node", pid: 13), ListeningPort(port: 8000, program: "ruby", pid: 31)]
+ let merged = ListeningPorts.merging(scanned, keepingAddressesOf: known)
+ #expect(merged.map(\.address) == ["http://localhost:3000/app", nil])
+ }
+
+ @Test func aProcessGoesByTheNameItWasGiven() {
+ let ports = [ListeningPort(port: 3000, program: "node", pid: 13), ListeningPort(port: 9229, program: "node", pid: 13),
+ ListeningPort(port: 8000, program: "python", pid: 20)]
+ #expect(ports[0].displayName == "node")
+ // The process is named, so every port it has is.
+ let named = ListeningPorts.naming(ports[0], " Storefront ", in: ports)
+ #expect(named.map(\.name) == ["Storefront", "Storefront", nil])
+ #expect(named.map(\.displayName) == ["Storefront", "Storefront", "python"])
+ #expect(ListeningPorts.phrase(named, namingPrograms: true) == "listening on :3000 (Storefront), :9229 (Storefront) and :8000 (python)")
+ // It keeps its name while it runs, on ports it opens later too; another process in its place doesn't get it.
+ let later = [ListeningPort(port: 3000, program: "node", pid: 13), ListeningPort(port: 3001, program: "node", pid: 13),
+ ListeningPort(port: 8000, program: "python", pid: 21)]
+ #expect(ListeningPorts.merging(later, keepingAddressesOf: named).map(\.name) == ["Storefront", "Storefront", nil])
+ let restarted = [ListeningPort(port: 3000, program: "node", pid: 14)]
+ #expect(ListeningPorts.merging(restarted, keepingAddressesOf: named).map(\.name) == [nil])
+ // A blank name, or none, takes it away.
+ #expect(ListeningPorts.naming(named[1], " ", in: named).map(\.name) == [nil, nil, nil])
+ #expect(ListeningPorts.naming(named[0], nil, in: named).map(\.name) == [nil, nil, nil])
+ }
+
+ @Test func opensTheAddressPrintedOrPlainHTTP() {
+ #expect(ListeningPort(port: 3000, program: "node").url.absoluteString == "http://localhost:3000")
+ #expect(ListeningPort(port: 3000, program: "node", address: "https://localhost:3000/app").url.absoluteString
+ == "https://localhost:3000/app")
+ #expect(ListeningPort(port: 3000, program: "node").label == ":3000")
+ }
+
+ @Test func saysWhatIsListening() {
+ let ports = [3000, 3001, 5173, 9229].map { ListeningPort(port: $0, program: $0 == 5173 ? "vite" : "node") }
+ #expect(ListeningPorts.label(Array(ports.prefix(1))) == ":3000")
+ #expect(ListeningPorts.label(Array(ports.prefix(3))) == ":3000 :3001 :5173")
+ #expect(ListeningPorts.label(ports) == ":3000 :3001 :5173 +1")
+ #expect(ListeningPorts.phrase(Array(ports.prefix(1))) == "listening on :3000")
+ #expect(ListeningPorts.phrase(Array(ports.prefix(2)), capitalized: true) == "Listening on :3000 and :3001")
+ #expect(ListeningPorts.phrase(Array(ports.prefix(3))) == "listening on :3000, :3001 and :5173")
+ #expect(ListeningPorts.phrase(Array(ports.suffix(2)), namingPrograms: true) == "listening on :5173 (vite) and :9229 (node)")
+ }
+
+ @Test func findsTheAddressAProgramAnnounced() {
+ let vite = """
+ VITE v7.1.0 ready in 212 ms
+
+ ➜ Local: http://localhost:5173/app/
+ ➜ Network: http://192.168.1.20:5173/app/
+ """
+ #expect(ListeningPorts.address(for: 5173, in: vite) == "http://localhost:5173/app/")
+ #expect(ListeningPorts.address(for: 3000, in: vite) == nil)
+ // The first one printed, without the sentence's punctuation.
+ #expect(ListeningPorts.address(for: 3000, in: "Ready on https://127.0.0.1:3000. GET http://localhost:3000/api/users 200")
+ == "https://127.0.0.1:3000")
+ #expect(ListeningPorts.address(for: 8080, in: "(see http://[::1]:8080/docs?tab=1#top)") == "http://[::1]:8080/docs?tab=1#top")
+ #expect(ListeningPorts.address(for: 4000, in: "Serving at http://api.myapp.localhost:4000") == "http://api.myapp.localhost:4000")
+ // Every interface: a browser opens it as localhost.
+ #expect(ListeningPorts.address(for: 8000, in: "Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)")
+ == "http://localhost:8000")
+ #expect(ListeningPorts.address(for: 8000, in: "listening on http://[::]:8000/") == "http://localhost:8000/")
+ // Another port, another machine, or no address at all.
+ #expect(ListeningPorts.address(for: 300, in: "http://localhost:3000") == nil)
+ #expect(ListeningPorts.address(for: 3000, in: "proxying to http://api.example.com:3000") == nil)
+ #expect(ListeningPorts.address(for: 3000, in: "Listening on port 3000") == nil)
+ }
+
+ @Test func readsTheSocketsOfAProcess() throws {
+ let descriptor = socket(AF_INET, SOCK_STREAM, 0)
+ try #require(descriptor >= 0)
+ defer { close(descriptor) }
+ var address = sockaddr_in()
+ address.sin_len = UInt8(MemoryLayout.size)
+ address.sin_family = sa_family_t(AF_INET)
+ address.sin_addr.s_addr = inet_addr("127.0.0.1")
+ var length = socklen_t(MemoryLayout.size)
+ let bound = withUnsafeMutablePointer(to: &address) {
+ $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { pointer in
+ bind(descriptor, pointer, length) == 0 && getsockname(descriptor, pointer, &length) == 0
+ }
+ }
+ try #require(bound)
+ let port = Int(UInt16(bigEndian: address.sin_port))
+ // Bound isn't listening yet.
+ #expect(!ProcessInspector.listeningPorts(of: getpid()).contains(port))
+ try #require(listen(descriptor, 1) == 0)
+ #expect(ProcessInspector.listeningPorts(of: getpid()).contains(port))
+ #expect(ProcessInspector.listeningPorts(of: -1).isEmpty)
+ #expect(ProcessInspector.processGroup(of: getpid()) == getpgrp())
+ #expect(ProcessInspector.processGroup(of: -1) == nil)
+ }
+
+ @Test func findsTheProcessesAProcessStartedAndTheirs() throws {
+ // A shell that starts a sleep and waits for it: a child, and a child of that child.
+ let shell = Process()
+ shell.executableURL = URL(fileURLWithPath: "/bin/sh")
+ shell.arguments = ["-c", "sleep 20 & wait"]
+ try shell.run()
+ var grandchildren: [pid_t] = []
+ for _ in 0..<100 where grandchildren.isEmpty {
+ grandchildren = ProcessInspector.children(of: shell.processIdentifier)
+ if grandchildren.isEmpty { Thread.sleep(forTimeInterval: 0.05) }
+ }
+ defer {
+ shell.terminate()
+ grandchildren.forEach { kill($0, SIGTERM) }
+ }
+ try #require(!grandchildren.isEmpty)
+ let found = ProcessInspector.descendants(of: getpid())
+ // Nearest first.
+ let child = try #require(found.firstIndex(of: shell.processIdentifier))
+ let grandchild = try #require(found.firstIndex(of: grandchildren[0]))
+ #expect(child < grandchild)
+ #expect(!ProcessInspector.children(of: getpid()).contains(grandchildren[0]))
+ #expect(ProcessInspector.descendants(of: getpid(), limit: 1).count == 1)
+ #expect(ProcessInspector.descendants(of: -1).isEmpty)
+ }
+}
+
+@Suite("Port conflicts")
+struct PortConflictTests {
+ @Test(arguments: [
+ ("Error: listen EADDRINUSE: address already in use :::3000", "npm run dev", 3000),
+ ("[12:30:45] Error: listen EADDRINUSE: address already in use 127.0.0.1:8080", "node server.js", 8080),
+ ("2026/11/12 10:02:11 listen tcp :8081: bind: address already in use", "go run .", 8081),
+ (#"Address already in use - bind(2) for "127.0.0.1" port 3000 (Errno::EADDRINUSE)"#, "bin/rails server", 3000),
+ ("ERROR: [Errno 48] error while attempting to bind on address ('127.0.0.1', 8000): address already in use",
+ "uvicorn main:app", 8000),
+ ("nginx: [emerg] bind() to 0.0.0.0:8443 failed (48: Address already in use)", "nginx", 8443),
+ ("Address already in use\nPort 5000 is in use by another program. Either identify and stop that program, or start the server with a different port.",
+ "flask run", 5000),
+ ("Error: That port is already in use.", "python manage.py runserver 8001", 8001),
+ // The error names no port: the command does.
+ ("OSError: [Errno 48] Address already in use", "python3 -m http.server 8000", 8000),
+ ("nc: Address already in use", "nc -l 127.0.0.1 47631", 47631),
+ ("java.net.BindException: Address already in use", "java -jar app.jar --server.port=9090", 9090),
+ (#"Error: Os { code: 48, kind: AddrInUse, message: "Address already in use" }"#, "PORT=4000 cargo run", 4000),
+ ("Error: bind: address already in use", "caddy file-server --listen localhost:2015", 2015),
+ ("bind: Address already in use", "docker-proxy -p 8088:80", 8088),
+ ])
+ func findsThePortACommandFoundTaken(output: String, command: String, port: Int) {
+ #expect(PortConflict.port(inOutput: "Starting…\n" + output + "\n at Server.setupListenHandle (node:net:1908:16)",
+ command: command) == port)
+ }
+
+ @Test func saysNothingWhenItCantTell() {
+ // Not about a port.
+ #expect(PortConflict.port(inOutput: "ls: nope: No such file or directory", command: "ls nope --port 3000") == nil)
+ #expect(PortConflict.port(inOutput: "error: the file is in use by another process", command: "cp a b") == nil)
+ // About one, but which?
+ #expect(PortConflict.port(inOutput: "OSError: [Errno 48] Address already in use", command: "python3 -m http.server") == nil)
+ #expect(PortConflict.port(inOutput: "bind: Address already in use", command: "relay 8000 9000") == nil)
+ #expect(PortConflict.port(inOutput: "Error: listen EADDRINUSE: address already in use :::99999", command: "node x.js") == nil)
+ }
+
+ @Test func saysWhoHasThePort() {
+ let conflict = PortConflict(port: 3000, program: "node", pane: "A")
+ #expect(conflict.sentence() == "Port 3000 is in use by “node”")
+ #expect(conflict.sentence(place: "Tab 2 › left pane") == "Port 3000 is in use by “node” in Tab 2 › left pane")
+ #expect(conflict.sentence(stillHeld: false, place: "Tab 2") == "Port 3000 was in use by “node”")
+ }
+
+ @Test func aTimelineRemembersIt() throws {
+ var timeline = CommandTimeline()
+ let id = timeline.begin(command: "npm run dev", directory: "/tmp", started: Date(timeIntervalSince1970: 0), block: nil)
+ timeline.finish(exitCode: 1, duration: 1, output: CapturedOutput(lines: ["Error: listen EADDRINUSE :::3000"]))
+ let conflict = PortConflict(port: 3000, program: "node", pane: "A")
+ timeline.setPortConflict(conflict, of: id)
+ #expect(timeline.entry(id: id)?.portConflict == conflict)
+ // Kept with the pane's commands, and read back; older ones saved without it still read.
+ let data = try JSONEncoder().encode(timeline)
+ #expect(try JSONDecoder().decode(CommandTimeline.self, from: data).entry(id: id)?.portConflict == conflict)
+ var plain = CommandTimeline()
+ plain.begin(command: "ls", directory: "/tmp", started: Date(timeIntervalSince1970: 0), block: nil)
+ let old = try JSONDecoder().decode(CommandTimeline.self, from: JSONEncoder().encode(plain))
+ #expect(old.entries.first?.portConflict == nil)
+ }
+
+ @Test func explainingIsToldWhoHasThePort() throws {
+ var entry = TimelineEntry(id: 1, command: "npm run dev", directory: "/tmp/web", started: Date(timeIntervalSince1970: 0), block: nil)
+ entry.exitCode = 1
+ #expect(FailureReport(entry: entry, output: "EADDRINUSE")?.prompt.contains("Known:") == false)
+ entry.portConflict = PortConflict(port: 3000, program: "node", pane: "A")
+ let report = try #require(FailureReport(entry: entry, output: "EADDRINUSE"))
+ #expect(report.known == "Port 3000 is in use by “node”, a program running in one of this terminal’s panes.")
+ #expect(report.prompt.contains("Exit status: 1\nKnown: Port 3000 is in use by “node”, a program running in one of this terminal’s panes.\nOutput:\nEADDRINUSE"))
+ }
+}
diff --git a/Tests/CodecCoreTests/SplitLayoutTests.swift b/Tests/CodecCoreTests/SplitLayoutTests.swift
index 7ac7267..4285202 100644
--- a/Tests/CodecCoreTests/SplitLayoutTests.swift
+++ b/Tests/CodecCoreTests/SplitLayoutTests.swift
@@ -114,6 +114,24 @@ struct SplitLayoutTests {
#expect(SplitLayout.leaf(a).leaf(after: b) === a)
}
+ @Test func saysWhereALeafIs() {
+ #expect(SplitLayout.leaf(a).position(of: a) == nil)
+ let sideBySide = SplitLayout.split(.horizontal, [.leaf(a), .leaf(b), .leaf(c)])
+ #expect([a, b, c].map { sideBySide.position(of: $0) } == ["left", "middle", "right"])
+ #expect(sideBySide.position(of: d) == nil)
+ let stacked = SplitLayout.split(.vertical, [.leaf(a), .leaf(b)])
+ #expect([a, b].map { stacked.position(of: $0) } == ["top", "bottom"])
+ // A pane on the left, and two stacked on the right.
+ let nested = SplitLayout.split(.horizontal, [.leaf(a), .split(.vertical, [.leaf(b), .leaf(c)])])
+ #expect([a, b, c].map { nested.position(of: $0) } == ["left", "top right", "bottom right"])
+ // Deeper on the same side, the outermost split along each axis says where.
+ let deep = SplitLayout.split(.horizontal, [.leaf(a), .split(.vertical, [.leaf(b), .split(.horizontal, [.leaf(c), .leaf(d)])])])
+ #expect([c, d].map { deep.position(of: $0) } == ["bottom right", "bottom right"])
+ let grid = SplitLayout.split(.vertical, [.leaf(a), .split(.horizontal, [.leaf(b), .leaf(c), .leaf(d)]), .leaf(Leaf("e"))])
+ #expect(grid.position(of: c) == "middle")
+ #expect(grid.position(of: b) == "middle left")
+ }
+
@Test func sameShapeComparesLeavesByIdentityAndStructure() {
let one = SplitLayout.leaf(a).splitting(a, with: b, axis: .horizontal)
let same = SplitLayout.leaf(a).splitting(a, with: b, axis: .horizontal)
diff --git a/Tests/CodecCoreTests/SystemTests.swift b/Tests/CodecCoreTests/SystemTests.swift
index 42a2f11..9b17bd5 100644
--- a/Tests/CodecCoreTests/SystemTests.swift
+++ b/Tests/CodecCoreTests/SystemTests.swift
@@ -156,6 +156,16 @@ struct RunningProgramsTests {
#expect(RunningPrograms.list(["make", "npm", "ssh"]) == "“make”, “npm” and “ssh”")
}
+ @Test func saysWhatTheProgramsAreListeningOn() {
+ let node = [ListeningPort(port: 3000, program: "node"), ListeningPort(port: 9229, program: "node")]
+ // The server is named even when the shell only knows what started it.
+ #expect(RunningPrograms.programs(["npm"], listening: node) == ["npm", "node"])
+ #expect(RunningPrograms.list(["npm"], listening: node) == "“npm” and “node” (listening on :3000 and :9229)")
+ #expect(RunningPrograms.list(["node"], listening: Array(node.prefix(1))) == "“node” (listening on :3000)")
+ #expect(RunningPrograms.list(["make", "nc", "ssh"], listening: [ListeningPort(port: 8080, program: "nc")])
+ == "“make”, “nc” (listening on :8080) and “ssh”")
+ }
+
@Test func findsChildProcesses() throws {
let child = Process()
child.executableURL = URL(fileURLWithPath: "/bin/sleep")
diff --git a/Tests/CodecUITests/ListeningPortsUITests.swift b/Tests/CodecUITests/ListeningPortsUITests.swift
new file mode 100644
index 0000000..45712aa
--- /dev/null
+++ b/Tests/CodecUITests/ListeningPortsUITests.swift
@@ -0,0 +1,401 @@
+import Testing
+import Foundation
+import AppKit
+import SwiftUI
+import CodecCore
+@testable import CodecUI
+
+/// The ports a pane's programs listen on, with real shells and `nc` listening for real.
+@Suite("Listening ports", .serialized, .timeLimit(.minutes(2)),
+ .enabled(if: FileManager.default.isExecutableFile(atPath: "/usr/bin/nc")))
+@MainActor
+struct ListeningPortsUITests {
+ @Test func followsWhatAPanesProgramsListenOn() async throws {
+ let env = try TestEnvironment()
+ defer { env.tearDown() }
+ let pane = env.makePane(host: FakeHost())
+ defer { pane.shutDown() }
+ let window = makeTestWindow()
+ window.contentView = pane.view
+ pane.view.layoutSubtreeIfNeeded()
+ var opened: [URL] = []
+ pane.openURL = { opened.append($0) }
+ #expect(await waitUntil { pane.terminal.process.running })
+ #expect(pane.session.listeningPorts.isEmpty)
+
+ // In the background, so it isn't the command in front; the pane finds it by itself.
+ let port = try freePort()
+ pane.terminal.send(txt: "nc -l 127.0.0.1 \(port) 0 && widths[2] > widths[1])
+ }
+
+ @Test func theWindowMenuListsEveryPaneThatsListening() async throws {
+ let env = try TestEnvironment()
+ defer { env.tearDown() }
+ let menu = ListeningPortsMenu(directory: env.environment.panes)
+ #expect(menu.items().map(\.title) == ["No Listening Ports"])
+ #expect(menu.items().first?.action == nil)
+
+ // Two windows, a pane listening in each.
+ let front = FakeHost(), back = FakeHost()
+ front.windowTitle = "web"
+ back.windowTitle = "api"
+ back.place = "Tab 2 › left pane"
+ let panes = [env.makePane(host: front), env.makePane(host: back)]
+ defer { panes.forEach { $0.shutDown() } }
+ var opened: [URL] = []
+ panes[1].openURL = { opened.append($0) }
+ let ports = [try freePort(), try freePort()]
+ for (pane, port) in zip(panes, ports) {
+ #expect(await waitUntil { pane.terminal.process.running })
+ pane.terminal.send(txt: "nc -l 127.0.0.1 \(port) new(Listen => 1, LocalAddr => "127.0.0.1", LocalPort => $ARGV[0]) or die "bind: $!\n";
+ sleep 30;
+ """.write(to: env.directory.appendingPathComponent("listen.pl"), atomically: true, encoding: .utf8)
+ let controller = TerminalWindowController(directory: env.directory.path, environment: env.environment)
+ controller.window?.setContentSize(NSSize(width: 1100, height: 600))
+ defer {
+ controller.model.tabs.flatMap(\.panes).forEach { $0.shutDown() }
+ controller.window?.close()
+ }
+ let tab = try #require(controller.model.selected)
+ let server = tab.focused
+ #expect(await waitUntil { server.isAtCommandLine })
+ #expect(controller.place(of: server) == "Tab 1")
+
+ // A server in the first pane, in the foreground.
+ let port = try freePort()
+ server.terminal.send(txt: "nc -l 127.0.0.1 \(port)\r")
+ #expect(await waitUntil { server.session.listeningPorts.map(\.port) == [port] })
+ #expect(server.session.listeningPorts.first?.foreground == true)
+ #expect(tab.toolTip.hasSuffix("\nListening on :\(port) (nc)"))
+ #expect(controller.allPanes.running.list == "“nc” (listening on :\(port))")
+
+ controller.toggleCommandTimeline(nil)
+ controller.window?.layoutIfNeeded()
+ let sidebar = controller.timelineSidebar!
+ let runningRow = try #require(sidebar.rows.lastIndex { $0.entry?.isRunning == true })
+ #expect(await waitUntil {
+ (sidebar.table.view(atColumn: 0, row: runningRow, makeIfNecessary: true) as? TimelineCell)?.shown.detail == "Listening on :\(port)"
+ })
+
+ // The same in a second pane: refused, and Codec knows who has the port.
+ controller.splitRight(nil)
+ let client = tab.focused
+ #expect(client !== server)
+ #expect(controller.place(of: server) == "Tab 1 › left pane")
+ #expect(await waitUntil { client.isAtCommandLine })
+
+ // The command palette knows the way to the server, and what it serves.
+ var opened: [URL] = []
+ server.openURL = { opened.append($0) }
+ let commands = controller.paletteCommands()
+ let goTo = try #require(commands.first { $0.title == "Go to :\(port) — nc, Tab 1 › left pane" })
+ goTo.action()
+ #expect(tab.focused === server)
+ try #require(commands.first { $0.title == "Open http://localhost:\(port) in the Browser" }).action()
+ #expect(opened.map(\.absoluteString) == ["http://localhost:\(port)"])
+ tab.focus(client)
+
+ client.terminal.send(txt: "perl listen.pl \(port)\r")
+ #expect(await waitUntil { client.timeline.entries.last?.portConflict != nil })
+ let failed = try #require(client.timeline.entries.last)
+ #expect(failed.portConflict == PortConflict(port: port, program: "nc", pane: server.session.id.uuidString))
+ let status = try #require(client.portConflictStatus(of: failed))
+ #expect(status.text == "Port \(port) is in use by “nc” in Tab 1 › left pane")
+ #expect(status.holder === server)
+
+ // Its row says so, with the way there.
+ #expect(sidebar.pane === client)
+ let row = try #require(sidebar.rows.firstIndex { $0.entry?.id == failed.id })
+ #expect(await waitUntil { sidebar.tableView(sidebar.table, heightOfRow: row) == TimelineCell.heightSayingWhyAndWhere })
+ let cell = try #require(sidebar.table.view(atColumn: 0, row: row, makeIfNecessary: true) as? TimelineCell)
+ #expect(cell.shown.detail == status.text)
+ #expect(cell.offersGoToPane)
+ #expect(sidebar.contextMenuItems(for: failed, row: row).map(\.title).contains("Go to Pane Listening on :\(port)"))
+
+ // So does its output, and what's sent to explain it.
+ sidebar.showOutput(of: failed, row: row)
+ #expect(sidebar.outputViewer?.portConflict?.text == status.text)
+ #expect(sidebar.outputViewer?.explanation.report?.known.hasPrefix("Port \(port) is in use by “nc”") == true)
+
+ cell.pressGoToPane()
+ #expect(tab.focused === server)
+ #expect(!sidebar.isShowingOutput)
+
+ // Once the server is stopped, there's nowhere to go.
+ server.terminal.send(txt: "\u{03}")
+ #expect(await waitUntil { server.session.listeningPorts.isEmpty })
+ let after = try #require(client.portConflictStatus(of: failed))
+ #expect(after.text == "Port \(port) was in use by “nc”" && after.holder == nil)
+ tab.focus(client)
+ #expect(await waitUntil { sidebar.pane === client })
+ let rowAfter = try #require(sidebar.rows.firstIndex { $0.entry?.id == failed.id })
+ #expect(sidebar.tableView(sidebar.table, heightOfRow: rowAfter) == TimelineCell.heightSayingWhy)
+ let cellAfter = try #require(sidebar.table.view(atColumn: 0, row: rowAfter, makeIfNecessary: true) as? TimelineCell)
+ #expect(cellAfter.shown.detail == after.text && !cellAfter.offersGoToPane)
+ #expect(!sidebar.contextMenuItems(for: failed, row: rowAfter).map(\.title).contains { $0.hasPrefix("Go to Pane") })
+ }
+
+ @Test func aPortHeldInTheSamePaneOrAnotherWindowSaysSo() async throws {
+ let env = try TestEnvironment()
+ defer { env.tearDown() }
+ let host = FakeHost(), other = FakeHost()
+ other.place = "Tab 3 › top pane"
+ let pane = env.makePane(host: host), elsewhere = env.makePane(host: other)
+ defer { [pane, elsewhere].forEach { $0.shutDown() } }
+ var entry = TimelineEntry(id: 1, command: "npm run dev", directory: "/tmp", started: Date(), block: nil)
+ entry.exitCode = 1
+ #expect(pane.portConflictStatus(of: entry) == nil)
+
+ entry.portConflict = PortConflict(port: 3000, program: "node", pane: pane.session.id.uuidString)
+ // Nothing listens: it has let go of the port since.
+ #expect(pane.portConflictStatus(of: entry)?.text == "Port 3000 was in use by “node”")
+ pane.session.listeningPorts = [ListeningPort(port: 3000, program: "node")]
+ let here = try #require(pane.portConflictStatus(of: entry))
+ #expect(here.text == "Port 3000 is in use by “node” in this pane" && here.holder == nil)
+
+ entry.portConflict = PortConflict(port: 3000, program: "node", pane: elsewhere.session.id.uuidString)
+ elsewhere.session.listeningPorts = [ListeningPort(port: 3000, program: "node")]
+ let there = try #require(pane.portConflictStatus(of: entry))
+ #expect(there.text == "Port 3000 is in use by “node” in another window, Tab 3 › top pane")
+ #expect(there.holder === elsewhere)
+
+ // A pane that's gone, as after quitting and coming back.
+ entry.portConflict = PortConflict(port: 3000, program: "node", pane: UUID().uuidString)
+ #expect(pane.portConflictStatus(of: entry)?.holder == nil)
+ }
+}
diff --git a/Tests/CodecUITests/ModelTests.swift b/Tests/CodecUITests/ModelTests.swift
index f58ff51..048f4d5 100644
--- a/Tests/CodecUITests/ModelTests.swift
+++ b/Tests/CodecUITests/ModelTests.swift
@@ -196,4 +196,25 @@ struct MainMenuTests {
#expect(hidden.map(\.keyEquivalent) == [key(NSRightArrowFunctionKey), key(NSDownArrowFunctionKey)])
#expect(hidden.allSatisfy { $0.keyEquivalentModifierMask == [.command, .control] && $0.allowsKeyEquivalentWhenHidden })
}
+
+ @Test func theViewMenuShowsListeningPorts() throws {
+ _ = NSApplication.shared
+ let entry = try #require(item(MainMenu.build(), path: ["View", "Listening Ports"]))
+ #expect(entry.keyEquivalent == "o" && entry.keyEquivalentModifierMask == [.command, .shift])
+ #expect(entry.action == #selector(TerminalWindowController.togglePorts(_:)))
+ }
+
+ @Test func theWindowMenuListsListeningPorts() throws {
+ let env = try TestEnvironment()
+ defer { env.tearDown() }
+ let ports = ListeningPortsMenu(directory: env.environment.panes)
+ let menu = MainMenu.build(listeningPorts: ports)
+ let submenu = try #require(item(menu, path: ["Window", "Listening Ports"])?.submenu)
+ // Filled in as it opens, by the object the app keeps for that.
+ #expect(submenu.delegate === ports)
+ submenu.delegate?.menuNeedsUpdate?(submenu)
+ submenu.update()
+ #expect(submenu.items.map(\.title) == ["No Listening Ports"])
+ #expect(submenu.items.first?.isEnabled == false)
+ }
}
diff --git a/Tests/CodecUITests/ScreenshotTour.swift b/Tests/CodecUITests/ScreenshotTour.swift
index 355e403..50e732f 100644
--- a/Tests/CodecUITests/ScreenshotTour.swift
+++ b/Tests/CodecUITests/ScreenshotTour.swift
@@ -47,18 +47,25 @@ struct ScreenshotTour {
defer { Self.windowsSayTheyreVisible(false) }
NSApplication.shared.setActivationPolicy(.regular)
- try await hero(env, project: project)
- try await intel(env, project: project)
- try await history(env, project: project)
- try await editor(env, project: project)
- try await openFile(env, project: project)
- try await listing(env, project: project)
- try await branches(env, project: project)
- try await themes(env, project: project)
- try await usualDuration(env, project: project)
- try await environments(env, project: project)
- try await environmentList(env, project: project)
- try await environmentSettings(env)
+ let shots: [(name: String, take: () async throws -> Void)] = [
+ ("hero", { try await hero(env, project: project) }),
+ ("intel", { try await intel(env, project: project) }),
+ ("history", { try await history(env, project: project) }),
+ ("editor", { try await editor(env, project: project) }),
+ ("open-file", { try await openFile(env, project: project) }),
+ ("listing", { try await listing(env, project: project) }),
+ ("branches", { try await branches(env, project: project) }),
+ ("themes", { try await themes(env, project: project) }),
+ ("usual-duration", { try await usualDuration(env, project: project) }),
+ ("environments", { try await environments(env, project: project) }),
+ ("environment-list", { try await environmentList(env, project: project) }),
+ ("environment-settings", { try await environmentSettings(env) }),
+ ("ports", { try await ports(env, project: project, home: home) }),
+ ]
+ // Only the shots asked for, so a change to one feature doesn't retake every image:
+ // `CODEC_SHOT=ports make screenshots`, or several, `CODEC_SHOT=hero,ports`.
+ let only = ProcessInfo.processInfo.environment["CODEC_SHOT"]?.split(separator: ",").map(String.init)
+ for shot in shots where only?.contains(shot.name) != false { try await shot.take() }
}
/// Swaps `NSWindow.occlusionState` for one that's always visible, or back.
@@ -223,6 +230,40 @@ struct ScreenshotTour {
try await waitFor { pane.session.running == nil }
}
+ /// A dev server running: its port in the status bar and on its row. Then the same command in a
+ /// second pane, refused the port: its row says which pane has it. Then the list of what's listening.
+ private func ports(_ env: TestEnvironment, project: URL, home: URL) async throws {
+ try makeDevServer(home)
+ let (controller, pane) = try await window(env, project: project, size: NSSize(width: 1360, height: 600))
+ defer { close(controller) }
+ controller.toggleCommandTimeline(nil)
+ try await run("git status --short", in: pane)
+ try await settle(seconds: 0.3)
+ pane.terminal.send(txt: "npm run dev\r")
+ try await waitFor { pane.session.listeningPorts.first?.address != nil }
+ controller.window?.makeFirstResponder(pane.view.preferredResponder)
+ try await settle(seconds: 4)
+ try await capture(controller.window!, as: "ports")
+
+ controller.splitRight(nil)
+ let right = try #require(controller.model.selected?.focused)
+ try await waitFor { right.isAtCommandLine }
+ try await settle(seconds: 0.3)
+ right.terminal.send(txt: "npm run dev\r")
+ try await waitFor { right.timeline.entries.last?.portConflict != nil && right.isAtCommandLine }
+ try await settle()
+ try await capture(controller.window!, as: "port-in-use")
+
+ // Another server beside it, the first one given a name: the Ports tab lists both.
+ try await settle(seconds: 0.3)
+ right.terminal.send(txt: "PORT=8080 node api/server.js\r")
+ try await waitFor { right.session.listeningPorts.first?.address != nil }
+ pane.name(try #require(pane.session.listeningPorts.first), as: "Storefront")
+ controller.togglePorts(nil)
+ try await settle(seconds: 3)
+ try await capture(controller.window!, as: "ports-list")
+ }
+
/// Going into a project with a Python environment and mise tools: the question whether to
/// activate it, and the versions in the status bar, one of them not installed.
private func environments(_ env: TestEnvironment, project: URL) async throws {
@@ -461,6 +502,66 @@ struct ScreenshotTour {
try #require(process.terminationStatus == 0, "\(command) failed")
}
+ /// Stand-ins for `npm run dev` and the Node server it starts: a program called `node` that listens
+ /// for real and says where, as a server does, or fails the way Node does when the port is taken.
+ /// It's compiled here, as a program goes by its file's own name, whatever links to it.
+ private func makeDevServer(_ home: URL) throws {
+ let bin = home.appendingPathComponent("bin")
+ try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true)
+ let source = home.appendingPathComponent("server.c")
+ try #"""
+ #include
+ #include
+ #include
+ #include
+
+ int main(int argc, char **argv) {
+ int port = getenv("PORT") ? atoi(getenv("PORT")) : argc > 1 ? atoi(argv[1]) : 3000;
+ int descriptor = socket(AF_INET, SOCK_STREAM, 0);
+ struct sockaddr_in address = {0};
+ address.sin_family = AF_INET;
+ address.sin_port = htons(port);
+ address.sin_addr.s_addr = inet_addr("127.0.0.1");
+ if (bind(descriptor, (struct sockaddr *)&address, sizeof address) != 0 || listen(descriptor, 8) != 0) {
+ printf("node:events:497\n throw er; // Unhandled 'error' event\n ^\n\n"
+ "Error: listen EADDRINUSE: address already in use 127.0.0.1:%d\n"
+ " at Server.setupListenHandle [as _listen2] (node:net:1908:16)\n"
+ " at listenInCluster (node:net:1965:12)\n"
+ " at Server.listen (node:net:2067:7) {\n"
+ " code: 'EADDRINUSE',\n syscall: 'listen',\n address: '127.0.0.1',\n port: %d\n}\n\n"
+ "Node.js v24.12.0\n", port, port);
+ return 1;
+ }
+ printf("Server ready on http://localhost:%d\n", port);
+ fflush(stdout);
+ for (;;) pause();
+ }
+
+ """#.write(to: source, atomically: true, encoding: .utf8)
+ try shell("cc -o '\(bin.path)/node' '\(source.path)' && rm '\(source.path)'", in: home)
+ // A port nothing else on this Mac has, so the first server gets it.
+ let port = [3000, 4000, 5173, 8080, 8000].first { port in
+ let descriptor = socket(AF_INET, SOCK_STREAM, 0)
+ defer { Darwin.close(descriptor) }
+ var address = sockaddr_in()
+ address.sin_len = UInt8(MemoryLayout.size)
+ address.sin_family = sa_family_t(AF_INET)
+ address.sin_port = in_port_t(port).bigEndian
+ address.sin_addr.s_addr = inet_addr("127.0.0.1")
+ return withUnsafePointer(to: &address) {
+ $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { bind(descriptor, $0, socklen_t(MemoryLayout.size)) == 0 }
+ }
+ } ?? 3000
+ let npm = bin.appendingPathComponent("npm")
+ try #"""
+ #!/bin/sh
+ printf '\n> storefront@1.4.0 dev\n> node server.js\n\n'
+ exec "$(dirname "$0")/node" \#(port)
+
+ """#.write(to: npm, atomically: true, encoding: .utf8)
+ try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: npm.path)
+ }
+
/// A project with a uv environment and tools from mise, and a `mise` that reports them there.
private func makeStorefront(_ home: URL) throws {
let store = home.appendingPathComponent("Developer/storefront")
@@ -548,6 +649,12 @@ struct ScreenshotTour {
try (zshrc + "\n").write(to: home.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8)
try makeStorefront(home)
try shell("git clone --quiet '\(Self.repository.path)' '\(project.path)'", in: home)
+ // The clone is on whatever branch this checkout is on. Taken from a feature branch, as the shots
+ // of a new feature are, that branch would show in `git log`: it goes by `main` instead.
+ try shell("""
+ b=$(git branch --show-current); [ "$b" = main ] || \
+ { git branch -M main && git update-ref -d "refs/remotes/origin/$b" && git remote set-head origin main; }
+ """, in: project)
try shell("""
git config user.name 'Solid Snake' && git config user.email snake@example.com && \
git branch feature/intel-folders && git branch fix/history-ranking && git branch release/1.2 && \
diff --git a/Tests/CodecUITests/TestSupport.swift b/Tests/CodecUITests/TestSupport.swift
index a54609b..779406e 100644
--- a/Tests/CodecUITests/TestSupport.swift
+++ b/Tests/CodecUITests/TestSupport.swift
@@ -59,6 +59,31 @@ final class FakeHost: PaneHost {
func explainFailure(of entry: TimelineEntry, in pane: Pane) { explained.append(entry) }
private(set) var openedFiles: [URL] = []
func openFile(_ url: URL, from pane: Pane) { openedFiles.append(url) }
+ /// Where the host says its panes are.
+ var place: String? = "Tab 1"
+ func place(of pane: Pane) -> String? { place }
+ private(set) var revealed: [Pane] = []
+ func reveal(_ pane: Pane) { revealed.append(pane) }
+ var windowTitle: String?
+}
+
+/// A TCP port nothing is listening on, as far as can be told: the system picked it a moment ago.
+func freePort() throws -> Int {
+ let descriptor = socket(AF_INET, SOCK_STREAM, 0)
+ guard descriptor >= 0 else { throw POSIXError(.EMFILE) }
+ defer { close(descriptor) }
+ var address = sockaddr_in()
+ address.sin_len = UInt8(MemoryLayout.size)
+ address.sin_family = sa_family_t(AF_INET)
+ address.sin_addr.s_addr = inet_addr("127.0.0.1")
+ var length = socklen_t(MemoryLayout.size)
+ let bound = withUnsafeMutablePointer(to: &address) {
+ $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { pointer in
+ bind(descriptor, pointer, length) == 0 && getsockname(descriptor, pointer, &length) == 0
+ }
+ }
+ guard bound else { throw POSIXError(.EADDRINUSE) }
+ return Int(UInt16(bigEndian: address.sin_port))
}
/// Never reports anything, so tests don't depend on the machine's load.
diff --git a/docs/commands-sidebar.md b/docs/commands-sidebar.md
index 1b62092..dbe7707 100644
--- a/docs/commands-sidebar.md
+++ b/docs/commands-sidebar.md
@@ -4,7 +4,7 @@
You tried a few things, went on for a while, and now you want to see what happened earlier. The Commands sidebar is that pane's story: every command it ran, with how it went and what it printed.
-Open it with ⇧⌘L, or the sidebar button at the right end of the tab bar. It follows the focused pane, like the file tree.
+Open it with ⇧⌘L, or the sidebar button at the right end of the tab bar. It follows the focused pane, like the file tree. It shares the right sidebar with the **Ports** tab (⇧⌘O), which lists [what's listening](prompt-and-status-bar.md#every-port-in-one-place) in every pane.
## What it shows
@@ -12,7 +12,7 @@ Commands are listed oldest first, under the folder they ran in. When a [Python e
- a green or red dot for whether it worked (pulsing while it runs),
- when it ran and how long it took,
-- the last line it printed (`Build complete!`, `3 tests failed`), or the exit status of a failed one.
+- the last line it printed (`Build complete!`, `3 tests failed`), or the exit status of a failed one. While a command is running a server, its row says so instead: `Listening on :3000` (see [listening ports](prompt-and-status-bar.md#listening-ports)).
## How long it usually takes
@@ -56,13 +56,25 @@ When the fix is a command (`git push --set-upstream origin feature/login`, `npm
Turn it off in Settings › Shell › Offer to explain failed commands.
+## Port already in use
+
+You start the dev server and it dies with `EADDRINUSE`: something already has the port, and it's usually the same server, left running in a pane you forgot. Codec knows what [each pane's programs listen on](prompt-and-status-bar.md#listening-ports), so it can say which.
+
+
+
+When a command fails saying the address or port is in use, and a program in one of Codec's panes holds that port, the command's row says so: "Port 3000 is in use by “node” in Tab 2 › left pane", with **Go to Pane**, which brings that pane to the front, in another window if that's where it is. The output viewer says the same, and so does right-click › Go to Pane Listening on :3000. [Explain](#why-did-it-fail) is told too, so its answer starts from who has the port rather than guessing.
+
+- The port comes from the error (`address already in use :::3000`, `bind(2) for "127.0.0.1" port 3000`), or from the command when the error names none (`python -m http.server 8000`).
+- Once the other program lets go of the port, the row says it *was* in use, and there's no pane to go to.
+- A port held by something Codec didn't start (another app, a container, a system service) gets no such line: the row shows the command's last line, as usual.
+
## Filtering
The filter field searches commands and what they printed. A row shows the line that matched, and going to it scrolls to that line. ⚠ shows failed commands only; the bin clears the list (the commands are still tracked, just hidden).
## Right-click
-Go to Command, Show Output, Explain Failure (failed commands), Save to Intel…, Copy Command, Copy Output, Select Output, Insert Command, Run Again, Copy Folder Path.
+Go to Command, Show Output, Explain Failure (failed commands), Go to Pane Listening on… (a command that found its [port taken](#port-already-in-use)), Save to Intel…, Copy Command, Copy Output, Select Output, Insert Command, Run Again, Copy Folder Path.
## Saving a command for later
diff --git a/docs/development.md b/docs/development.md
index a81a8f9..51a1dbf 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -19,7 +19,7 @@ Three targets, compiled in Swift 6 language mode (strict concurrency):
| Target | What's in it |
|---|---|
-| `CodecCore` | Logic with no UI, all of it unit-tested: the split layout tree and pane geometry, shell integration scripts and environment, integration event parsing, bell and exit policies, preferences, git branch lookup and switching, the changed lines of a file (a line diff), smart selection, command block tracking, each pane's command timeline and kept output, Intel files and their variables, syntax highlighting, Markdown rendering, file types and their colors (for the file tree and `ls`), command history ranking and storage, process inspection, system sampling, versions and release lookup for the update check, and downloading, checking and putting a new version in place. |
+| `CodecCore` | Logic with no UI, all of it unit-tested: the split layout tree and pane geometry, shell integration scripts and environment, integration event parsing, bell and exit policies, preferences, git branch lookup and switching, the changed lines of a file (a line diff), smart selection, command block tracking, each pane's command timeline and kept output, Intel files and their variables, syntax highlighting, Markdown rendering, file types and their colors (for the file tree and `ls`), command history ranking and storage, process inspection (what's running under a shell, and the ports it listens on), system sampling, versions and release lookup for the update check, and downloading, checking and putting a new version in place. |
| `CodecUI` | AppKit and SwiftUI: app delegate and menus, window controller, tabs, panes, chrome, sidebars, editor, palette, settings, sounds, notifications, the update check and installing. |
| `Codec` | The executable; calls `CodecApp.run()`. |
@@ -46,6 +46,6 @@ Terminal emulation is provided by [SwiftTerm](https://github.com/migueldeicaza/S
## Screenshots
-The images in `docs/images` come from `make screenshots`, which runs `Tests/CodecUITests/ScreenshotTour.swift`: real Codec windows, a real zsh, and a fresh clone of this repository with a few demo changes, branches, history and Intel items, all in a throwaway home folder under `.build`, so none of your own history, Intel or files show up.
+The images in `docs/images` come from `make screenshots`, which runs `Tests/CodecUITests/ScreenshotTour.swift`: real Codec windows, a real zsh, and a fresh clone of this repository with a few demo changes, branches, history and Intel items, all in a throwaway home folder under `.build`, so none of your own history, Intel or files show up. `CODEC_SHOT=ports make screenshots` retakes one shot alone, and `CODEC_SHOT=hero,ports` several (the names are in the tour's list), which keeps a change to one feature from touching every image.
Windows open on screen while it runs (about a minute); leave the Mac alone, and unlocked, until it's done. When a feature's look changes, retake them and commit the new images with it.
diff --git a/docs/images/commands.png b/docs/images/commands.png
index 6dce0e2..7995083 100644
Binary files a/docs/images/commands.png and b/docs/images/commands.png differ
diff --git a/docs/images/hero.png b/docs/images/hero.png
index 651389b..5095f04 100644
Binary files a/docs/images/hero.png and b/docs/images/hero.png differ
diff --git a/docs/images/port-in-use.png b/docs/images/port-in-use.png
new file mode 100644
index 0000000..9031a44
Binary files /dev/null and b/docs/images/port-in-use.png differ
diff --git a/docs/images/ports-list.png b/docs/images/ports-list.png
new file mode 100644
index 0000000..60e7170
Binary files /dev/null and b/docs/images/ports-list.png differ
diff --git a/docs/images/ports.png b/docs/images/ports.png
new file mode 100644
index 0000000..664af81
Binary files /dev/null and b/docs/images/ports.png differ
diff --git a/docs/images/usual-duration.png b/docs/images/usual-duration.png
index 7e0451b..c83e935 100644
Binary files a/docs/images/usual-duration.png and b/docs/images/usual-duration.png differ
diff --git a/docs/prompt-and-status-bar.md b/docs/prompt-and-status-bar.md
index cc80510..a602eaf 100644
--- a/docs/prompt-and-status-bar.md
+++ b/docs/prompt-and-status-bar.md
@@ -28,8 +28,38 @@ Along the bottom of the window:
- the **directory**: click to open it in Finder,
- the **git branch**: click to switch,
- **how many environments** are in effect (the Python environment and each tool version), when there are any: click it for the list, with where each comes from ([below](#project-environments-zsh)),
+- the **ports** the pane's programs are listening on (`:3000`), when there are any: click to open one in the browser ([below](#listening-ports)),
- the **running or last command**, with its exit status and how long it took; while a command you've run before is running, how far along it likely is ([how long it usually takes](commands-sidebar.md#how-long-it-usually-takes)). After a failure, **Explain** asks Apple Intelligence [why it failed](commands-sidebar.md#why-did-it-fail).
+## Listening ports
+
+Every dev server prints its address differently, and you scroll back to find it. Codec asks macOS instead: when a program started from the pane listens on a TCP port, the port shows in the status bar.
+
+
+
+- **Click it** to open `http://localhost:3000` in the browser. If the program printed an address for that port as it started (`Local: https://localhost:3000/app`), that's what opens, scheme and path included; an address for every interface (`http://0.0.0.0:8000`) opens as `localhost`.
+- **Several ports** (`:3000 :9229`) make a menu, each with its address and the program holding it.
+- It counts whatever the pane's shell started, however far down: `npm run dev` runs a shell that runs Node, and it's Node's port that shows. Jobs in the background count too. A server that detaches itself from the terminal (a daemon) doesn't.
+- The port goes when the program stops listening.
+
+### Every port in one place
+
+The status bar shows the focused pane's ports. For all of them, open the **Ports** tab of the right sidebar, beside [Commands](commands-sidebar.md): ⇧⌘O or View › Listening Ports. The sidebar button at the right end of the tab bar brings the sidebar back on the tab it was last on.
+
+
+
+It lists every port the programs in any pane are listening on, with the program and where its pane is, this window's first and other windows' under their names. It keeps up as servers start and stop, and as tabs and panes move.
+
+- **Click a port** to go straight to its pane, in whichever tab or window it is.
+- **Hover it** for two buttons: the compass opens it in the browser, the pencil names it. Right-click has the same, and Copy Address.
+- **Name a process** to tell servers apart when they're all "node": click the pencil, type "Storefront", press Return. The name stands in for the program wherever the port shows (this list, the status bar's tooltips and menu, the tab's tooltip, Window › Listening Ports, the command palette, and the line about a [port already in use](commands-sidebar.md#port-already-in-use)), on every port the process has. It lasts as long as the process: a server that's restarted is a new process and goes by its program's name again. Clear the name, or right-click › Remove Name, to take it away.
+
+**Window › Listening Ports** has the same list as a menu (`:3000 Storefront — Tab 2 › left pane`), under each window's name when more than one has ports. Choose a port to go to its pane; hold ⌥ and the item opens the port in the browser instead. The [command palette](tabs-and-panes.md#the-command-palette) (⇧⌘P) has both too: type the port or the name, then pick "Go to :3000 — Storefront, Tab 2 › left pane" or "Open http://localhost:3000 in the Browser".
+
+The same ports show in the [Commands sidebar](commands-sidebar.md#what-it-shows) on the running command's row, in the tab's tooltip, and in the question Codec asks [before closing](tabs-and-panes.md#closing-safely) something that's still running. And when another command finds one of them taken, Codec [says which pane has it](commands-sidebar.md#port-already-in-use).
+
+Codec looks a couple of seconds after a command starts, whenever something is printed, and every few seconds otherwise, by asking the kernel about the processes under the pane's shell. It runs nothing (no `lsof`), and works in any shell, with or without Codec's integration.
+
## Project environments (zsh)
Codec keeps track of the Python environment and the tool versions each pane's shell is using.
diff --git a/docs/shortcuts.md b/docs/shortcuts.md
index 8fd582f..55faa7b 100644
--- a/docs/shortcuts.md
+++ b/docs/shortcuts.md
@@ -48,6 +48,7 @@
| ⇧⌘E | Show / hide the file tree |
| ⇧⌘I | Show / hide Intel |
| ⇧⌘L | Show / hide the Commands sidebar |
+| ⇧⌘O | Show / hide the [listening ports](prompt-and-status-bar.md#every-port-in-one-place) |
| Return | Open the selected item; in the Commands sidebar, go to the command |
| Space | Commands sidebar: show the command's output |
| → / ← | Intel: open / close a folder |
diff --git a/docs/tabs-and-panes.md b/docs/tabs-and-panes.md
index e83ba98..17040d3 100644
--- a/docs/tabs-and-panes.md
+++ b/docs/tabs-and-panes.md
@@ -8,6 +8,8 @@ Tabs live in the title bar. ⌘T opens one in the current directory; ⌘1…⌘9
A tab is titled after what's running in it. To name it yourself, double-click it, right-click › Rename Tab…, or press ⇧⌘R. Clear the name to go back to the automatic title.
+Rest the pointer on a tab for its folder, and the [ports](prompt-and-status-bar.md#listening-ports) its panes' programs are listening on: "Listening on :3000 (node)". The [Ports tab](prompt-and-status-bar.md#every-port-in-one-place) of the right sidebar (⇧⌘O) and Window › Listening Ports list them for every tab and window, and go to the pane of the one you choose.
+
Drag a tab along the tab bar to put it elsewhere; ⌘1…⌘9 follow the new order, and so does the window when Codec reopens it. Middle-click a tab to close it.
## Splits
@@ -34,7 +36,7 @@ Closed a tab by mistake? ⇧⌘T (Shell › Reopen Closed Tab) brings it back, a
## Closing safely
-Before a tab, a pane or a window closes, or Codec quits, it checks whether that would end something still running, in the foreground or in the background (`npm run dev &` counts; the shell's own helpers don't), and names what would be terminated before asking.
+Before a tab, a pane or a window closes, or Codec quits, it checks whether that would end something still running, in the foreground or in the background (`npm run dev &` counts; the shell's own helpers don't), and names what would be terminated before asking. A program that's [listening on a port](prompt-and-status-bar.md#listening-ports) is named with it, even when it was started by something else: "“npm” and “node” (listening on :3000) are still running and will be terminated."
## When something needs you
@@ -57,4 +59,4 @@ Right-click in a pane for Copy, Copy Output, Select Output, Paste, Select All, S
## The command palette
-⇧⌘P, or the ⌘ button at the right of the tab bar, opens the command palette: every action, theme, open tab and [Intel](intel.md) item, fuzzy-searched.
+⇧⌘P, or the ⌘ button at the right of the tab bar, opens the command palette: every action, theme, open tab, [listening port](prompt-and-status-bar.md#listening-ports) and [Intel](intel.md) item, fuzzy-searched.