diff --git a/Packages/Edith/Sources/Edith/Core/Application/CLIWindowBridge.swift b/Packages/Edith/Sources/Edith/Core/Application/CLIWindowBridge.swift index d02b0578e..c3d844f4f 100644 --- a/Packages/Edith/Sources/Edith/Core/Application/CLIWindowBridge.swift +++ b/Packages/Edith/Sources/Edith/Core/Application/CLIWindowBridge.swift @@ -4,6 +4,8 @@ import SwiftUI @MainActor enum CLIWindowBridge { + private static var openedFileRequests: [String] = [] + private static var filesObserver: NSObjectProtocol? private static var revealObserver: NSObjectProtocol? private static var snapshotObserver: NSObjectProtocol? @@ -19,10 +21,36 @@ enum CLIWindowBridge { AppRuntimeCenter().perform(.snapshot) { snapshot(info) } } } + filesObserver = IPC.observe(IPC.Name.requestFinderOpen) { info in + MainActor.assumeIsolated { openFiles(info) } + } QuinjetSessionBridge.shared.install() MachineTerminalBroadcastBridge.install() } + private static func openFiles(_ info: [AnyHashable: Any]) { + let model = MachinesModel.shared + let requestID = info["requestID"] as? String ?? "" + if openedFileRequests.contains(requestID) { + IPC.post(IPC.Name.finderOpenResult, userInfo: ["opened": true, "requestID": requestID]) + return + } + guard !requestID.isEmpty, let raw = info["machine"] as? String, + let id = UUID(uuidString: raw), model.knows(id) + else { + IPC.post( + IPC.Name.finderOpenResult, + userInfo: [ + "opened": false, "reason": "Machine is unavailable.", "requestID": requestID, + ]) + return + } + FinderWindow.open(session: model.session(for: id), path: info["path"] as? String) + openedFileRequests.append(requestID) + openedFileRequests = Array(openedFileRequests.suffix(16)) + IPC.post(IPC.Name.finderOpenResult, userInfo: ["opened": true, "requestID": requestID]) + } + private static func fail(_ message: String) { IPC.post(IPC.Name.revealResult, userInfo: ["ok": false, "error": message]) } diff --git a/Packages/Edith/Sources/EdithCLI/CommandTree.swift b/Packages/Edith/Sources/EdithCLI/CommandTree.swift index 24a69be77..46ebafb07 100644 --- a/Packages/Edith/Sources/EdithCLI/CommandTree.swift +++ b/Packages/Edith/Sources/EdithCLI/CommandTree.swift @@ -766,6 +766,8 @@ public enum CommandTree { "ed machines files get": Spec( options: ["--json", "--help", "--dry-run", "--replace", "--yes"], arguments: [.machine, .remotePath, .localPath], destructivePolicy: .previewThenYes), + "ed machines files open": Spec( + options: ["--json"], arguments: [.machineOrLocal, .remotePath]), "ed machines files preview": Spec( options: ["--json", "-h", "--help", "--version"], arguments: [.machine, .remotePath]), "ed machines files launch": Spec( diff --git a/Packages/Edith/Sources/EdithCLI/Commands/MachineFileCommands.swift b/Packages/Edith/Sources/EdithCLI/Commands/MachineFileCommands.swift index 4e808f838..f86d7f54f 100644 --- a/Packages/Edith/Sources/EdithCLI/Commands/MachineFileCommands.swift +++ b/Packages/Edith/Sources/EdithCLI/Commands/MachineFileCommands.swift @@ -13,7 +13,7 @@ struct MachinesFilesCommand: AsyncParsableCommand { MachinesFilesMakeDirectoryCommand.self, MachinesFilesRemoveCommand.self, MachinesFilesSearchCommand.self, MachinesFilesInfoCommand.self, MachinesFilesDuplicateCommand.self, MachinesFilesUndoCommand.self, - MachineFilesPreviewCommand.self, + MachinesFilesOpenCommand.self, MachineFilesPreviewCommand.self, MachineFilesLaunchCommand.self, MachineFilesRevealCommand.self, MachineFilesGetManyCommand.self, MachineFilesTransferCommand.self, ], @@ -349,6 +349,81 @@ struct MachineFilesPutCommand: AsyncParsableCommand { } } +struct MachinesFilesOpenCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "open", + abstract: "Open Edith's Files window on a machine directory.", + discussion: """ + Opens a Files window in the main Edith application, starting it if needed. + With no path, browsing continues in this terminal's remembered directory. + """) + + @Flag(name: .long, help: "Emit JSON on stdout.") + var json = false + + @Argument(help: "Machine name, ssh alias or id.") + var machine: String + + @Argument(help: "Remote directory to show. Defaults to this terminal's directory.") + var path: String? + + func run() async throws { + try await execute { + let target = + machine.lowercased() == "local" + ? Machine.local : try MachineResolver.machine(machine) + let directory = + path ?? MachineWorkingDirectory.load(machineID: target.id) + let progress = CLIProgress.forCommand(json: json) + if !AppBridge.mainAppIsRunning { + guard let bundle = CLIEnvironment.installedAppURL() else { + throw CLIFailure.unavailable( + "Edith is not installed", hint: "install Edith and retry") + } + try await EdithProcesses.launch(bundle) + } + let requestID = UUID().uuidString + var answer: [AnyHashable: Any]? + for _ in 0..<4 { + answer = await AppBridge.awaitReply( + IPC.Name.finderOpenResult, timeout: 3, + matching: { $0["requestID"] as? String == requestID } + ) { + var info: [String: Any] = [ + "machine": target.id.uuidString, "requestID": requestID, + ] + if let directory { info["path"] = directory } + AppBridge.post(IPC.Name.requestFinderOpen, userInfo: info) + } + if answer != nil { break } + } + progress.end() + guard let reply = answer else { + throw AppBridge.silence("opening the Files window") + } + guard reply["opened"] as? Bool == true else { + throw CLIFailure.unavailable( + "Edith would not open a window for \(target.name)", + hint: reply["reason"] as? String) + } + report(machine: target, directory: directory) + } + } + + private func report(machine target: Machine, directory: String?) { + guard !json else { + CLIOut.json( + .object([ + "machine": .string(target.name), + "opened": .bool(true), + "path": .string(directory ?? ""), + ])) + return + } + CLIOut.out("opened \(directory ?? "the home directory") on \(target.name)") + } +} + extension String { func expandingTilde() -> String { (self as NSString).expandingTildeInPath diff --git a/Packages/Edith/Sources/EdithKit/Core/IPC/IPC.swift b/Packages/Edith/Sources/EdithKit/Core/IPC/IPC.swift index 93bd26304..861e6c025 100644 --- a/Packages/Edith/Sources/EdithKit/Core/IPC/IPC.swift +++ b/Packages/Edith/Sources/EdithKit/Core/IPC/IPC.swift @@ -102,6 +102,8 @@ public enum IPC { public static let requestCalendarEvents = IPC.scopedName( "com.pulkit.edith.requestCalendarEvents") public static let calendarEvents = IPC.scopedName("com.pulkit.edith.calendarEvents") + public static let requestFinderOpen = IPC.scopedName("com.pulkit.edith.requestFinderOpen") + public static let finderOpenResult = IPC.scopedName("com.pulkit.edith.finderOpenResult") public static let requestReveal = IPC.scopedName("com.pulkit.edith.requestReveal") public static let revealResult = IPC.scopedName("com.pulkit.edith.revealResult") public static let requestWindowSnapshot = IPC.scopedName( diff --git a/Packages/Edith/Tests/EdithTests/CLIContractTests.swift b/Packages/Edith/Tests/EdithTests/CLIContractTests.swift index d5d848bf8..c51f3152e 100644 --- a/Packages/Edith/Tests/EdithTests/CLIContractTests.swift +++ b/Packages/Edith/Tests/EdithTests/CLIContractTests.swift @@ -834,6 +834,8 @@ enum JSONContract { JSONCase("ed machines workspace rm", ["machines", "workspace", "rm", "nope", "--json"]), JSONCase( "ed machines files undo", ["machines", "files", "undo", "nowhere-at-all", "--json"]), + JSONCase( + "ed machines files open", ["machines", "files", "open", "nowhere-at-all", "--json"]), JSONCase("ed machines mounts", ["machines", "mounts", "--json"]), JSONCase("ed machines mount", ["machines", "mount", "nowhere-at-all", "--json"]), JSONCase("ed machines unmount", ["machines", "unmount", "nowhere-at-all", "--json"]), diff --git a/Packages/Edith/Tests/EdithTests/CLIFilesOpenTests.swift b/Packages/Edith/Tests/EdithTests/CLIFilesOpenTests.swift new file mode 100644 index 000000000..a095afd4c --- /dev/null +++ b/Packages/Edith/Tests/EdithTests/CLIFilesOpenTests.swift @@ -0,0 +1,38 @@ +import Foundation +import Testing + +@testable import EdithCLI +@testable import EdithKit + +@Suite struct CLIFilesOpenTests { + @Test func localWindowUsesTheMainAppAndCorrelatedReply() async throws { + try await CLIProbe.inWorld { world in + CLIEnvironment.isMainAppRunning = { true } + world.answers { _ in + ["opened": true, "requestID": world.posted.last?.info["requestID"] ?? ""] + } + let result = await CLIProbe.capture([ + "machines", "files", "open", "local", "/tmp/demo-project", "--json", + ]) + #expect(result.code == 0) + #expect(result.object?["opened"] as? Bool == true) + #expect(result.object?["path"] as? String == "/tmp/demo-project") + #expect(world.posted.first?.info["machine"] as? String == Machine.localID.uuidString) + #expect(world.postedNames() == [IPC.Name.requestFinderOpen.rawValue]) + if let directory = ProcessInfo.processInfo.environment["EDITH_RENDER_DUMP"] { + let url = URL(fileURLWithPath: directory).appendingPathComponent("files-open.txt") + try ("$ ed machines files open local /tmp/demo-project --json\n" + result.stdout) + .write(to: url, atomically: true, encoding: .utf8) + } + } + } + + @Test func unrelatedWindowRepliesAreRejected() async { + await CLIProbe.inWorld { world in + CLIEnvironment.isMainAppRunning = { true } + world.answers { _ in ["opened": true, "requestID": "another-request"] } + let result = await CLIProbe.capture(["machines", "files", "open", "local", "--json"]) + #expect(result.code == ExitCodes.unavailable) + } + } +} diff --git a/Packages/Edith/Tests/EdithTests/CLIParityTests.swift b/Packages/Edith/Tests/EdithTests/CLIParityTests.swift index 99758c05c..ee6469d63 100644 --- a/Packages/Edith/Tests/EdithTests/CLIParityTests.swift +++ b/Packages/Edith/Tests/EdithTests/CLIParityTests.swift @@ -47,6 +47,8 @@ enum UIParity { "ed color clear": "the colour picker no longer offers a clear-history action", "ed companion machines add": "the companion setup no longer adds deployment machines from the app", + "ed machines files open": + "the Files window is presentation state rather than a domain operation", "ed machines docker compose up": "the Docker window groups by project but never runs compose", "ed machines docker compose down": diff --git a/docs/cli/machines-files/README.md b/docs/cli/machines-files/README.md index 8f13d1214..a3a72d33c 100644 --- a/docs/cli/machines-files/README.md +++ b/docs/cli/machines-files/README.md @@ -177,3 +177,5 @@ for that machine is already open. - [Conventions and contracts](../conventions.md) has the full exit code and JSON contract these pages assume. - [All command groups](../README.md) + +Use [`ed machines files open`](./open.md) to open a directory in the main application’s Files window. diff --git a/docs/cli/machines-files/open.md b/docs/cli/machines-files/open.md new file mode 100644 index 000000000..422936014 --- /dev/null +++ b/docs/cli/machines-files/open.md @@ -0,0 +1,16 @@ +# Open a Files window + +`ed machines files open [path] [--json]` opens a directory in Edith's Files window. + +The window belongs to the main application. The command starts Edith when needed and waits for the window to acknowledge the request. The background agent continues owning collection and shared state. + +With no path, the command uses the directory remembered for this terminal and machine. If no directory was remembered, the window opens at the machine's home directory. + +```sh +ed machines files open local ~/projects +ed machines files open workstation --json +``` + +A JSON success includes `machine`, `opened`, and `path`. Unknown machines fail before the application is launched. An unavailable application or an unanswered window request returns an error. + +[Back to CLI index](../README.md)