Skip to content

feat: add stubbed file sync UI #116

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Mar 28, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
Original file line number Diff line number Diff line change
@@ -23,6 +23,12 @@ struct DesktopApp: App {
.environmentObject(appDelegate.state)
}
.windowResizability(.contentSize)
Window("Coder File Sync", id: Windows.fileSync.rawValue) {
FileSyncConfig<CoderVPNService, MutagenDaemon>()
.environmentObject(appDelegate.state)
.environmentObject(appDelegate.fileSyncDaemon)
.environmentObject(appDelegate.vpn)
}
}
}

@@ -61,9 +67,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
await self.state.handleTokenExpiry()
}
}, content: {
VPNMenu<CoderVPNService>().frame(width: 256)
VPNMenu<CoderVPNService, MutagenDaemon>().frame(width: 256)
.environmentObject(self.vpn)
.environmentObject(self.state)
.environmentObject(self.fileSyncDaemon)
}
))
// Subscribe to system VPN updates
24 changes: 24 additions & 0 deletions Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import VPNLib

@MainActor
final class PreviewFileSync: FileSyncDaemon {
var sessionState: [VPNLib.FileSyncSession] = []

var state: DaemonState = .running

init() {}

func refreshSessions() async {}

func start() async throws(DaemonError) {
state = .running
}

func stop() async {
state = .stopped
}

func createSession(localPath _: String, agentHost _: String, remotePath _: String) async throws(DaemonError) {}

func deleteSessions(ids _: [String]) async throws(VPNLib.DaemonError) {}
}
6 changes: 5 additions & 1 deletion Coder-Desktop/Coder-Desktop/VPN/MenuState.swift
Original file line number Diff line number Diff line change
@@ -2,7 +2,7 @@ import Foundation
import SwiftUI
import VPNLib

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

var onlineAgents: [Agent] {
agents.map(\.value).filter { $0.primaryHost != nil }
}

mutating func clear() {
agents.removeAll()
workspaces.removeAll()
118 changes: 118 additions & 0 deletions Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import SwiftUI
import VPNLib

struct FileSyncConfig<VPN: VPNService, FS: FileSyncDaemon>: View {
@EnvironmentObject var vpn: VPN
@EnvironmentObject var fileSync: FS

@State private var selection: FileSyncSession.ID?
@State private var addingNewSession: Bool = false
@State private var editingSession: FileSyncSession?

@State private var loading: Bool = false
@State private var deleteError: DaemonError?

var body: some View {
Group {
Table(fileSync.sessionState, selection: $selection) {
TableColumn("Local Path") {
Text($0.alphaPath).help($0.alphaPath)
}.width(min: 200, ideal: 240)
TableColumn("Workspace", value: \.agentHost)
.width(min: 100, ideal: 120)
TableColumn("Remote Path", value: \.betaPath)
.width(min: 100, ideal: 120)
TableColumn("Status") { $0.status.body }
.width(min: 80, ideal: 100)
TableColumn("Size") { item in
Text(item.size)
}
.width(min: 60, ideal: 80)
}
.contextMenu(forSelectionType: FileSyncSession.ID.self, menu: { _ in },
primaryAction: { selectedSessions in
if let session = selectedSessions.first {
editingSession = fileSync.sessionState.first(where: { $0.id == session })
}
})
.frame(minWidth: 400, minHeight: 200)
.padding(.bottom, 25)
.overlay(alignment: .bottom) {
VStack(alignment: .leading, spacing: 0) {
Divider()
HStack(spacing: 0) {
Button {
addingNewSession = true
} label: {
Image(systemName: "plus")
.frame(width: 24, height: 24)
}.disabled(vpn.menuState.agents.isEmpty)
Divider()
Button {
Task {
loading = true
defer { loading = false }
do throws(DaemonError) {
try await fileSync.deleteSessions(ids: [selection!])
} catch {
deleteError = error
}
await fileSync.refreshSessions()
selection = nil
}
} label: {
Image(systemName: "minus").frame(width: 24, height: 24)
}.disabled(selection == nil)
if let selection {
if let selectedSession = fileSync.sessionState.first(where: { $0.id == selection }) {
Divider()
Button {
// TODO: Pause & Unpause
} label: {
switch selectedSession.status {
case .paused:
Image(systemName: "play").frame(width: 24, height: 24)
default:
Image(systemName: "pause").frame(width: 24, height: 24)
}
}
}
}
}
.buttonStyle(.borderless)
}
.background(.primary.opacity(0.04))
.fixedSize(horizontal: false, vertical: true)
}
}.sheet(isPresented: $addingNewSession) {
FileSyncSessionModal<VPN, FS>()
.frame(width: 700)
}.sheet(item: $editingSession) { session in
FileSyncSessionModal<VPN, FS>(existingSession: session)
.frame(width: 700)
}.alert("Error", isPresented: Binding(
get: { deleteError != nil },
set: { isPresented in
if !isPresented {
deleteError = nil
}
}
)) {} message: {
Text(deleteError?.description ?? "An unknown error occurred.")
}.task {
while !Task.isCancelled {
await fileSync.refreshSessions()
try? await Task.sleep(for: .seconds(2))
}
}.disabled(loading)
}
}

#if DEBUG
#Preview {
FileSyncConfig<PreviewVPN, PreviewFileSync>()
.environmentObject(AppState(persistent: false))
.environmentObject(PreviewVPN())
.environmentObject(PreviewFileSync())
}
#endif
100 changes: 100 additions & 0 deletions Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import SwiftUI
import VPNLib

struct FileSyncSessionModal<VPN: VPNService, FS: FileSyncDaemon>: View {
var existingSession: FileSyncSession?
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var vpn: VPN
@EnvironmentObject private var fileSync: FS

@State private var localPath: String = ""
@State private var workspace: Agent?
@State private var remotePath: String = ""

@State private var loading: Bool = false
@State private var createError: DaemonError?

var body: some View {
let agents = vpn.menuState.onlineAgents
VStack(spacing: 0) {
Form {
Section {
HStack(spacing: 5) {
TextField("Local Path", text: $localPath)
Spacer()
Button {
let panel = NSOpenPanel()
panel.directoryURL = FileManager.default.homeDirectoryForCurrentUser
panel.allowsMultipleSelection = false
panel.canChooseDirectories = true
panel.canChooseFiles = false
if panel.runModal() == .OK {
localPath = panel.url?.path(percentEncoded: false) ?? "<none>"
}
} label: {
Image(systemName: "folder")
}
}
}
Section {
Picker("Workspace", selection: $workspace) {
ForEach(agents, id: \.id) { agent in
Text(agent.primaryHost!).tag(agent)
}
// HACK: Silence error logs for no-selection.
Divider().tag(nil as Agent?)
}
}
Section {
TextField("Remote Path", text: $remotePath)
}
}.formStyle(.grouped).scrollDisabled(true).padding(.horizontal)
Divider()
HStack {
Spacer()
Button("Cancel", action: { dismiss() }).keyboardShortcut(.cancelAction)
Button(existingSession == nil ? "Add" : "Save") { Task { await submit() }}
.keyboardShortcut(.defaultAction)
}.padding(20)
}.onAppear {
if let existingSession {
localPath = existingSession.alphaPath
workspace = agents.first { $0.primaryHost == existingSession.agentHost }
remotePath = existingSession.betaPath
} else {
// Set the picker to the first agent by default
workspace = agents.first
}
}.disabled(loading)
.alert("Error", isPresented: Binding(
get: { createError != nil },
set: { if $0 { createError = nil } }
)) {} message: {
Text(createError?.description ?? "An unknown error occurred.")
}
}

func submit() async {
createError = nil
guard let workspace else {
return
}
loading = true
defer { loading = false }
do throws(DaemonError) {
if let existingSession {
// TODO: Support selecting & deleting multiple sessions at once
try await fileSync.deleteSessions(ids: [existingSession.id])
}
try await fileSync.createSession(
localPath: localPath,
agentHost: workspace.primaryHost!,
remotePath: remotePath
)
} catch {
createError = error
return
}
dismiss()
}
}
6 changes: 2 additions & 4 deletions Coder-Desktop/Coder-Desktop/Views/LoginForm.swift
Original file line number Diff line number Diff line change
@@ -48,10 +48,8 @@ struct LoginForm: View {
loginError = nil
}
}
)) {
Button("OK", role: .cancel) {}.keyboardShortcut(.defaultAction)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I discovered this was unnecessary and removed it everywhere. An OK button always appears on alerts, and it always gets selected by pressing enter.

} message: {
Text(loginError?.description ?? "")
)) {} message: {
Text(loginError?.description ?? "An unknown error occurred.")
}.disabled(loading)
.frame(width: 550)
.fixedSize()
Original file line number Diff line number Diff line change
@@ -15,7 +15,7 @@ struct LiteralHeadersSection<VPN: VPNService>: View {
Toggle(isOn: $state.useLiteralHeaders) {
Text("HTTP Headers")
Text("When enabled, these headers will be included on all outgoing HTTP requests.")
if vpn.state != .disabled { Text("Cannot be modified while Coder Connect is enabled.") }
if !vpn.state.canBeStarted { Text("Cannot be modified while Coder Connect is enabled.") }
}
.controlSize(.large)

@@ -65,7 +65,7 @@ struct LiteralHeadersSection<VPN: VPNService>: View {
LiteralHeaderModal(existingHeader: header)
}.onTapGesture {
selectedHeader = nil
}.disabled(vpn.state != .disabled)
}.disabled(!vpn.state.canBeStarted)
.onReceive(inspection.notice) { inspection.visit(self, $0) } // ViewInspector
}
}
16 changes: 16 additions & 0 deletions Coder-Desktop/Coder-Desktop/Views/StatusDot.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import SwiftUI

struct StatusDot: View {
let color: Color

var body: some View {
ZStack {
Circle()
.fill(color.opacity(0.4))
.frame(width: 12, height: 12)
Circle()
.fill(color.opacity(1.0))
.frame(width: 7, height: 7)
}
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import SwiftUI
import VPNLib

struct VPNMenu<VPN: VPNService>: View {
struct VPNMenu<VPN: VPNService, FS: FileSyncDaemon>: View {
@EnvironmentObject var vpn: VPN
@EnvironmentObject var fileSync: FS
@EnvironmentObject var state: AppState
@Environment(\.openSettings) private var openSettings
@Environment(\.openWindow) private var openWindow
@@ -60,6 +62,24 @@ struct VPNMenu<VPN: VPNService>: View {
}.buttonStyle(.plain)
TrayDivider()
}
if vpn.state == .connected {
Button {
openWindow(id: .fileSync)
} label: {
ButtonRowView {
HStack {
// TODO: A future PR will provide users a way to recover from a daemon failure without
// needing to restart the app
if case .failed = fileSync.state, sessionsHaveError(fileSync.sessionState) {
Image(systemName: "exclamationmark.arrow.trianglehead.2.clockwise.rotate.90")
.frame(width: 12, height: 12).help("One or more sync sessions have errors")
}
Text("File sync")
}
}
}.buttonStyle(.plain)
TrayDivider()
}
if vpn.state == .failed(.systemExtensionError(.needsUserApproval)) {
Button {
openSystemExtensionSettings()
@@ -119,8 +139,9 @@ func openSystemExtensionSettings() {
appState.login(baseAccessURL: URL(string: "http://127.0.0.1:8080")!, sessionToken: "")
// appState.clearSession()

return VPNMenu<PreviewVPN>().frame(width: 256)
return VPNMenu<PreviewVPN, PreviewFileSync>().frame(width: 256)
.environmentObject(PreviewVPN())
.environmentObject(appState)
.environmentObject(PreviewFileSync())
}
#endif
Original file line number Diff line number Diff line change
@@ -70,14 +70,7 @@ struct MenuItemView: View {
HStack(spacing: 0) {
Link(destination: wsURL) {
HStack(spacing: Theme.Size.trayPadding) {
ZStack {
Circle()
.fill(item.status.color.opacity(0.4))
.frame(width: 12, height: 12)
Circle()
.fill(item.status.color.opacity(1.0))
.frame(width: 7, height: 7)
}
StatusDot(color: item.status.color)
Text(itemName).lineLimit(1).truncationMode(.tail)
Spacer()
}.padding(.horizontal, Theme.Size.trayPadding)
1 change: 1 addition & 0 deletions Coder-Desktop/Coder-Desktop/Windows.swift
Original file line number Diff line number Diff line change
@@ -3,6 +3,7 @@ import SwiftUI
// Window IDs
enum Windows: String {
case login
case fileSync
}

extension OpenWindowAction {
24 changes: 24 additions & 0 deletions Coder-Desktop/Coder-DesktopTests/Util.swift
Original file line number Diff line number Diff line change
@@ -3,6 +3,7 @@ import Combine
import NetworkExtension
import SwiftUI
import ViewInspector
import VPNLib

@MainActor
class MockVPNService: VPNService, ObservableObject {
@@ -26,4 +27,27 @@ class MockVPNService: VPNService, ObservableObject {
var startWhenReady: Bool = false
}

@MainActor
class MockFileSyncDaemon: FileSyncDaemon {
var sessionState: [VPNLib.FileSyncSession] = []

func refreshSessions() async {}

func deleteSessions(ids _: [String]) async throws(VPNLib.DaemonError) {}

var state: VPNLib.DaemonState = .running

func start() async throws(VPNLib.DaemonError) {
return
}

func stop() async {}

func listSessions() async throws -> [VPNLib.FileSyncSession] {
[]
}

func createSession(localPath _: String, agentHost _: String, remotePath _: String) async throws(DaemonError) {}
}

extension Inspection: @unchecked Sendable, @retroactive InspectionEmissary {}
8 changes: 5 additions & 3 deletions Coder-Desktop/Coder-DesktopTests/VPNMenuTests.swift
Original file line number Diff line number Diff line change
@@ -7,15 +7,17 @@ import ViewInspector
@Suite(.timeLimit(.minutes(1)))
struct VPNMenuTests {
let vpn: MockVPNService
let fsd: MockFileSyncDaemon
let state: AppState
let sut: VPNMenu<MockVPNService>
let sut: VPNMenu<MockVPNService, MockFileSyncDaemon>
let view: any View

init() {
vpn = MockVPNService()
state = AppState(persistent: false)
sut = VPNMenu<MockVPNService>()
view = sut.environmentObject(vpn).environmentObject(state)
sut = VPNMenu<MockVPNService, MockFileSyncDaemon>()
fsd = MockFileSyncDaemon()
view = sut.environmentObject(vpn).environmentObject(state).environmentObject(fsd)
}

@Test
43 changes: 20 additions & 23 deletions Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
Original file line number Diff line number Diff line change
@@ -9,19 +9,12 @@ import SwiftUI
@MainActor
public protocol FileSyncDaemon: ObservableObject {
var state: DaemonState { get }
var sessionState: [FileSyncSession] { get }
func start() async throws(DaemonError)
func stop() async
func listSessions() async throws -> [FileSyncSession]
func createSession(with: FileSyncSession) async throws
}

public struct FileSyncSession {
public let id: String
public let name: String
public let localPath: URL
public let workspace: String
public let agent: String
public let remotePath: URL
func refreshSessions() async
func createSession(localPath: String, agentHost: String, remotePath: String) async throws(DaemonError)
func deleteSessions(ids: [String]) async throws(DaemonError)
}

@MainActor
@@ -41,6 +34,8 @@ public class MutagenDaemon: FileSyncDaemon {
}
}

@Published public var sessionState: [FileSyncSession] = []

private var mutagenProcess: Subprocess?
private let mutagenPath: URL!
private let mutagenDataDirectory: URL
@@ -79,7 +74,7 @@ public class MutagenDaemon: FileSyncDaemon {
state = .failed(error)
return
}
await stopIfNoSessions()
await refreshSessions()
}
}

@@ -227,6 +222,7 @@ public class MutagenDaemon: FileSyncDaemon {
let process = Subprocess([mutagenPath.path, "daemon", "run"])
process.environment = [
"MUTAGEN_DATA_DIRECTORY": mutagenDataDirectory.path,
"MUTAGEN_SSH_PATH": "/usr/bin",
]
logger.info("setting mutagen data directory: \(self.mutagenDataDirectory.path, privacy: .public)")
return process
@@ -256,27 +252,28 @@ public class MutagenDaemon: FileSyncDaemon {
}
}

public func listSessions() async throws -> [FileSyncSession] {
guard case .running = state else {
return []
}
public func refreshSessions() async {
guard case .running = state else { return }
// TODO: Implement
return []
}

public func createSession(with _: FileSyncSession) async throws {
public func createSession(
localPath _: String,
agentHost _: String,
remotePath _: String
) async throws(DaemonError) {
if case .stopped = state {
do throws(DaemonError) {
try await start()
} catch {
state = .failed(error)
return
throw error
}
}
// TODO: Add Session
// TODO: Add session
}

public func deleteSession() async throws {
public func deleteSessions(ids _: [String]) async throws(DaemonError) {
// TODO: Delete session
await stopIfNoSessions()
}
@@ -346,7 +343,7 @@ public enum DaemonError: Error {
case terminatedUnexpectedly
case grpcFailure(Error)

var description: String {
public var description: String {
switch self {
case let .daemonStartFailure(error):
"Daemon start failure: \(error)"
@@ -361,5 +358,5 @@ public enum DaemonError: Error {
}
}

var localizedDescription: String { description }
public var localizedDescription: String { description }
}
66 changes: 66 additions & 0 deletions Coder-Desktop/VPNLib/FileSync/FileSyncSession.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import SwiftUI

public struct FileSyncSession: Identifiable {
public let id: String
public let alphaPath: String
public let agentHost: String
public let betaPath: String
public let status: FileSyncStatus
public let size: String
}

public enum FileSyncStatus {
case unknown
case error(String)
case ok
case paused
case needsAttention(String)
case working(String)

public var color: Color {
switch self {
case .ok:
.white
case .paused:
.secondary
case .unknown:
.red
case .error:
.red
case .needsAttention:
.orange
case .working:
.white
}
}

public var description: String {
switch self {
case .unknown:
"Unknown"
case let .error(msg):
msg
case .ok:
"Watching"
case .paused:
"Paused"
case let .needsAttention(msg):
msg
case let .working(msg):
msg
}
}

public var body: some View {
Text(description).foregroundColor(color)
}
}

public func sessionsHaveError(_ sessions: [FileSyncSession]) -> Bool {
for session in sessions {
if case .error = session.status {
return true
}
}
return false
}