diff --git a/Coder-Desktop/Coder-Desktop/Info.plist b/Coder-Desktop/Coder-Desktop/Info.plist index 8609906b..5e59b253 100644 --- a/Coder-Desktop/Coder-Desktop/Info.plist +++ b/Coder-Desktop/Coder-Desktop/Info.plist @@ -2,6 +2,15 @@ + NSAppTransportSecurity + + + NSAllowsArbitraryLoads + + NetworkExtension NEMachServiceName diff --git a/Coder-Desktop/Coder-Desktop/Views/FileSync/FilePicker.swift b/Coder-Desktop/Coder-Desktop/Views/FileSync/FilePicker.swift new file mode 100644 index 00000000..4ee31a62 --- /dev/null +++ b/Coder-Desktop/Coder-Desktop/Views/FileSync/FilePicker.swift @@ -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() + + init( + host: String, + outputAbsPath: Binding + ) { + _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 + ) + } + } +} diff --git a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift index 0e42ea0c..7b902f21 100644 --- a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift +++ b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift @@ -13,6 +13,7 @@ struct FileSyncSessionModal: 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: 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: 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) } } diff --git a/Coder-Desktop/Coder-DesktopTests/FilePickerTests.swift b/Coder-Desktop/Coder-DesktopTests/FilePickerTests.swift new file mode 100644 index 00000000..61bf2196 --- /dev/null +++ b/Coder-Desktop/Coder-DesktopTests/FilePickerTests.swift @@ -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... +} diff --git a/Coder-Desktop/Coder-DesktopTests/Util.swift b/Coder-Desktop/Coder-DesktopTests/Util.swift index 4301cbc4..249aa10b 100644 --- a/Coder-Desktop/Coder-DesktopTests/Util.swift +++ b/Coder-Desktop/Coder-DesktopTests/Util.swift @@ -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 +} diff --git a/Coder-Desktop/CoderSDK/AgentClient.swift b/Coder-Desktop/CoderSDK/AgentClient.swift new file mode 100644 index 00000000..ecdd3d43 --- /dev/null +++ b/Coder-Desktop/CoderSDK/AgentClient.swift @@ -0,0 +1,7 @@ +public final class AgentClient: Sendable { + let client: Client + + public init(agentHost: String) { + client = Client(url: URL(string: "http://\(agentHost):4")!) + } +} diff --git a/Coder-Desktop/CoderSDK/AgentLS.swift b/Coder-Desktop/CoderSDK/AgentLS.swift new file mode 100644 index 00000000..7110f405 --- /dev/null +++ b/Coder-Desktop/CoderSDK/AgentLS.swift @@ -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 +} diff --git a/Coder-Desktop/CoderSDK/Client.swift b/Coder-Desktop/CoderSDK/Client.swift index 239db14a..98e1c8a9 100644 --- a/Coder-Desktop/CoderSDK/Client.swift +++ b/Coder-Desktop/CoderSDK/Client.swift @@ -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]