Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
4b3e261
feat: download remote file explorer items
austinywang May 12, 2026
4730067
Merge remote-tracking branch 'origin/main' into issue-3637-download-r…
austinywang May 12, 2026
b8787df
fix: make remote download process wait async
austinywang May 12, 2026
dd198df
fix: own remote download task lifecycle
austinywang May 12, 2026
7118719
fix: race scp termination grace period
austinywang May 12, 2026
48af0bf
Merge remote-tracking branch 'origin/main' into issue-3637-download-r…
austinywang May 12, 2026
100db6c
fix: clear file explorer download warnings
austinywang May 12, 2026
4194be8
Merge remote-tracking branch 'origin/main' into issue-3637-download-r…
austinywang May 12, 2026
efe723e
fix: address remote download review feedback
austinywang May 12, 2026
a8f376d
fix: clear remote download main actor warnings
austinywang May 12, 2026
05d66fa
fix: isolate remote download transport
austinywang May 12, 2026
0cfd465
Merge remote-tracking branch 'origin/main' into issue-3637-download-r…
austinywang May 13, 2026
83484b9
fix: harden remote download cancellation cleanup
austinywang May 13, 2026
fd77c59
test: cover quoted remote download paths
austinywang May 14, 2026
ee21aba
fix: quote remote download scp paths
austinywang May 14, 2026
7d0ff87
merge: resolve main conflicts
austinywang May 14, 2026
ebce175
fix: share file explorer path helpers
austinywang May 15, 2026
ed82f9c
Merge remote-tracking branch 'origin/main' into issue-3637-download-r…
austinywang May 18, 2026
3add51a
test: cover remote download launch failures
austinywang May 18, 2026
dfe7538
fix: sanitize remote download launch failures
austinywang May 18, 2026
a3e03f4
Merge remote-tracking branch 'origin/main' into issue-3637-download-r…
austinywang May 18, 2026
aad5c59
fix: preserve ssh option values with equals
austinywang May 18, 2026
75ea62a
fix: remember remote download destination
austinywang May 18, 2026
d2113d5
Merge remote-tracking branch 'origin/main' into issue-3637-download-r…
austinywang May 18, 2026
edb1a28
Merge origin/main into issue-3637-download-remote-files-file-explorer
austinywang May 19, 2026
78f5887
Merge remote-tracking branch 'origin/main' into issue-3637-download-r…
austinywang May 19, 2026
1a54aba
fix: localize remote download strings
austinywang May 19, 2026
66b7d50
fix: localize remote ssh error strings
austinywang May 19, 2026
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
510 changes: 510 additions & 0 deletions Resources/Localizable.xcstrings

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2469,6 +2469,7 @@ struct ContentView: View {
// No selection means we have no local cwd to scope by; clear so the
// sessions panel doesn't keep filtering by a stale previous tab.
sessionIndexStore.setCurrentDirectoryIfChanged(nil)
fileExplorerStore.setDefaultLocalDownloadDirectory(nil)
fileExplorerStore.applyWorkspaceRoot(.none)
return
}
Expand All @@ -2477,6 +2478,7 @@ struct ContentView: View {

if tab.isRemoteWorkspace {
sessionIndexStore.setCurrentDirectoryIfChanged(nil)
fileExplorerStore.setDefaultLocalDownloadDirectory(tab.defaultLocalDownloadDirectory)
guard let config = tab.remoteConfiguration, config.transport == .ssh else {
fileExplorerStore.applyWorkspaceRoot(.none)
return
Expand Down Expand Up @@ -2514,11 +2516,13 @@ struct ContentView: View {
let dir = tab.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines)
guard !dir.isEmpty else {
sessionIndexStore.setCurrentDirectoryIfChanged(nil)
fileExplorerStore.setDefaultLocalDownloadDirectory(nil)
fileExplorerStore.applyWorkspaceRoot(.none)
return
}

sessionIndexStore.setCurrentDirectoryIfChanged(dir)
fileExplorerStore.setDefaultLocalDownloadDirectory(dir)
fileExplorerStore.applyWorkspaceRoot(.local(path: dir))
}

Expand Down
188 changes: 42 additions & 146 deletions Sources/FileExplorerStore.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import AppKit
import Combine
import Darwin
Comment thread
cursor[bot] marked this conversation as resolved.
import Foundation
import QuartzCore
import SwiftUI
Expand Down Expand Up @@ -275,6 +276,12 @@ protocol SSHFileExplorerTransport: AnyObject {
connection: SSHFileExplorerConnection,
showHidden: Bool
) async throws -> [FileExplorerEntry]
nonisolated func download(
remotePath: String,
isDirectory: Bool,
connection: SSHFileExplorerConnection,
toLocalDirectory localDirectory: String
) async throws -> String
}

enum FileExplorerWorkspaceRoot: Equatable {
Expand Down Expand Up @@ -403,168 +410,51 @@ final class SSHFileExplorerProvider: FileExplorerProvider, @unchecked Sendable {
}
return try await transport.listDirectory(path: path, connection: connection, showHidden: showHidden)
}
}

final class ProcessSSHFileExplorerTransport: SSHFileExplorerTransport {
static let shared = ProcessSSHFileExplorerTransport()

nonisolated func resolveHomePath(connection: SSHFileExplorerConnection) async throws -> String {
let output = try await Self.runSSHCommand(
connection: connection,
command: #"printf '%s\n' "$HOME""#
)
return output.trimmingCharacters(in: .whitespacesAndNewlines)
}

nonisolated func listDirectory(
path: String,
connection: SSHFileExplorerConnection,
showHidden: Bool
) async throws -> [FileExplorerEntry] {
try await Self.runSSHListCommand(path: path, connection: connection, showHidden: showHidden)
}

private struct SSHCommandResult: Sendable {
let stdout: String
let stderr: String
let terminationStatus: Int32
}

// Keeps the child process reachable from the cancellation handler while
// the blocking wait runs off Swift's cooperative executor.
private final class SSHCommandProcess: @unchecked Sendable {
private let process = Process()
private let outPipe = Pipe()
private let errPipe = Pipe()
private let lock = NSLock()
private var cancelled = false

init(connection: SSHFileExplorerConnection, command: String) {
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
process.arguments = ProcessSSHFileExplorerTransport.sshArguments(connection: connection, command: command)
process.standardOutput = outPipe
process.standardError = errPipe
}

func run() throws -> SSHCommandResult {
lock.lock()
let wasCancelled = cancelled
lock.unlock()
if wasCancelled {
throw CancellationError()
}

try process.run()

lock.lock()
let shouldTerminate = cancelled && process.isRunning
lock.unlock()
if shouldTerminate {
process.terminate()
}

let data = outPipe.fileHandleForReading.readDataToEndOfFile()
let stderrData = errPipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()

return SSHCommandResult(
stdout: String(data: data, encoding: .utf8) ?? "",
stderr: String(data: stderrData, encoding: .utf8) ?? "",
terminationStatus: process.terminationStatus
)
}

func terminate() {
lock.lock()
cancelled = true
let isRunning = process.isRunning
lock.unlock()

if isRunning {
process.terminate()
}
}
}

private static func runSSHCommand(connection: SSHFileExplorerConnection, command: String) async throws -> String {
let commandProcess = SSHCommandProcess(connection: connection, command: command)
let result = try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
continuation.resume(with: Result { try commandProcess.run() })
}
}
} onCancel: {
commandProcess.terminate()
}

guard result.terminationStatus == 0 else {
throw FileExplorerError.sshCommandFailed(result.stderr)
}
return result.stdout
}

private static func sshArguments(connection: SSHFileExplorerConnection, command: String) -> [String] {
var args: [String] = []
if let port = connection.port {
args += ["-p", String(port)]
}
if let identityFile = connection.identityFile {
args += ["-i", identityFile]
}
for option in connection.sshOptions {
args += ["-o", option]
func download(remotePath: String, isDirectory: Bool, toLocalDirectory localDirectory: String) async throws -> String {
guard isAvailable else {
throw FileExplorerError.providerUnavailable
}
// Batch mode, no TTY, connection timeout
args += ["-o", "BatchMode=yes", "-o", "ConnectTimeout=5", "-T"]
args += [connection.destination, command]
return args
}

private static func runSSHListCommand(
path: String,
connection: SSHFileExplorerConnection,
showHidden: Bool
) async throws -> [FileExplorerEntry] {
// Escape single quotes in path for shell safety
let escapedPath = path.replacingOccurrences(of: "'", with: "'\\''")
let lsFlags = showHidden ? "-1paFA" : "-1paF"
let output = try await runSSHCommand(
return try await transport.download(
remotePath: remotePath,
isDirectory: isDirectory,
connection: connection,
command: "ls \(lsFlags) '\(escapedPath)' 2>/dev/null"
toLocalDirectory: localDirectory
)

let normalizedPath = path.hasSuffix("/") ? path : path + "/"
return output.split(separator: "\n", omittingEmptySubsequences: true).compactMap { line in
let entry = String(line)
// Skip . and .. entries
guard entry != "./" && entry != "../" else { return nil }
let isDir = entry.hasSuffix("/")
let name = isDir ? String(entry.dropLast()) : entry
guard showHidden || !name.hasPrefix(".") else { return nil }
// Strip type indicators from -F flag (*, @, =, |) for files
let cleanName: String
if !isDir, let last = name.last, "*@=|".contains(last) {
cleanName = String(name.dropLast())
} else {
cleanName = name
}
let fullPath = normalizedPath + cleanName
return FileExplorerEntry(name: cleanName, path: fullPath, isDirectory: isDir)
}
}
}

enum FileExplorerError: LocalizedError {
case providerUnavailable
case sshCommandFailed(String)
case invalidDownloadDestination(String)
case downloadFailed(String)
case downloadTimedOut

var errorDescription: String? {
switch self {
case .providerUnavailable:
return String(localized: "fileExplorer.error.unavailable", defaultValue: "File explorer is not available")
case .sshCommandFailed(let detail):
return String(localized: "fileExplorer.error.sshFailed", defaultValue: "SSH command failed: \(detail)")
return String(
format: String(localized: "fileExplorer.error.sshFailed", defaultValue: "SSH command failed: %@"),
detail
)
case .invalidDownloadDestination(let path):
return String(
format: String(
localized: "fileExplorer.error.downloadDestinationInvalid",
defaultValue: "Download destination is not a local folder: %@"
),
path
)
case .downloadFailed(let detail):
return String(
format: String(localized: "fileExplorer.error.downloadFailed", defaultValue: "Download failed: %@"),
detail
)
case .downloadTimedOut:
return String(localized: "fileExplorer.error.downloadTimedOut", defaultValue: "Download timed out.")
}
}
}
Expand Down Expand Up @@ -598,6 +488,8 @@ final class FileExplorerStore: ObservableObject {
/// Whether hidden files are shown. Set from FileExplorerState externally.
var showHiddenFiles: Bool = false

private(set) var defaultLocalDownloadDirectory: String?

/// Watches the root directory for filesystem changes (local only).
private var directoryWatcher: FileExplorerDirectoryWatcher?

Expand Down Expand Up @@ -674,6 +566,10 @@ final class FileExplorerStore: ObservableObject {
}
}

func setDefaultLocalDownloadDirectory(_ path: String?) {
defaultLocalDownloadDirectory = LocalDirectoryPathNormalization.existingDirectoryPath(path)
}

func setRootPath(_ path: String) {
guard path != rootPath else {
#if DEBUG
Expand Down
Loading
Loading