Skip to content

Commit 93a8e1c

Browse files
committed
feat: add stubbed file sync UI
1 parent 4fb7970 commit 93a8e1c

19 files changed

+414
-46
lines changed

Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift

+10-3
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ struct DesktopApp: App {
2323
.environmentObject(appDelegate.state)
2424
}
2525
.windowResizability(.contentSize)
26+
Window("File Sync", id: Windows.fileSync.rawValue) {
27+
FileSyncConfig<CoderVPNService, MutagenDaemon>()
28+
.environmentObject(appDelegate.state)
29+
.environmentObject(appDelegate.fileSyncDaemon)
30+
.environmentObject(appDelegate.vpn)
31+
}
2632
}
2733
}
2834

@@ -41,9 +47,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
4147
}
4248
vpn.installSystemExtension()
4349
#if arch(arm64)
44-
let mutagenBinary = "mutagen-darwin-arm64"
50+
let mutagenBinary = "mutagen-darwin-arm64"
4551
#elseif arch(x86_64)
46-
let mutagenBinary = "mutagen-darwin-amd64"
52+
let mutagenBinary = "mutagen-darwin-amd64"
4753
#endif
4854
fileSyncDaemon = MutagenDaemon(
4955
mutagenPath: Bundle.main.url(forResource: mutagenBinary, withExtension: nil)
@@ -61,9 +67,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
6167
await self.state.handleTokenExpiry()
6268
}
6369
}, content: {
64-
VPNMenu<CoderVPNService>().frame(width: 256)
70+
VPNMenu<CoderVPNService, MutagenDaemon>().frame(width: 256)
6571
.environmentObject(self.vpn)
6672
.environmentObject(self.state)
73+
.environmentObject(self.fileSyncDaemon)
6774
}
6875
))
6976
// Subscribe to system VPN updates
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import VPNLib
2+
3+
@MainActor
4+
final class PreviewFileSync: FileSyncDaemon {
5+
var sessionState: [VPNLib.FileSyncSession] = []
6+
7+
var state: DaemonState = .running
8+
9+
init() {}
10+
11+
func refreshSessions() async {}
12+
13+
func start() async throws(DaemonError) {
14+
state = .running
15+
}
16+
17+
func stop() async {
18+
state = .stopped
19+
}
20+
21+
func createSession(localPath _: String, agentHost _: String, remotePath _: String) async throws(DaemonError) {}
22+
23+
func deleteSessions(ids _: [String]) async throws(VPNLib.DaemonError) {}
24+
}

Coder-Desktop/Coder-Desktop/VPN/MenuState.swift

+5-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import Foundation
22
import SwiftUI
33
import VPNLib
44

5-
struct Agent: Identifiable, Equatable, Comparable {
5+
struct Agent: Identifiable, Equatable, Comparable, Hashable {
66
let id: UUID
77
let name: String
88
let status: AgentStatus
@@ -135,6 +135,10 @@ struct VPNMenuState {
135135
return items.sorted()
136136
}
137137

138+
var onlineAgents: [Agent] {
139+
agents.map(\.value).filter { $0.primaryHost != nil }
140+
}
141+
138142
mutating func clear() {
139143
agents.removeAll()
140144
workspaces.removeAll()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import SwiftUI
2+
import VPNLib
3+
4+
struct FileSyncConfig<VPN: VPNService, FS: FileSyncDaemon>: View {
5+
@EnvironmentObject var vpn: VPN
6+
@EnvironmentObject var fileSync: FS
7+
8+
@State private var selection: FileSyncSession.ID?
9+
@State private var addingNewSession: Bool = false
10+
@State private var editingSession: FileSyncSession?
11+
12+
@State private var loading: Bool = false
13+
@State private var deleteError: DaemonError?
14+
15+
var body: some View {
16+
Group {
17+
Table(fileSync.sessionState, selection: $selection) {
18+
TableColumn("Local Path") {
19+
Text($0.alphaPath).help($0.alphaPath)
20+
}.width(min: 200, ideal: 240)
21+
TableColumn("Workspace", value: \.agentHost)
22+
.width(min: 100, ideal: 120)
23+
TableColumn("Remote Path", value: \.betaPath)
24+
.width(min: 100, ideal: 120)
25+
TableColumn("Status") { $0.status.body }
26+
.width(min: 80, ideal: 100)
27+
TableColumn("Size") { item in
28+
Text(item.size)
29+
}
30+
.width(min: 60, ideal: 80)
31+
}
32+
.frame(minWidth: 400, minHeight: 200)
33+
.padding(.bottom, 25)
34+
.overlay(alignment: .bottom) {
35+
VStack(alignment: .leading, spacing: 0) {
36+
Divider()
37+
HStack(spacing: 0) {
38+
Button {
39+
addingNewSession = true
40+
} label: {
41+
Image(systemName: "plus")
42+
.frame(width: 24, height: 24)
43+
}.disabled(vpn.menuState.agents.isEmpty)
44+
Divider()
45+
Button {
46+
Task {
47+
loading = true
48+
defer { loading = false }
49+
do throws(DaemonError) {
50+
try await fileSync.deleteSessions(ids: [selection!])
51+
} catch {
52+
deleteError = error
53+
}
54+
await fileSync.refreshSessions()
55+
selection = nil
56+
}
57+
} label: {
58+
Image(systemName: "minus").frame(width: 24, height: 24)
59+
}.disabled(selection == nil)
60+
if let selection {
61+
if let selectedSession = fileSync.sessionState.first(where: { $0.id == selection }) {
62+
Divider()
63+
Button {
64+
// TODO: Pause & Unpause
65+
} label: {
66+
switch selectedSession.status {
67+
case .paused:
68+
Image(systemName: "play").frame(width: 24, height: 24)
69+
default:
70+
Image(systemName: "pause").frame(width: 24, height: 24)
71+
}
72+
}
73+
}
74+
}
75+
}
76+
.buttonStyle(.borderless)
77+
}
78+
.background(.primary.opacity(0.04))
79+
.fixedSize(horizontal: false, vertical: true)
80+
}
81+
}.sheet(isPresented: $addingNewSession) {
82+
FileSyncSessionModal<VPN, FS>()
83+
.frame(width: 700)
84+
}.sheet(item: $editingSession) { session in
85+
FileSyncSessionModal<VPN, FS>(existingSession: session)
86+
.frame(width: 700)
87+
}.alert("Error", isPresented: Binding(
88+
get: { deleteError != nil },
89+
set: { isPresented in
90+
if !isPresented {
91+
deleteError = nil
92+
}
93+
}
94+
)) {} message: {
95+
Text(deleteError?.description ?? "An unknown error occurred. This should never happen.")
96+
}.task {
97+
while !Task.isCancelled {
98+
await fileSync.refreshSessions()
99+
try? await Task.sleep(for: .seconds(2))
100+
}
101+
}.disabled(loading)
102+
}
103+
}
104+
105+
#if DEBUG
106+
#Preview {
107+
FileSyncConfig<PreviewVPN, PreviewFileSync>()
108+
.environmentObject(AppState(persistent: false))
109+
.environmentObject(PreviewVPN())
110+
.environmentObject(PreviewFileSync())
111+
}
112+
#endif
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import SwiftUI
2+
import VPNLib
3+
4+
struct FileSyncSessionModal<VPN: VPNService, FS: FileSyncDaemon>: View {
5+
var existingSession: FileSyncSession?
6+
@Environment(\.dismiss) private var dismiss
7+
@EnvironmentObject private var vpn: VPN
8+
@EnvironmentObject private var fileSync: FS
9+
10+
@State private var localPath: String = ""
11+
@State private var workspace: Agent?
12+
@State private var remotePath: String = ""
13+
14+
@State private var loading: Bool = false
15+
@State private var createError: DaemonError?
16+
17+
var body: some View {
18+
let agents = vpn.menuState.onlineAgents
19+
VStack(spacing: 0) {
20+
Form {
21+
Section {
22+
HStack(spacing: 5) {
23+
TextField("Local Path", text: $localPath)
24+
Spacer()
25+
Button {
26+
let panel = NSOpenPanel()
27+
panel.directoryURL = FileManager.default.homeDirectoryForCurrentUser
28+
panel.allowsMultipleSelection = false
29+
panel.canChooseDirectories = true
30+
panel.canChooseFiles = false
31+
if panel.runModal() == .OK {
32+
localPath = panel.url?.path(percentEncoded: false) ?? "<none>"
33+
}
34+
} label: {
35+
Image(systemName: "folder")
36+
}
37+
}
38+
}
39+
Section {
40+
Picker("Workspace", selection: $workspace) {
41+
ForEach(agents, id: \.id) { agent in
42+
Text(agent.primaryHost!).tag(agent)
43+
}
44+
// HACK: Silence error logs for no-selection.
45+
Divider().tag(nil as Agent?)
46+
}
47+
}
48+
Section {
49+
TextField("Remote Path", text: $remotePath)
50+
}
51+
}.formStyle(.grouped).scrollDisabled(true).padding(.horizontal)
52+
Divider()
53+
HStack {
54+
Spacer()
55+
if loading {
56+
ProgressView()
57+
}
58+
Button("Cancel", action: { dismiss() }).keyboardShortcut(.cancelAction)
59+
Button(existingSession == nil ? "Add" : "Save") { Task { await submit() }}
60+
.keyboardShortcut(.defaultAction)
61+
}.padding(20)
62+
}.onAppear {
63+
if let existingSession {
64+
localPath = existingSession.alphaPath
65+
workspace = agents.first { $0.primaryHost == existingSession.agentHost }
66+
remotePath = existingSession.betaPath
67+
} else {
68+
// Set the picker to the first agent by default
69+
workspace = agents.first
70+
}
71+
}.disabled(loading)
72+
.alert("Error", isPresented: Binding(
73+
get: { createError != nil },
74+
set: { if $0 { createError = nil } }
75+
)) {} message: {
76+
Text(createError?.description ?? "An unknown error occurred. This should never happen.")
77+
}
78+
}
79+
80+
func submit() async {
81+
createError = nil
82+
guard let workspace else {
83+
return
84+
}
85+
loading = true
86+
defer { loading = false }
87+
do throws(DaemonError) {
88+
if let existingSession {
89+
// TODO: Support selecting & deleting multiple sessions at once
90+
try await fileSync.deleteSessions(ids: [existingSession.id])
91+
}
92+
try await fileSync.createSession(
93+
localPath: localPath,
94+
agentHost: workspace.primaryHost!,
95+
remotePath: remotePath
96+
)
97+
} catch {
98+
createError = error
99+
return
100+
}
101+
dismiss()
102+
}
103+
}

Coder-Desktop/Coder-Desktop/Views/LoginForm.swift

+2-4
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,8 @@ struct LoginForm: View {
4848
loginError = nil
4949
}
5050
}
51-
)) {
52-
Button("OK", role: .cancel) {}.keyboardShortcut(.defaultAction)
53-
} message: {
54-
Text(loginError?.description ?? "")
51+
)) {} message: {
52+
Text(loginError?.description ?? "An unknown error occurred. This should never happen.")
5553
}.disabled(loading)
5654
.frame(width: 550)
5755
.fixedSize()

Coder-Desktop/Coder-Desktop/Views/Settings/LiteralHeadersSection.swift

+2-2
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ struct LiteralHeadersSection<VPN: VPNService>: View {
1515
Toggle(isOn: $state.useLiteralHeaders) {
1616
Text("HTTP Headers")
1717
Text("When enabled, these headers will be included on all outgoing HTTP requests.")
18-
if vpn.state != .disabled { Text("Cannot be modified while Coder Connect is enabled.") }
18+
if !vpn.state.canBeStarted { Text("Cannot be modified while Coder Connect is enabled.") }
1919
}
2020
.controlSize(.large)
2121

@@ -65,7 +65,7 @@ struct LiteralHeadersSection<VPN: VPNService>: View {
6565
LiteralHeaderModal(existingHeader: header)
6666
}.onTapGesture {
6767
selectedHeader = nil
68-
}.disabled(vpn.state != .disabled)
68+
}.disabled(!vpn.state.canBeStarted)
6969
.onReceive(inspection.notice) { inspection.visit(self, $0) } // ViewInspector
7070
}
7171
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import SwiftUI
2+
3+
struct StatusDot: View {
4+
let color: Color
5+
6+
var body: some View {
7+
ZStack {
8+
Circle()
9+
.fill(color.opacity(0.4))
10+
.frame(width: 12, height: 12)
11+
Circle()
12+
.fill(color.opacity(1.0))
13+
.frame(width: 7, height: 7)
14+
}
15+
}
16+
}

Coder-Desktop/Coder-Desktop/Views/VPNMenu.swift Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift

+23-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import SwiftUI
2+
import VPNLib
23

3-
struct VPNMenu<VPN: VPNService>: View {
4+
struct VPNMenu<VPN: VPNService, FS: FileSyncDaemon>: View {
45
@EnvironmentObject var vpn: VPN
6+
@EnvironmentObject var fileSync: FS
57
@EnvironmentObject var state: AppState
68
@Environment(\.openSettings) private var openSettings
79
@Environment(\.openWindow) private var openWindow
@@ -60,6 +62,24 @@ struct VPNMenu<VPN: VPNService>: View {
6062
}.buttonStyle(.plain)
6163
TrayDivider()
6264
}
65+
if vpn.state == .connected {
66+
Button {
67+
openWindow(id: .fileSync)
68+
} label: {
69+
ButtonRowView {
70+
HStack {
71+
// TODO: A future PR will provide users a way to recover from a daemon failure without
72+
// needing to restart the app
73+
if case .failed = fileSync.state, sessionsHaveError(fileSync.sessionState) {
74+
Image(systemName: "exclamationmark.arrow.trianglehead.2.clockwise.rotate.90")
75+
.frame(width: 12, height: 12).help("One or more sync sessions have errors")
76+
}
77+
Text("File sync")
78+
}
79+
}
80+
}.buttonStyle(.plain)
81+
TrayDivider()
82+
}
6383
if vpn.state == .failed(.systemExtensionError(.needsUserApproval)) {
6484
Button {
6585
openSystemExtensionSettings()
@@ -119,8 +139,9 @@ func openSystemExtensionSettings() {
119139
appState.login(baseAccessURL: URL(string: "http://127.0.0.1:8080")!, sessionToken: "")
120140
// appState.clearSession()
121141

122-
return VPNMenu<PreviewVPN>().frame(width: 256)
142+
return VPNMenu<PreviewVPN, PreviewFileSync>().frame(width: 256)
123143
.environmentObject(PreviewVPN())
124144
.environmentObject(appState)
145+
.environmentObject(PreviewFileSync())
125146
}
126147
#endif

0 commit comments

Comments
 (0)