Skip to content

feat: add remote folder picker to file sync GUI #127

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Apr 9, 2025
Merged
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
9 changes: 9 additions & 0 deletions Coder-Desktop/Coder-Desktop/Info.plist
Original file line number Diff line number Diff line change
@@ -2,6 +2,15 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSAppTransportSecurity</key>
<dict>
<!--
Required to make HTTP (not HTTPS) requests to workspace agents
(i.e. workspace.coder:4). These are already encrypted over wireguard.
-->
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NetworkExtension</key>
<dict>
<key>NEMachServiceName</key>
232 changes: 232 additions & 0 deletions Coder-Desktop/Coder-Desktop/Views/FileSync/FilePicker.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
import CoderSDK
import Foundation
import SwiftUI

struct FilePicker: View {
@Environment(\.dismiss) var dismiss
@StateObject private var model: FilePickerModel
@State private var selection: FilePickerEntryModel?

@Binding var outputAbsPath: String

let inspection = Inspection<Self>()

init(
host: String,
outputAbsPath: Binding<String>
) {
_model = StateObject(wrappedValue: FilePickerModel(host: host))
_outputAbsPath = outputAbsPath
}

var body: some View {
VStack(spacing: 0) {
if model.rootIsLoading {
Spacer()
ProgressView()
.controlSize(.large)
Spacer()
} else if let loadError = model.error {
Text("\(loadError.description)")
.font(.headline)
.foregroundColor(.red)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
} else {
List(selection: $selection) {
ForEach(model.rootEntries) { entry in
FilePickerEntry(entry: entry).tag(entry)
}
}.contextMenu(
forSelectionType: FilePickerEntryModel.self,
menu: { _ in },
primaryAction: { selections in
// Per the type of `selection`, this will only ever be a set of
// one entry.
selections.forEach { entry in withAnimation { entry.isExpanded.toggle() } }
}
).listStyle(.sidebar)
}
Divider()
HStack {
Spacer()
Button("Cancel", action: { dismiss() }).keyboardShortcut(.cancelAction)
Button("Select", action: submit).keyboardShortcut(.defaultAction).disabled(selection == nil)
}.padding(20)
}
.onAppear {
model.loadRoot()
}
.onReceive(inspection.notice) { inspection.visit(self, $0) } // ViewInspector
}

private func submit() {
guard let selection else { return }
outputAbsPath = selection.absolute_path
dismiss()
}
}

@MainActor
class FilePickerModel: ObservableObject {
@Published var rootEntries: [FilePickerEntryModel] = []
@Published var rootIsLoading: Bool = false
@Published var error: ClientError?

// It's important that `AgentClient` is a reference type (class)
// as we were having performance issues with a struct (unless it was a binding).
let client: AgentClient

init(host: String) {
client = AgentClient(agentHost: host)
}

func loadRoot() {
error = nil
rootIsLoading = true
Task {
defer { rootIsLoading = false }
do throws(ClientError) {
rootEntries = try await client
.listAgentDirectory(.init(path: [], relativity: .root))
.toModels(client: client)
} catch {
self.error = error
}
}
}
}

struct FilePickerEntry: View {
@ObservedObject var entry: FilePickerEntryModel

var body: some View {
Group {
if entry.dir {
directory
} else {
Label(entry.name, systemImage: "doc")
.help(entry.absolute_path)
.selectionDisabled()
.foregroundColor(.secondary)
}
}
}

private var directory: some View {
DisclosureGroup(isExpanded: $entry.isExpanded) {
if let entries = entry.entries {
ForEach(entries) { entry in
FilePickerEntry(entry: entry).tag(entry)
}
}
} label: {
Label {
Text(entry.name)
ZStack {
ProgressView().controlSize(.small).opacity(entry.isLoading && entry.error == nil ? 1 : 0)
Image(systemName: "exclamationmark.triangle.fill")
.opacity(entry.error != nil ? 1 : 0)
}
} icon: {
Image(systemName: "folder")
}.help(entry.error != nil ? entry.error!.description : entry.absolute_path)
}
}
}

@MainActor
class FilePickerEntryModel: Identifiable, Hashable, ObservableObject {
nonisolated let id: [String]
let name: String
// Components of the path as an array
let path: [String]
let absolute_path: String
let dir: Bool

let client: AgentClient

@Published var entries: [FilePickerEntryModel]?
@Published var isLoading = false
@Published var error: ClientError?
@Published private var innerIsExpanded = false
var isExpanded: Bool {
get { innerIsExpanded }
set {
if !newValue {
withAnimation { self.innerIsExpanded = false }
} else {
Task {
self.loadEntries()
}
}
}
}

init(
name: String,
client: AgentClient,
absolute_path: String,
path: [String],
dir: Bool = false,
entries: [FilePickerEntryModel]? = nil
) {
self.name = name
self.client = client
self.path = path
self.dir = dir
self.absolute_path = absolute_path
self.entries = entries

// Swift Arrays are copy on write
id = path
}

func loadEntries() {
self.error = nil
withAnimation { isLoading = true }
Task {
defer {
withAnimation {
isLoading = false
innerIsExpanded = true
}
}
do throws(ClientError) {
entries = try await client
.listAgentDirectory(.init(path: path, relativity: .root))
.toModels(client: client)
} catch {
self.error = error
}
}
}

nonisolated static func == (lhs: FilePickerEntryModel, rhs: FilePickerEntryModel) -> Bool {
lhs.id == rhs.id
}

nonisolated func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}

extension LSResponse {
@MainActor
func toModels(client: AgentClient) -> [FilePickerEntryModel] {
contents.compactMap { entry in
// Filter dotfiles from the picker
guard !entry.name.hasPrefix(".") else { return nil }

return FilePickerEntryModel(
name: entry.name,
client: client,
absolute_path: entry.absolute_path_string,
path: self.absolute_path + [entry.name],
dir: entry.is_dir,
entries: nil
)
}
}
}
Original file line number Diff line number Diff line change
@@ -13,6 +13,7 @@ struct FileSyncSessionModal<VPN: VPNService, FS: FileSyncDaemon>: View {

@State private var loading: Bool = false
@State private var createError: DaemonError?
@State private var pickingRemote: Bool = false

var body: some View {
let agents = vpn.menuState.onlineAgents
@@ -46,7 +47,16 @@ struct FileSyncSessionModal<VPN: VPNService, FS: FileSyncDaemon>: View {
}
}
Section {
TextField("Remote Path", text: $remotePath)
HStack(spacing: 5) {
TextField("Remote Path", text: $remotePath)
Spacer()
Button {
pickingRemote = true
} label: {
Image(systemName: "folder")
}.disabled(remoteHostname == nil)
.help(remoteHostname == nil ? "Select a workspace first" : "Open File Picker")
}
}
}.formStyle(.grouped).scrollDisabled(true).padding(.horizontal)
Divider()
@@ -72,6 +82,9 @@ struct FileSyncSessionModal<VPN: VPNService, FS: FileSyncDaemon>: View {
set: { if !$0 { createError = nil } }
)) {} message: {
Text(createError?.description ?? "An unknown error occurred.")
}.sheet(isPresented: $pickingRemote) {
FilePicker(host: remoteHostname!, outputAbsPath: $remotePath)
.frame(width: 300, height: 400)
}
}

115 changes: 115 additions & 0 deletions Coder-Desktop/Coder-DesktopTests/FilePickerTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
@testable import Coder_Desktop
@testable import CoderSDK
import Mocker
import SwiftUI
import Testing
import ViewInspector

@MainActor
@Suite(.timeLimit(.minutes(1)))
struct FilePickerTests {
let mockResponse: LSResponse

init() {
mockResponse = LSResponse(
absolute_path: ["/"],
absolute_path_string: "/",
contents: [
LSFile(name: "home", absolute_path_string: "/home", is_dir: true),
LSFile(name: "tmp", absolute_path_string: "/tmp", is_dir: true),
LSFile(name: "etc", absolute_path_string: "/etc", is_dir: true),
LSFile(name: "README.md", absolute_path_string: "/README.md", is_dir: false),
]
)
}

@Test
func testLoadError() async throws {
let host = "test-error.coder"
let sut = FilePicker(host: host, outputAbsPath: .constant(""))
let view = sut

let url = URL(string: "http://\(host):4")!

let errorMessage = "Connection failed"
Mock(
url: url.appendingPathComponent("/api/v0/list-directory"),
contentType: .json,
statusCode: 500,
data: [.post: errorMessage.data(using: .utf8)!]
).register()

try await ViewHosting.host(view) {
try await sut.inspection.inspect { view in
try #expect(await eventually { @MainActor in
let text = try view.find(ViewType.Text.self)
return try text.string().contains("Connection failed")
})
}
}
}

@Test
func testSuccessfulFileLoad() async throws {
let host = "test-success.coder"
let sut = FilePicker(host: host, outputAbsPath: .constant(""))
let view = sut

let url = URL(string: "http://\(host):4")!

try Mock(
url: url.appendingPathComponent("/api/v0/list-directory"),
statusCode: 200,
data: [.post: Client.encoder.encode(mockResponse)]
).register()

try await ViewHosting.host(view) {
try await sut.inspection.inspect { view in
try #expect(await eventually { @MainActor in
_ = try view.find(ViewType.List.self)
return true
})
_ = try view.find(text: "README.md")
_ = try view.find(text: "home")
let selectButton = try view.find(button: "Select")
#expect(selectButton.isDisabled())
}
}
}

@Test
func testDirectoryExpansion() async throws {
let host = "test-expansion.coder"
let sut = FilePicker(host: host, outputAbsPath: .constant(""))
let view = sut

let url = URL(string: "http://\(host):4")!

try Mock(
url: url.appendingPathComponent("/api/v0/list-directory"),
statusCode: 200,
data: [.post: Client.encoder.encode(mockResponse)]
).register()

try await ViewHosting.host(view) {
try await sut.inspection.inspect { view in
try #expect(await eventually { @MainActor in
_ = try view.find(ViewType.List.self)
return true
})

let disclosureGroup = try view.find(ViewType.DisclosureGroup.self)
#expect(view.findAll(ViewType.DisclosureGroup.self).count == 3)
try disclosureGroup.expand()

// Disclosure group should expand out to 3 more directories
try #expect(await eventually { @MainActor in
return try view.findAll(ViewType.DisclosureGroup.self).count == 6
})
}
}
}

// TODO: The writing of more extensive tests is blocked by ViewInspector,
// as it can't select an item in a list...
}
25 changes: 25 additions & 0 deletions Coder-Desktop/Coder-DesktopTests/Util.swift
Original file line number Diff line number Diff line change
@@ -57,3 +57,28 @@ class MockFileSyncDaemon: FileSyncDaemon {
}

extension Inspection: @unchecked Sendable, @retroactive InspectionEmissary {}

public func eventually(
timeout: Duration = .milliseconds(500),
interval: Duration = .milliseconds(10),
condition: @escaping () async throws -> Bool
) async throws -> Bool {
let endTime = ContinuousClock.now.advanced(by: timeout)

var lastError: Error?

while ContinuousClock.now < endTime {
do {
if try await condition() { return true }
lastError = nil
} catch {
lastError = error
try await Task.sleep(for: interval)
}
}

if let lastError {
throw lastError
}
return false
}
7 changes: 7 additions & 0 deletions Coder-Desktop/CoderSDK/AgentClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
public final class AgentClient: Sendable {
let client: Client

public init(agentHost: String) {
client = Client(url: URL(string: "http://\(agentHost):4")!)
}
}
43 changes: 43 additions & 0 deletions Coder-Desktop/CoderSDK/AgentLS.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
public extension AgentClient {
func listAgentDirectory(_ req: LSRequest) async throws(ClientError) -> LSResponse {
let res = try await client.request("/api/v0/list-directory", method: .post, body: req)
guard res.resp.statusCode == 200 else {
throw client.responseAsError(res)
}
return try client.decode(LSResponse.self, from: res.data)
}
}

public struct LSRequest: Sendable, Codable {
// e.g. [], ["repos", "coder"]
public let path: [String]
// Whether the supplied path is relative to the user's home directory,
// or the root directory.
public let relativity: LSRelativity

public init(path: [String], relativity: LSRelativity) {
self.path = path
self.relativity = relativity
}

public enum LSRelativity: String, Sendable, Codable {
case root
case home
}
}

public struct LSResponse: Sendable, Codable {
public let absolute_path: [String]
// e.g. Windows: "C:\\Users\\coder"
// Linux: "/home/coder"
public let absolute_path_string: String
public let contents: [LSFile]
}

public struct LSFile: Sendable, Codable {
public let name: String
// e.g. "C:\\Users\\coder\\hello.txt"
// "/home/coder/hello.txt"
public let absolute_path_string: String
public let is_dir: Bool
}
2 changes: 1 addition & 1 deletion Coder-Desktop/CoderSDK/Client.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Foundation

public struct Client {
public struct Client: Sendable {
public let url: URL
public var token: String?
public var headers: [HTTPHeader]