Skip to content
Merged
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 CLI/CMUXCLI+TaskHelp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,7 @@ extension CMUXCLI {
set-hook [--list] [--unset <event>] | <event> <command>
popup
bind-key | unbind-key | copy-mode
set-buffer [--name <name>] <text>
set-buffer [--name <name>] [<text> | -]
list-buffers
paste-buffer [--name <name>] [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>]
respawn-pane [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>] [--command <cmd>]
Expand Down
26 changes: 23 additions & 3 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19826,9 +19826,9 @@ struct CMUXCLI {
"""
case "set-buffer":
return """
Usage: cmux set-buffer [--name <name>] [--] <text>
Usage: cmux set-buffer [--name <name>] [--] [<text> | -]

Save text into a named tmux-compat buffer.
Save text into a named tmux-compat buffer, exactly as given. With no text argument, or with -, the text is read from stdin.

Flags:
--name <name> Buffer name (default: default)
Expand Down Expand Up @@ -27359,7 +27359,27 @@ struct CMUXCLI {
case "set-buffer":
let (nameArg, rem0) = parseOption(commandArgs, name: "--name")
let name = (nameArg?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? nameArg! : "default"
let content = rem0.dropFirst(rem0.first == "--" ? 1 : 0).joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
// Store the text exactly as given, like tmux: trailing newlines and
// indentation are part of what paste-buffer should deliver. With no
// text argument, or a lone "-", read the text from stdin so output
// can be piped in (`cmd | cmux set-buffer`).
let textArgs = Array(rem0.dropFirst(rem0.first == "--" ? 1 : 0))
let content: String
if textArgs.isEmpty || textArgs == ["-"] {
guard isatty(STDIN_FILENO) != 1 else {
throw CLIError(message: "set-buffer requires text")
}
let data = FileHandle.standardInput.readDataToEndOfFile()
guard let text = String(data: data, encoding: .utf8) else {
throw CLIError(message: String(
localized: "cli.setBuffer.error.invalidUTF8",
defaultValue: "set-buffer: stdin is not valid UTF-8 text"
))
}
content = text
} else {
content = textArgs.joined(separator: " ")
}
guard !content.isEmpty else {
throw CLIError(message: "set-buffer requires text")
}
Expand Down
59 changes: 59 additions & 0 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -558407,6 +558407,65 @@
}
}
}
},
"cli.setBuffer.error.invalidUTF8": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "set-buffer: stdin is not valid UTF-8 text"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "set-buffer: محتوى stdin ليس نص UTF-8 صالحًا"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "set-buffer: stdin ist kein gültiger UTF-8-Text"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "set-buffer: stdin no es texto UTF-8 válido"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "set-buffer : stdin n’est pas un texte UTF-8 valide"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "set-buffer: stdin が有効な UTF-8 テキストではありません"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "set-buffer: stdin이 유효한 UTF-8 텍스트가 아닙니다"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "set-buffer:stdin 不是有效的 UTF-8 文本"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "set-buffer:stdin 不是有效的 UTF-8 文字"
}
}
}
}
},
"version": "1.0"
Expand Down
4 changes: 4 additions & 0 deletions cmux.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,7 @@
C11757000000000000000033 /* CLIStdioSIGPIPERegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1175700000000000000001A /* CLIStdioSIGPIPERegressionTests.swift */; };
C11757000000000000000045 /* CLITestBundleAnchor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11757000000000000000053 /* CLITestBundleAnchor.swift */; };
C11757000000000000000054 /* CLITestBundleAnchor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11757000000000000000053 /* CLITestBundleAnchor.swift */; };
C117570000000000000000B2 /* CLITmuxCompatBufferContentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C117570000000000000000B1 /* CLITmuxCompatBufferContentTests.swift */; };
B05553B10000000000000001 /* CLITmuxCompatRemoteSplitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B05553B10000000000000002 /* CLITmuxCompatRemoteSplitTests.swift */; };
7837E0057837E0057837E005 /* CLITmuxCompatResizePaneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7837E0067837E0067837E006 /* CLITmuxCompatResizePaneTests.swift */; };
C11757000000000000000034 /* CLITmuxCompatStoreConcurrencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1175700000000000000001B /* CLITmuxCompatStoreConcurrencyTests.swift */; };
Expand Down Expand Up @@ -4745,6 +4746,7 @@
A7367002A1B2C3D4E5F60718 /* CLISSHSessionAttachAnchorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLISSHSessionAttachAnchorTests.swift; sourceTree = "<group>"; };
C1175700000000000000001A /* CLIStdioSIGPIPERegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIStdioSIGPIPERegressionTests.swift; sourceTree = "<group>"; };
C11757000000000000000053 /* CLITestBundleAnchor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLITestBundleAnchor.swift; sourceTree = "<group>"; };
C117570000000000000000B1 /* CLITmuxCompatBufferContentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLITmuxCompatBufferContentTests.swift; sourceTree = "<group>"; };
B05553B10000000000000002 /* CLITmuxCompatRemoteSplitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLITmuxCompatRemoteSplitTests.swift; sourceTree = "<group>"; };
7837E0067837E0067837E006 /* CLITmuxCompatResizePaneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLITmuxCompatResizePaneTests.swift; sourceTree = "<group>"; };
C1175700000000000000001B /* CLITmuxCompatStoreConcurrencyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLITmuxCompatStoreConcurrencyTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -8178,6 +8180,7 @@
C11757000000000000000019 /* CLISSHPTYResizeInputTests.swift */,
C1175700000000000000001A /* CLIStdioSIGPIPERegressionTests.swift */,
C1175700000000000000001B /* CLITmuxCompatStoreConcurrencyTests.swift */,
C117570000000000000000B1 /* CLITmuxCompatBufferContentTests.swift */,
C1175700000000000000001C /* CLIWindowHandleRoutingTests.swift */,
C1175700000000000000001D /* CLIWorkspaceGroupSafetyTests.swift */,
C1175700000000000000001E /* CLIWorkspaceStableIDTests.swift */,
Expand Down Expand Up @@ -12848,6 +12851,7 @@
C11757000000000000000032 /* CLISSHPTYResizeInputTests.swift in Sources */,
C11757000000000000000033 /* CLIStdioSIGPIPERegressionTests.swift in Sources */,
C11757000000000000000045 /* CLITestBundleAnchor.swift in Sources */,
C117570000000000000000B2 /* CLITmuxCompatBufferContentTests.swift in Sources */,
C11757000000000000000034 /* CLITmuxCompatStoreConcurrencyTests.swift in Sources */,
C11757000000000000000046 /* CLIWindowCommandMockServer.swift in Sources */,
C11757000000000000000035 /* CLIWindowHandleRoutingTests.swift in Sources */,
Expand Down
131 changes: 131 additions & 0 deletions cmuxCLITests/CLITmuxCompatBufferContentTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import Darwin
import Foundation
import Testing

/// `cmux set-buffer` stores exactly the text it is given, including trailing
/// newlines and indentation, and reads that text from stdin when no text
/// argument is passed, so `cmd | cmux set-buffer` works like tmux.
@Suite(.serialized)
struct CLITmuxCompatBufferContentTests {
@Test func setBufferReadsStandardInputVerbatim() throws {
let text = "first line\n indented line\n\n"
let run = try runSetBuffer(arguments: ["set-buffer", "--name", "piped"], standardInput: text)

#expect(run.result.status == 0, Comment(rawValue: run.result.stderr))
#expect(run.buffers?["piped"] == text)
}

@Test func setBufferDashReadsStandardInput() throws {
let run = try runSetBuffer(arguments: ["set-buffer", "--name", "dash", "-"], standardInput: "from stdin\n")

#expect(run.result.status == 0, Comment(rawValue: run.result.stderr))
#expect(run.buffers?["dash"] == "from stdin\n")
}

@Test func setBufferKeepsTrailingNewlineAndSpacesFromArguments() throws {
let run = try runSetBuffer(arguments: ["set-buffer", "--name", "arg", "--", " echo hi\n"])

#expect(run.result.status == 0, Comment(rawValue: run.result.stderr))
#expect(run.buffers?["arg"] == " echo hi\n")
}

@Test func setBufferWithEmptyStandardInputFailsWithoutWriting() throws {
let run = try runSetBuffer(arguments: ["set-buffer", "--name", "empty"], standardInput: "")

#expect(run.result.status != 0)
#expect(run.buffers?["empty"] == nil)
}

// MARK: - Harness

private struct Run {
let result: CLIHookProcessRunner.Result
let buffers: [String: String]?
}

private func runSetBuffer(arguments: [String], standardInput: String? = nil) throws -> Run {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-tmux-buffer-content-\(UUID().uuidString)", isDirectory: true)
let home = root.appendingPathComponent("home", isDirectory: true)
let storeURL = home
.appendingPathComponent(".cmuxterm", isDirectory: true)
.appendingPathComponent("tmux-compat-store.json", isDirectory: false)
try FileManager.default.createDirectory(
at: storeURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
defer { try? FileManager.default.removeItem(at: root) }

let socketPath = makeCodexHookSocketPath("setbuf")
let listenerFD = try bindCodexHookUnixSocket(at: socketPath)
let drain = Self.startClientDrain(listenerFD: listenerFD)
defer {
drain.stop.set()
_ = drain.done.wait(timeout: .now() + 5)
Darwin.close(listenerFD)
unlink(socketPath)
}

let result = CLIHookProcessRunner.run(
executablePath: try BundledCLITestSupport.bundledCLIPath(for: CLITestBundleAnchor.self),
arguments: arguments,
environment: [
"CMUX_SOCKET_PATH": socketPath,
"CMUX_SOCKET_PASSWORD": "",
"CMUX_CLI_SENTRY_DISABLED": "1",
"CFFIXED_USER_HOME": home.path,
"HOME": home.path,
"PATH": ProcessInfo.processInfo.environment["PATH"] ?? "/usr/bin:/bin",
],
standardInput: standardInput,
timeout: 60
)
#expect(!result.timedOut, Comment(rawValue: result.stderr))

var buffers: [String: String]?
if let data = try? Data(contentsOf: storeURL),
let object = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
buffers = object["buffers"] as? [String: String]
}
return Run(result: result, buffers: buffers)
}

private final class StopFlag: @unchecked Sendable {
private let lock = NSLock()
private var value = false

var isSet: Bool {
lock.lock()
defer { lock.unlock() }
return value
}

func set() {
lock.lock()
value = true
lock.unlock()
}
}

/// Accepts and immediately closes clients until stopped; set-buffer only
/// touches the local store, so no socket replies are needed.
private static func startClientDrain(listenerFD: Int32) -> (done: DispatchSemaphore, stop: StopFlag) {
let done = DispatchSemaphore(value: 0)
let stop = StopFlag()
DispatchQueue.global(qos: .userInitiated).async {
defer { done.signal() }
while !stop.isSet {
var descriptor = pollfd(fd: listenerFD, events: Int16(POLLIN), revents: 0)
let ready = Darwin.poll(&descriptor, 1, 100)
if ready < 0 {
if errno == EINTR { continue }
return
}
guard ready > 0 else { continue }
let clientFD = Darwin.accept(listenerFD, nil, nil)
if clientFD >= 0 { Darwin.close(clientFD) }
}
}
return (done, stop)
}
}
2 changes: 1 addition & 1 deletion docs/cli-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -525,7 +525,7 @@ tmux compatibility commands:
| `set-hook` | Manage tmux-compat hook definitions. |
| `popup` | Placeholder, currently unsupported. |
| `bind-key`, `unbind-key`, `copy-mode` | Placeholders, currently unsupported. |
| `set-buffer` | Set a tmux-compat buffer. |
| `set-buffer` | Set a tmux-compat buffer to the given text exactly; reads stdin when no text (or `-`) is given. |
| `paste-buffer` | Paste a tmux-compat buffer. |
| `list-buffers` | List tmux-compat buffers. |
| `respawn-pane` | Send a restart command to a surface. |
Expand Down
Loading