Skip to content

Commit de604d7

Browse files
authoredApr 9, 2025··

File tree

8 files changed

+446
-2
lines changed

8 files changed

+446
-2
lines changed
 

Diff for: ‎Coder-Desktop/Coder-Desktop/Info.plist

+9
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
33
<plist version="1.0">
44
<dict>
5+
<key>NSAppTransportSecurity</key>
6+
<dict>
7+
<!--
8+
Required to make HTTP (not HTTPS) requests to workspace agents
9+
(i.e. workspace.coder:4). These are already encrypted over wireguard.
10+
-->
11+
<key>NSAllowsArbitraryLoads</key>
12+
<true/>
13+
</dict>
514
<key>NetworkExtension</key>
615
<dict>
716
<key>NEMachServiceName</key>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import CoderSDK
2+
import Foundation
3+
import SwiftUI
4+
5+
struct FilePicker: View {
6+
@Environment(\.dismiss) var dismiss
7+
@StateObject private var model: FilePickerModel
8+
@State private var selection: FilePickerEntryModel?
9+
10+
@Binding var outputAbsPath: String
11+
12+
let inspection = Inspection<Self>()
13+
14+
init(
15+
host: String,
16+
outputAbsPath: Binding<String>
17+
) {
18+
_model = StateObject(wrappedValue: FilePickerModel(host: host))
19+
_outputAbsPath = outputAbsPath
20+
}
21+
22+
var body: some View {
23+
VStack(spacing: 0) {
24+
if model.rootIsLoading {
25+
Spacer()
26+
ProgressView()
27+
.controlSize(.large)
28+
Spacer()
29+
} else if let loadError = model.error {
30+
Text("\(loadError.description)")
31+
.font(.headline)
32+
.foregroundColor(.red)
33+
.multilineTextAlignment(.center)
34+
.frame(maxWidth: .infinity, maxHeight: .infinity)
35+
.padding()
36+
} else {
37+
List(selection: $selection) {
38+
ForEach(model.rootEntries) { entry in
39+
FilePickerEntry(entry: entry).tag(entry)
40+
}
41+
}.contextMenu(
42+
forSelectionType: FilePickerEntryModel.self,
43+
menu: { _ in },
44+
primaryAction: { selections in
45+
// Per the type of `selection`, this will only ever be a set of
46+
// one entry.
47+
selections.forEach { entry in withAnimation { entry.isExpanded.toggle() } }
48+
}
49+
).listStyle(.sidebar)
50+
}
51+
Divider()
52+
HStack {
53+
Spacer()
54+
Button("Cancel", action: { dismiss() }).keyboardShortcut(.cancelAction)
55+
Button("Select", action: submit).keyboardShortcut(.defaultAction).disabled(selection == nil)
56+
}.padding(20)
57+
}
58+
.onAppear {
59+
model.loadRoot()
60+
}
61+
.onReceive(inspection.notice) { inspection.visit(self, $0) } // ViewInspector
62+
}
63+
64+
private func submit() {
65+
guard let selection else { return }
66+
outputAbsPath = selection.absolute_path
67+
dismiss()
68+
}
69+
}
70+
71+
@MainActor
72+
class FilePickerModel: ObservableObject {
73+
@Published var rootEntries: [FilePickerEntryModel] = []
74+
@Published var rootIsLoading: Bool = false
75+
@Published var error: ClientError?
76+
77+
// It's important that `AgentClient` is a reference type (class)
78+
// as we were having performance issues with a struct (unless it was a binding).
79+
let client: AgentClient
80+
81+
init(host: String) {
82+
client = AgentClient(agentHost: host)
83+
}
84+
85+
func loadRoot() {
86+
error = nil
87+
rootIsLoading = true
88+
Task {
89+
defer { rootIsLoading = false }
90+
do throws(ClientError) {
91+
rootEntries = try await client
92+
.listAgentDirectory(.init(path: [], relativity: .root))
93+
.toModels(client: client)
94+
} catch {
95+
self.error = error
96+
}
97+
}
98+
}
99+
}
100+
101+
struct FilePickerEntry: View {
102+
@ObservedObject var entry: FilePickerEntryModel
103+
104+
var body: some View {
105+
Group {
106+
if entry.dir {
107+
directory
108+
} else {
109+
Label(entry.name, systemImage: "doc")
110+
.help(entry.absolute_path)
111+
.selectionDisabled()
112+
.foregroundColor(.secondary)
113+
}
114+
}
115+
}
116+
117+
private var directory: some View {
118+
DisclosureGroup(isExpanded: $entry.isExpanded) {
119+
if let entries = entry.entries {
120+
ForEach(entries) { entry in
121+
FilePickerEntry(entry: entry).tag(entry)
122+
}
123+
}
124+
} label: {
125+
Label {
126+
Text(entry.name)
127+
ZStack {
128+
ProgressView().controlSize(.small).opacity(entry.isLoading && entry.error == nil ? 1 : 0)
129+
Image(systemName: "exclamationmark.triangle.fill")
130+
.opacity(entry.error != nil ? 1 : 0)
131+
}
132+
} icon: {
133+
Image(systemName: "folder")
134+
}.help(entry.error != nil ? entry.error!.description : entry.absolute_path)
135+
}
136+
}
137+
}
138+
139+
@MainActor
140+
class FilePickerEntryModel: Identifiable, Hashable, ObservableObject {
141+
nonisolated let id: [String]
142+
let name: String
143+
// Components of the path as an array
144+
let path: [String]
145+
let absolute_path: String
146+
let dir: Bool
147+
148+
let client: AgentClient
149+
150+
@Published var entries: [FilePickerEntryModel]?
151+
@Published var isLoading = false
152+
@Published var error: ClientError?
153+
@Published private var innerIsExpanded = false
154+
var isExpanded: Bool {
155+
get { innerIsExpanded }
156+
set {
157+
if !newValue {
158+
withAnimation { self.innerIsExpanded = false }
159+
} else {
160+
Task {
161+
self.loadEntries()
162+
}
163+
}
164+
}
165+
}
166+
167+
init(
168+
name: String,
169+
client: AgentClient,
170+
absolute_path: String,
171+
path: [String],
172+
dir: Bool = false,
173+
entries: [FilePickerEntryModel]? = nil
174+
) {
175+
self.name = name
176+
self.client = client
177+
self.path = path
178+
self.dir = dir
179+
self.absolute_path = absolute_path
180+
self.entries = entries
181+
182+
// Swift Arrays are copy on write
183+
id = path
184+
}
185+
186+
func loadEntries() {
187+
self.error = nil
188+
withAnimation { isLoading = true }
189+
Task {
190+
defer {
191+
withAnimation {
192+
isLoading = false
193+
innerIsExpanded = true
194+
}
195+
}
196+
do throws(ClientError) {
197+
entries = try await client
198+
.listAgentDirectory(.init(path: path, relativity: .root))
199+
.toModels(client: client)
200+
} catch {
201+
self.error = error
202+
}
203+
}
204+
}
205+
206+
nonisolated static func == (lhs: FilePickerEntryModel, rhs: FilePickerEntryModel) -> Bool {
207+
lhs.id == rhs.id
208+
}
209+
210+
nonisolated func hash(into hasher: inout Hasher) {
211+
hasher.combine(id)
212+
}
213+
}
214+
215+
extension LSResponse {
216+
@MainActor
217+
func toModels(client: AgentClient) -> [FilePickerEntryModel] {
218+
contents.compactMap { entry in
219+
// Filter dotfiles from the picker
220+
guard !entry.name.hasPrefix(".") else { return nil }
221+
222+
return FilePickerEntryModel(
223+
name: entry.name,
224+
client: client,
225+
absolute_path: entry.absolute_path_string,
226+
path: self.absolute_path + [entry.name],
227+
dir: entry.is_dir,
228+
entries: nil
229+
)
230+
}
231+
}
232+
}

Diff for: ‎Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift

+14-1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ struct FileSyncSessionModal<VPN: VPNService, FS: FileSyncDaemon>: View {
1313

1414
@State private var loading: Bool = false
1515
@State private var createError: DaemonError?
16+
@State private var pickingRemote: Bool = false
1617

1718
var body: some View {
1819
let agents = vpn.menuState.onlineAgents
@@ -46,7 +47,16 @@ struct FileSyncSessionModal<VPN: VPNService, FS: FileSyncDaemon>: View {
4647
}
4748
}
4849
Section {
49-
TextField("Remote Path", text: $remotePath)
50+
HStack(spacing: 5) {
51+
TextField("Remote Path", text: $remotePath)
52+
Spacer()
53+
Button {
54+
pickingRemote = true
55+
} label: {
56+
Image(systemName: "folder")
57+
}.disabled(remoteHostname == nil)
58+
.help(remoteHostname == nil ? "Select a workspace first" : "Open File Picker")
59+
}
5060
}
5161
}.formStyle(.grouped).scrollDisabled(true).padding(.horizontal)
5262
Divider()
@@ -72,6 +82,9 @@ struct FileSyncSessionModal<VPN: VPNService, FS: FileSyncDaemon>: View {
7282
set: { if !$0 { createError = nil } }
7383
)) {} message: {
7484
Text(createError?.description ?? "An unknown error occurred.")
85+
}.sheet(isPresented: $pickingRemote) {
86+
FilePicker(host: remoteHostname!, outputAbsPath: $remotePath)
87+
.frame(width: 300, height: 400)
7588
}
7689
}
7790

0 commit comments

Comments
 (0)