Skip to content

Commit e25df16

Browse files
committed
feat: add stubbed file sync UI
1 parent 173554d commit e25df16

18 files changed

+323
-16
lines changed

Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift

+8-1
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

@@ -56,9 +62,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
5662
await self.state.handleTokenExpiry()
5763
}
5864
}, content: {
59-
VPNMenu<CoderVPNService>().frame(width: 256)
65+
VPNMenu<CoderVPNService, MutagenDaemon>().frame(width: 256)
6066
.environmentObject(self.vpn)
6167
.environmentObject(self.state)
68+
.environmentObject(self.fileSyncDaemon)
6269
}
6370
))
6471
// Subscribe to system VPN updates
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import VPNLib
2+
3+
@MainActor
4+
final class PreviewFileSync: FileSyncDaemon {
5+
var state: DaemonState = .running
6+
7+
init() {}
8+
9+
func start() async throws(DaemonError) {
10+
state = .running
11+
}
12+
13+
func stop() async {
14+
state = .stopped
15+
}
16+
17+
func listSessions() async throws -> [FileSyncSession] {
18+
[]
19+
}
20+
21+
func createSession(with _: FileSyncSession) async throws {}
22+
}

Coder-Desktop/Coder-Desktop/State.swift

+10
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,14 @@ class AppState: ObservableObject {
6363
}
6464
}
6565

66+
// Temporary feature flag
67+
@Published var showFileSyncUI: Bool = UserDefaults.standard.bool(forKey: Keys.showFileSyncUI) {
68+
didSet {
69+
guard persistent else { return }
70+
UserDefaults.standard.set(showFileSyncUI, forKey: Keys.showFileSyncUI)
71+
}
72+
}
73+
6674
func tunnelProviderProtocol() -> NETunnelProviderProtocol? {
6775
if !hasSession { return nil }
6876
let proto = NETunnelProviderProtocol()
@@ -164,6 +172,8 @@ class AppState: ObservableObject {
164172
static let literalHeaders = "LiteralHeaders"
165173
static let stopVPNOnQuit = "StopVPNOnQuit"
166174
static let startVPNOnLaunch = "StartVPNOnLaunch"
175+
176+
static let showFileSyncUI = "showFileSyncUI"
167177
}
168178
}
169179

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

+4
Original file line numberDiff line numberDiff line change
@@ -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,140 @@
1+
import SwiftUI
2+
import VPNLib
3+
4+
struct FileSyncRow: Identifiable {
5+
var id = UUID()
6+
var localPath: URL
7+
var workspace: String
8+
// This is a string as to be host-OS agnostic
9+
var remotePath: String
10+
var status: FileSyncStatus
11+
var size: String
12+
}
13+
14+
enum FileSyncStatus {
15+
case unknown
16+
case error(String)
17+
case okay
18+
case paused
19+
case needsAttention(String)
20+
case working(String)
21+
22+
var color: Color {
23+
switch self {
24+
case .okay:
25+
.white
26+
case .paused:
27+
.secondary
28+
case .unknown:
29+
.red
30+
case .error:
31+
.red
32+
case .needsAttention:
33+
.orange
34+
case .working:
35+
.white
36+
}
37+
}
38+
39+
var description: String {
40+
switch self {
41+
case .unknown:
42+
"Unknown"
43+
case let .error(msg):
44+
msg
45+
case .okay:
46+
"OK"
47+
case .paused:
48+
"Paused"
49+
case let .needsAttention(msg):
50+
msg
51+
case let .working(msg):
52+
msg
53+
}
54+
}
55+
56+
var body: some View {
57+
Text(description).foregroundColor(color)
58+
}
59+
}
60+
61+
struct FileSyncConfig<VPN: VPNService, FS: FileSyncDaemon>: View {
62+
@EnvironmentObject var vpn: VPN
63+
64+
@State private var selection: FileSyncRow.ID?
65+
@State private var addingNewSession: Bool = false
66+
@State private var items: [FileSyncRow] = []
67+
68+
var body: some View {
69+
Group {
70+
Table(items, selection: $selection) {
71+
TableColumn("Local Path") { row in
72+
Text(row.localPath.path())
73+
}.width(min: 200, ideal: 240)
74+
TableColumn("Workspace", value: \.workspace)
75+
.width(min: 100, ideal: 120)
76+
TableColumn("Remote Path", value: \.remotePath)
77+
.width(min: 100, ideal: 120)
78+
TableColumn("Status") { $0.status.body }
79+
.width(min: 80, ideal: 100)
80+
TableColumn("Size") { item in
81+
Text(item.size)
82+
}
83+
.width(min: 60, ideal: 80)
84+
}
85+
.frame(minWidth: 400, minHeight: 200)
86+
.padding(.bottom, 25)
87+
.overlay(alignment: .bottom) {
88+
VStack(alignment: .leading, spacing: 0) {
89+
Divider()
90+
HStack(spacing: 0) {
91+
Button {
92+
addingNewSession = true
93+
} label: {
94+
Image(systemName: "plus")
95+
.frame(width: 24, height: 24)
96+
}.disabled(vpn.menuState.agents.isEmpty)
97+
Divider()
98+
Button {
99+
// TODO: Remove from list
100+
} label: {
101+
Image(systemName: "minus").frame(width: 24, height: 24)
102+
}.disabled(selection == nil)
103+
if let selection {
104+
if let selectedSession = items.first(where: { $0.id == selection }) {
105+
Divider()
106+
Button {
107+
// TODO: Pause & Unpause
108+
} label: {
109+
switch selectedSession.status {
110+
case .paused:
111+
Image(systemName: "play").frame(width: 24, height: 24)
112+
default:
113+
Image(systemName: "pause").frame(width: 24, height: 24)
114+
}
115+
}
116+
}
117+
}
118+
}
119+
.buttonStyle(.borderless)
120+
}
121+
.background(.primary.opacity(0.04))
122+
.fixedSize(horizontal: false, vertical: true)
123+
}
124+
}.sheet(isPresented: $addingNewSession) {
125+
FileSyncSessionModal<VPN, FS>()
126+
.frame(width: 550)
127+
}.onTapGesture {
128+
selection = nil
129+
}
130+
}
131+
}
132+
133+
#if DEBUG
134+
#Preview {
135+
FileSyncConfig<PreviewVPN, PreviewFileSync>()
136+
.environmentObject(AppState(persistent: false))
137+
.environmentObject(PreviewVPN())
138+
.environmentObject(PreviewFileSync())
139+
}
140+
#endif
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import SwiftUI
2+
import VPNLib
3+
4+
struct FileSyncSessionModal<VPN: VPNService, FS: FileSyncDaemon>: View {
5+
var existingSession: FileSyncRow?
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: UUID?
12+
@State private var remotePath: String = ""
13+
14+
var body: some View {
15+
let agents = vpn.menuState.onlineAgents
16+
VStack(spacing: 0) {
17+
Form {
18+
Section {
19+
HStack {
20+
Text("Local Path")
21+
Text(localPath)
22+
Spacer()
23+
Button {
24+
let panel = NSOpenPanel()
25+
panel.allowsMultipleSelection = false
26+
panel.canChooseDirectories = true
27+
panel.canChooseFiles = false
28+
if panel.runModal() == .OK {
29+
localPath = panel.url?.path(percentEncoded: false) ?? "<none>"
30+
}
31+
} label: {
32+
Image(systemName: "folder")
33+
}
34+
}
35+
}
36+
Section {
37+
Picker("Workspace", selection: $workspace) {
38+
ForEach(agents) { agent in
39+
Text(agent.primaryHost!).tag(agent.id)
40+
}
41+
}
42+
}
43+
Section {
44+
TextField("Remote Path", text: $remotePath)
45+
}
46+
}.formStyle(.grouped).scrollDisabled(true).padding(.horizontal)
47+
Divider()
48+
HStack {
49+
Spacer()
50+
Button("Cancel", action: { dismiss() }).keyboardShortcut(.cancelAction)
51+
Button(existingSession == nil ? "Add" : "Save", action: submit)
52+
.keyboardShortcut(.defaultAction)
53+
}.padding(20)
54+
}.onAppear {
55+
if existingSession != nil {
56+
// TODO: Populate form
57+
} else {
58+
workspace = agents.first?.id
59+
}
60+
}
61+
}
62+
63+
func submit() {
64+
defer {
65+
// TODO: Instruct window to refresh state via gRPC
66+
dismiss()
67+
}
68+
if existingSession != nil {
69+
// TODO: Delete existing
70+
}
71+
// TODO: Insert
72+
}
73+
}

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

+5
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ struct GeneralTab: View {
1818
Text("Start Coder Connect on launch")
1919
}
2020
}
21+
Section {
22+
Toggle(isOn: $state.showFileSyncUI) {
23+
Text("Show experimental File Sync UI")
24+
}
25+
}
2126
}.formStyle(.grouped)
2227
}
2328
}

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

+18-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,19 @@ struct VPNMenu<VPN: VPNService>: View {
6062
}.buttonStyle(.plain)
6163
TrayDivider()
6264
}
65+
if state.showFileSyncUI, vpn.state == .connected {
66+
Button {
67+
openWindow(id: .fileSync)
68+
} label: {
69+
ButtonRowView {
70+
HStack {
71+
StatusDot(color: fileSync.state.color)
72+
Text("Configure file sync")
73+
}
74+
}
75+
}.buttonStyle(.plain)
76+
TrayDivider()
77+
}
6378
if vpn.state == .failed(.systemExtensionError(.needsUserApproval)) {
6479
Button {
6580
openSystemExtensionSettings()
@@ -119,8 +134,9 @@ func openSystemExtensionSettings() {
119134
appState.login(baseAccessURL: URL(string: "http://127.0.0.1:8080")!, sessionToken: "")
120135
// appState.clearSession()
121136

122-
return VPNMenu<PreviewVPN>().frame(width: 256)
137+
return VPNMenu<PreviewVPN, PreviewFileSync>().frame(width: 256)
123138
.environmentObject(PreviewVPN())
124139
.environmentObject(appState)
140+
.environmentObject(PreviewFileSync())
125141
}
126142
#endif

0 commit comments

Comments
 (0)