Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ Click anywhere in the command you're typing to put the cursor there, drag to sel

<img src="docs/images/branches.png" alt="Two panes side by side, and the branch switcher open">

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)

Expand Down
8 changes: 8 additions & 0 deletions Sources/CodecCore/CommandTimeline.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?) {
Expand Down Expand Up @@ -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 }
Expand Down
12 changes: 9 additions & 3 deletions Sources/CodecCore/FailureExplanation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,32 +10,37 @@ 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
/// running or ended without a report.
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 {
"""
Command: \(command)
Folder: \(directory)
\(tools.isEmpty ? "" : "Tools: \(tools)\n")Exit status: \(exitCode)
Output:
\(known.isEmpty ? "" : "Known: \(known)\n")Output:
\(output.isEmpty ? "(nothing)" : output)
"""
}
Expand Down Expand Up @@ -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. \
Expand Down
196 changes: 196 additions & 0 deletions Sources/CodecCore/ListeningPorts.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading