Skip to content

Commit 5b4d965

Browse files
committed
feat: add remote folder picker to file sync GUI
1 parent 9f625fd commit 5b4d965

File tree

7 files changed

+463
-2
lines changed

7 files changed

+463
-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,255 @@
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: FilePickerItemModel.ID?
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.isLoading {
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.rootFiles) { rootItem in
39+
FilePickerItem(item: rootItem)
40+
}
41+
}.contextMenu(
42+
forSelectionType: FilePickerItemModel.ID.self,
43+
menu: { _ in },
44+
primaryAction: { selections in
45+
// Per the type of `selection`, this will only ever be a set of
46+
// one item.
47+
let files = model.findFilesByIds(ids: selections)
48+
files.forEach { file in withAnimation { file.isExpanded.toggle() } }
49+
}
50+
).listStyle(.sidebar)
51+
}
52+
Divider()
53+
HStack {
54+
Spacer()
55+
Button("Cancel", action: { dismiss() }).keyboardShortcut(.cancelAction)
56+
Button("Select", action: submit).keyboardShortcut(.defaultAction).disabled(selection == nil)
57+
}.padding(20)
58+
}
59+
.onAppear {
60+
model.loadRoot()
61+
}
62+
.onReceive(inspection.notice) { inspection.visit(self, $0) } // ViewInspector
63+
}
64+
65+
private func submit() {
66+
guard let selection else { return }
67+
let files = model.findFilesByIds(ids: [selection])
68+
if let file = files.first {
69+
outputAbsPath = file.absolute_path
70+
}
71+
dismiss()
72+
}
73+
}
74+
75+
@MainActor
76+
class FilePickerModel: ObservableObject {
77+
@Published var rootFiles: [FilePickerItemModel] = []
78+
@Published var isLoading: Bool = false
79+
@Published var error: ClientError?
80+
81+
let client: Client
82+
83+
init(host: String) {
84+
client = Client(url: URL(string: "http://\(host):4")!)
85+
}
86+
87+
func loadRoot() {
88+
error = nil
89+
isLoading = true
90+
Task {
91+
defer { isLoading = false }
92+
do throws(ClientError) {
93+
rootFiles = try await client
94+
.listAgentDirectory(.init(path: [], relativity: .root))
95+
.toModels(client: Binding(get: { self.client }, set: { _ in }), path: [])
96+
} catch {
97+
self.error = error
98+
}
99+
}
100+
}
101+
102+
func findFilesByIds(ids: Set<FilePickerItemModel.ID>) -> [FilePickerItemModel] {
103+
var result: [FilePickerItemModel] = []
104+
105+
for id in ids {
106+
if let file = findFileByPath(path: id, in: rootFiles) {
107+
result.append(file)
108+
}
109+
}
110+
111+
return result
112+
}
113+
114+
private func findFileByPath(path: [String], in files: [FilePickerItemModel]?) -> FilePickerItemModel? {
115+
guard let files, !path.isEmpty else { return nil }
116+
117+
if let file = files.first(where: { $0.name == path[0] }) {
118+
if path.count == 1 {
119+
return file
120+
}
121+
// Array slices are just views, so this isn't expensive
122+
return findFileByPath(path: Array(path[1...]), in: file.contents)
123+
}
124+
125+
return nil
126+
}
127+
}
128+
129+
struct FilePickerItem: View {
130+
@ObservedObject var item: FilePickerItemModel
131+
132+
var body: some View {
133+
Group {
134+
if item.dir {
135+
directory
136+
} else {
137+
Label(item.name, systemImage: "doc")
138+
.help(item.absolute_path)
139+
.selectionDisabled()
140+
.foregroundColor(.secondary)
141+
}
142+
}
143+
}
144+
145+
private var directory: some View {
146+
DisclosureGroup(isExpanded: $item.isExpanded) {
147+
if let contents = item.contents {
148+
ForEach(contents) { item in
149+
FilePickerItem(item: item)
150+
}
151+
}
152+
} label: {
153+
Label {
154+
Text(item.name)
155+
ZStack {
156+
ProgressView().controlSize(.small).opacity(item.isLoading && item.error == nil ? 1 : 0)
157+
Image(systemName: "exclamationmark.triangle.fill")
158+
.opacity(item.error != nil ? 1 : 0)
159+
}
160+
} icon: {
161+
Image(systemName: "folder")
162+
}.help(item.error != nil ? item.error!.description : item.absolute_path)
163+
}
164+
}
165+
}
166+
167+
@MainActor
168+
class FilePickerItemModel: Identifiable, ObservableObject {
169+
nonisolated let id: [String]
170+
let name: String
171+
// Components of the path as an array
172+
let path: [String]
173+
let absolute_path: String
174+
let dir: Bool
175+
176+
// This being a binding is pretty important performance-wise, as it's a struct
177+
// that would otherwise be recreated every time the the item row is rendered.
178+
// Removing the binding results in very noticeable lag when scrolling a file tree.
179+
@Binding var client: Client
180+
181+
@Published var contents: [FilePickerItemModel]?
182+
@Published var isLoading = false
183+
@Published var error: ClientError?
184+
@Published private var innerIsExpanded = false
185+
var isExpanded: Bool {
186+
get { innerIsExpanded }
187+
set {
188+
if !newValue {
189+
withAnimation { self.innerIsExpanded = false }
190+
} else {
191+
Task {
192+
self.loadContents()
193+
}
194+
}
195+
}
196+
}
197+
198+
init(
199+
name: String,
200+
client: Binding<Client>,
201+
absolute_path: String,
202+
path: [String],
203+
dir: Bool = false,
204+
contents: [FilePickerItemModel]? = nil
205+
) {
206+
self.name = name
207+
_client = client
208+
self.path = path
209+
self.dir = dir
210+
self.absolute_path = absolute_path
211+
self.contents = contents
212+
213+
// Swift Arrays are COW
214+
id = path
215+
}
216+
217+
func loadContents() {
218+
self.error = nil
219+
withAnimation { isLoading = true }
220+
Task {
221+
defer {
222+
withAnimation {
223+
isLoading = false
224+
innerIsExpanded = true
225+
}
226+
}
227+
do throws(ClientError) {
228+
contents = try await client
229+
.listAgentDirectory(.init(path: path, relativity: .root))
230+
.toModels(client: $client, path: path)
231+
} catch {
232+
self.error = error
233+
}
234+
}
235+
}
236+
}
237+
238+
extension LSResponse {
239+
@MainActor
240+
func toModels(client: Binding<Client>, path: [String]) -> [FilePickerItemModel] {
241+
contents.compactMap { file in
242+
// Filter dotfiles from the picker
243+
guard !file.name.hasPrefix(".") else { return nil }
244+
245+
return FilePickerItemModel(
246+
name: file.name,
247+
client: client,
248+
absolute_path: file.absolute_path_string,
249+
path: path + [file.name],
250+
dir: file.is_dir,
251+
contents: nil
252+
)
253+
}
254+
}
255+
}

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(workspace == nil)
58+
.help(workspace == 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: workspace!.primaryHost!, outputAbsPath: $remotePath)
87+
.frame(width: 300, height: 400)
7588
}
7689
}
7790

0 commit comments

Comments
 (0)