diff --git a/CLI/CMUXCLI+TaskHelp.swift b/CLI/CMUXCLI+TaskHelp.swift index 39117c3db711..a76009f8a361 100644 --- a/CLI/CMUXCLI+TaskHelp.swift +++ b/CLI/CMUXCLI+TaskHelp.swift @@ -428,7 +428,7 @@ extension CMUXCLI { set-hook [--list] [--unset ] | popup bind-key | unbind-key | copy-mode - set-buffer [--name ] + set-buffer [--name ] [ | -] list-buffers paste-buffer [--name ] [--workspace ] [--surface ] [--window ] respawn-pane [--workspace ] [--surface ] [--window ] [--command ] diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 4589608320cd..954b02073b25 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -19826,9 +19826,9 @@ struct CMUXCLI { """ case "set-buffer": return """ - Usage: cmux set-buffer [--name ] [--] + Usage: cmux set-buffer [--name ] [--] [ | -] - 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 Buffer name (default: default) @@ -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") } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 0ff3f0ff963f..abb7cab42610 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -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" diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index c1355c0d22a5..e0ef8f732811 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -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 */; }; @@ -4745,6 +4746,7 @@ A7367002A1B2C3D4E5F60718 /* CLISSHSessionAttachAnchorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLISSHSessionAttachAnchorTests.swift; sourceTree = ""; }; C1175700000000000000001A /* CLIStdioSIGPIPERegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIStdioSIGPIPERegressionTests.swift; sourceTree = ""; }; C11757000000000000000053 /* CLITestBundleAnchor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLITestBundleAnchor.swift; sourceTree = ""; }; + C117570000000000000000B1 /* CLITmuxCompatBufferContentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLITmuxCompatBufferContentTests.swift; sourceTree = ""; }; B05553B10000000000000002 /* CLITmuxCompatRemoteSplitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLITmuxCompatRemoteSplitTests.swift; sourceTree = ""; }; 7837E0067837E0067837E006 /* CLITmuxCompatResizePaneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLITmuxCompatResizePaneTests.swift; sourceTree = ""; }; C1175700000000000000001B /* CLITmuxCompatStoreConcurrencyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLITmuxCompatStoreConcurrencyTests.swift; sourceTree = ""; }; @@ -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 */, @@ -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 */, diff --git a/cmuxCLITests/CLITmuxCompatBufferContentTests.swift b/cmuxCLITests/CLITmuxCompatBufferContentTests.swift new file mode 100644 index 000000000000..c109bb0dbe9f --- /dev/null +++ b/cmuxCLITests/CLITmuxCompatBufferContentTests.swift @@ -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) + } +} diff --git a/docs/cli-contract.md b/docs/cli-contract.md index 871e39d47b17..6c49132f13aa 100644 --- a/docs/cli-contract.md +++ b/docs/cli-contract.md @@ -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. |