From db73c7ac55b4f00a361ba2f6d72d61fa4172d126 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Fri, 26 Sep 2025 14:56:20 -0700 Subject: [PATCH] feat: Enhance .gitignore and Refactor BrowserView Components - Updated .gitignore to include Xcode-specific files and directories, improving project cleanliness and preventing unnecessary files from being tracked. - Refactored `BrowserView` and `SidebarView` components to improve layout and user interaction, including adjustments to button styles and spacing for better visual consistency. - Introduced a new `FinderSidebarLocationRow` component for improved sidebar navigation, enhancing the user experience in file browsing. - Made various UI adjustments in the `InspectorDetailView` to streamline the display of file details and improve overall aesthetics. --- .gitignore | 92 +++ .../macos2/Spacedrive/Spacedrive.entitlements | 10 + .../Spacedrive.xcodeproj/project.pbxproj | 64 +- .../contents.xcworkspacedata | 7 + .../Components/ConnectivityCard.swift | 170 ++++++ .../Spacedrive/Components/IconDebugView.swift | 0 .../Components/LibrarySelector.swift | 243 ++++++++ .../Spacedrive/Components/SDButton.swift | 236 ++++++++ .../Spacedrive/Components/SDCard.swift | 182 ++++++ .../Components/SpacedriveIcon.swift | 339 +++++++++++ .../Components/SpacedriveIconExample.swift | 113 ++++ .../Components/Window/CustomTitleBar.swift | 23 + .../Window/NativeTrafficLights.swift | 158 +++++ .../Components/Window/WindowContainer.swift | 47 ++ .../Spacedrive/Spacedrive/ContentView.swift | 24 - .../DesignSystem/SpacedriveColors.swift | 83 +++ .../DesignSystem/SpacedriveTypography.swift | 183 ++++++ .../Development/DevControlsView.swift | 164 +++++ .../Development/DevWindowConfiguration.swift | 204 +++++++ .../Spacedrive/MenuBar/MenuBarManager.swift | 508 ++++++++++++++++ .../Spacedrive/Services/DaemonConnector.swift | 567 ++++++++++++++++++ .../Spacedrive/Spacedrive/SpacedriveApp.swift | 138 ++++- .../Spacedrive/State/SharedAppState.swift | 426 +++++++++++++ .../Browser/Components/ContentArea.swift | 159 +++++ .../Browser/Components/ImagePreviewView.swift | 143 +++++ .../Components/LiquidGlassButton.swift | 67 +++ .../Components/LiquidGlassSidebar.swift | 134 +++++ .../Components/SidebarLocationButton.swift | 54 ++ .../Browser/Components/TahoeSidebar.swift | 65 ++ .../Browser/Models/BrowserModels.swift | 143 +++++ .../Windows/Browser/Views/BrowserView.swift | 314 ++++++++++ .../IconShowcase/Views/IconShowcaseView.swift | 292 +++++++++ .../ViewModels/InspectorViewModel.swift | 135 +++++ .../Inspector/Views/InspectorView.swift | 422 +++++++++++++ .../Windows/Jobs/Models/JobModels.swift | 311 ++++++++++ .../Jobs/ViewModels/JobListViewModel.swift | 88 +++ .../Windows/Jobs/Views/JobCompanionView.swift | 45 ++ .../Windows/Jobs/Views/JobMonitorView.swift | 103 ++++ .../Windows/Jobs/Views/JobRowView.swift | 368 ++++++++++++ .../Components/SettingsNavigationView.swift | 193 ++++++ .../Settings/Views/SettingsContentView.swift | 306 ++++++++++ .../Windows/Settings/Views/SettingsView.swift | 73 +++ 42 files changed, 7358 insertions(+), 38 deletions(-) create mode 100644 apps/macos2/Spacedrive/Spacedrive.entitlements create mode 100644 apps/macos2/Spacedrive/Spacedrive.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 apps/macos2/Spacedrive/Spacedrive/Components/ConnectivityCard.swift create mode 100644 apps/macos2/Spacedrive/Spacedrive/Components/IconDebugView.swift create mode 100644 apps/macos2/Spacedrive/Spacedrive/Components/LibrarySelector.swift create mode 100644 apps/macos2/Spacedrive/Spacedrive/Components/SDButton.swift create mode 100644 apps/macos2/Spacedrive/Spacedrive/Components/SDCard.swift create mode 100644 apps/macos2/Spacedrive/Spacedrive/Components/SpacedriveIcon.swift create mode 100644 apps/macos2/Spacedrive/Spacedrive/Components/SpacedriveIconExample.swift create mode 100644 apps/macos2/Spacedrive/Spacedrive/Components/Window/CustomTitleBar.swift create mode 100644 apps/macos2/Spacedrive/Spacedrive/Components/Window/NativeTrafficLights.swift create mode 100644 apps/macos2/Spacedrive/Spacedrive/Components/Window/WindowContainer.swift delete mode 100644 apps/macos2/Spacedrive/Spacedrive/ContentView.swift create mode 100644 apps/macos2/Spacedrive/Spacedrive/DesignSystem/SpacedriveColors.swift create mode 100644 apps/macos2/Spacedrive/Spacedrive/DesignSystem/SpacedriveTypography.swift create mode 100644 apps/macos2/Spacedrive/Spacedrive/Development/DevControlsView.swift create mode 100644 apps/macos2/Spacedrive/Spacedrive/Development/DevWindowConfiguration.swift create mode 100644 apps/macos2/Spacedrive/Spacedrive/MenuBar/MenuBarManager.swift create mode 100644 apps/macos2/Spacedrive/Spacedrive/Services/DaemonConnector.swift create mode 100644 apps/macos2/Spacedrive/Spacedrive/State/SharedAppState.swift create mode 100644 apps/macos2/Spacedrive/Spacedrive/Windows/Browser/Components/ContentArea.swift create mode 100644 apps/macos2/Spacedrive/Spacedrive/Windows/Browser/Components/ImagePreviewView.swift create mode 100644 apps/macos2/Spacedrive/Spacedrive/Windows/Browser/Components/LiquidGlassButton.swift create mode 100644 apps/macos2/Spacedrive/Spacedrive/Windows/Browser/Components/LiquidGlassSidebar.swift create mode 100644 apps/macos2/Spacedrive/Spacedrive/Windows/Browser/Components/SidebarLocationButton.swift create mode 100644 apps/macos2/Spacedrive/Spacedrive/Windows/Browser/Components/TahoeSidebar.swift create mode 100644 apps/macos2/Spacedrive/Spacedrive/Windows/Browser/Models/BrowserModels.swift create mode 100644 apps/macos2/Spacedrive/Spacedrive/Windows/Browser/Views/BrowserView.swift create mode 100644 apps/macos2/Spacedrive/Spacedrive/Windows/IconShowcase/Views/IconShowcaseView.swift create mode 100644 apps/macos2/Spacedrive/Spacedrive/Windows/Inspector/ViewModels/InspectorViewModel.swift create mode 100644 apps/macos2/Spacedrive/Spacedrive/Windows/Inspector/Views/InspectorView.swift create mode 100644 apps/macos2/Spacedrive/Spacedrive/Windows/Jobs/Models/JobModels.swift create mode 100644 apps/macos2/Spacedrive/Spacedrive/Windows/Jobs/ViewModels/JobListViewModel.swift create mode 100644 apps/macos2/Spacedrive/Spacedrive/Windows/Jobs/Views/JobCompanionView.swift create mode 100644 apps/macos2/Spacedrive/Spacedrive/Windows/Jobs/Views/JobMonitorView.swift create mode 100644 apps/macos2/Spacedrive/Spacedrive/Windows/Jobs/Views/JobRowView.swift create mode 100644 apps/macos2/Spacedrive/Spacedrive/Windows/Settings/Components/SettingsNavigationView.swift create mode 100644 apps/macos2/Spacedrive/Spacedrive/Windows/Settings/Views/SettingsContentView.swift create mode 100644 apps/macos2/Spacedrive/Spacedrive/Windows/Settings/Views/SettingsView.swift diff --git a/.gitignore b/.gitignore index 3b54cf9ad..8c61345ed 100644 --- a/.gitignore +++ b/.gitignore @@ -284,6 +284,98 @@ $RECYCLE.BIN/ # End of https://www.toptal.com/developers/gitignore/api/macos,windows,linux,rust,node,react,turbo,vercel,nextjs,storybookjs +### Xcode ### +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +# *.xcodeproj +# +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +# .swiftpm + +.build/ + +## CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +# Pods/ +# +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +## Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +## Accio dependency management +Dependencies/ +.accio/ + +## fastlane +# +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/ + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +## Code Injection +# +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode + +iOSInjectionProject/ + ### Project ### !apps/desktop/dist !apps/web/dist diff --git a/apps/macos2/Spacedrive/Spacedrive.entitlements b/apps/macos2/Spacedrive/Spacedrive.entitlements new file mode 100644 index 000000000..32a6338b9 --- /dev/null +++ b/apps/macos2/Spacedrive/Spacedrive.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.get-task-allow + + + diff --git a/apps/macos2/Spacedrive/Spacedrive.xcodeproj/project.pbxproj b/apps/macos2/Spacedrive/Spacedrive.xcodeproj/project.pbxproj index 3ed8af8a5..f7b03e128 100644 --- a/apps/macos2/Spacedrive/Spacedrive.xcodeproj/project.pbxproj +++ b/apps/macos2/Spacedrive/Spacedrive.xcodeproj/project.pbxproj @@ -6,6 +6,10 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + 6ADA37DD2E8684310017BA3A /* SpacedriveClient in Frameworks */ = {isa = PBXBuildFile; productRef = 6ADA37DC2E8684310017BA3A /* SpacedriveClient */; }; +/* End PBXBuildFile section */ + /* Begin PBXContainerItemProxy section */ 6A0F02F22E86800300B2DEBA /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; @@ -24,7 +28,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - 6A0F02E42E86800200B2DEBA /* Spacedrive.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Spacedrive.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 6A0F02E42E86800200B2DEBA /* AppIcon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AppIcon.app; sourceTree = BUILT_PRODUCTS_DIR; }; 6A0F02F12E86800300B2DEBA /* SpacedriveTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SpacedriveTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 6A0F02FB2E86800300B2DEBA /* SpacedriveUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SpacedriveUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -52,6 +56,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 6ADA37DD2E8684310017BA3A /* SpacedriveClient in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -78,6 +83,7 @@ 6A0F02E62E86800200B2DEBA /* Spacedrive */, 6A0F02F42E86800300B2DEBA /* SpacedriveTests */, 6A0F02FE2E86800300B2DEBA /* SpacedriveUITests */, + 6ADA37DB2E8684310017BA3A /* Frameworks */, 6A0F02E52E86800200B2DEBA /* Products */, ); sourceTree = ""; @@ -85,19 +91,26 @@ 6A0F02E52E86800200B2DEBA /* Products */ = { isa = PBXGroup; children = ( - 6A0F02E42E86800200B2DEBA /* Spacedrive.app */, + 6A0F02E42E86800200B2DEBA /* AppIcon.app */, 6A0F02F12E86800300B2DEBA /* SpacedriveTests.xctest */, 6A0F02FB2E86800300B2DEBA /* SpacedriveUITests.xctest */, ); name = Products; sourceTree = ""; }; + 6ADA37DB2E8684310017BA3A /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - 6A0F02E32E86800200B2DEBA /* Spacedrive */ = { + 6A0F02E32E86800200B2DEBA /* AppIcon */ = { isa = PBXNativeTarget; - buildConfigurationList = 6A0F03052E86800300B2DEBA /* Build configuration list for PBXNativeTarget "Spacedrive" */; + buildConfigurationList = 6A0F03052E86800300B2DEBA /* Build configuration list for PBXNativeTarget "AppIcon" */; buildPhases = ( 6A0F02E02E86800200B2DEBA /* Sources */, 6A0F02E12E86800200B2DEBA /* Frameworks */, @@ -110,11 +123,12 @@ fileSystemSynchronizedGroups = ( 6A0F02E62E86800200B2DEBA /* Spacedrive */, ); - name = Spacedrive; + name = AppIcon; packageProductDependencies = ( + 6ADA37DC2E8684310017BA3A /* SpacedriveClient */, ); productName = Spacedrive; - productReference = 6A0F02E42E86800200B2DEBA /* Spacedrive.app */; + productReference = 6A0F02E42E86800200B2DEBA /* AppIcon.app */; productType = "com.apple.product-type.application"; }; 6A0F02F02E86800300B2DEBA /* SpacedriveTests */ = { @@ -195,12 +209,15 @@ ); mainGroup = 6A0F02DB2E86800200B2DEBA; minimizedProjectReferenceProxies = 1; + packageReferences = ( + 6A0F036F2E86817A00B2DEBA /* XCLocalSwiftPackageReference "../../../packages/swift-client" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 6A0F02E52E86800200B2DEBA /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( - 6A0F02E32E86800200B2DEBA /* Spacedrive */, + 6A0F02E32E86800200B2DEBA /* AppIcon */, 6A0F02F02E86800300B2DEBA /* SpacedriveTests */, 6A0F02FA2E86800300B2DEBA /* SpacedriveUITests */, ); @@ -258,12 +275,12 @@ /* Begin PBXTargetDependency section */ 6A0F02F32E86800300B2DEBA /* PBXTargetDependency */ = { isa = PBXTargetDependency; - target = 6A0F02E32E86800200B2DEBA /* Spacedrive */; + target = 6A0F02E32E86800200B2DEBA /* AppIcon */; targetProxy = 6A0F02F22E86800300B2DEBA /* PBXContainerItemProxy */; }; 6A0F02FD2E86800300B2DEBA /* PBXTargetDependency */ = { isa = PBXTargetDependency; - target = 6A0F02E32E86800200B2DEBA /* Spacedrive */; + target = 6A0F02E32E86800200B2DEBA /* AppIcon */; targetProxy = 6A0F02FC2E86800300B2DEBA /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ @@ -390,10 +407,12 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; + CODE_SIGN_ENTITLEMENTS = Spacedrive.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ZYS9K9Q88U; - ENABLE_APP_SANDBOX = YES; + ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; ENABLE_USER_SELECTED_FILES = readonly; @@ -434,10 +453,12 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; + CODE_SIGN_ENTITLEMENTS = Spacedrive.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ZYS9K9Q88U; - ENABLE_APP_SANDBOX = YES; + ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; ENABLE_USER_SELECTED_FILES = readonly; @@ -477,6 +498,7 @@ isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_ENTITLEMENTS = Spacedrive.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ZYS9K9Q88U; @@ -503,6 +525,7 @@ isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_ENTITLEMENTS = Spacedrive.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ZYS9K9Q88U; @@ -528,6 +551,7 @@ 6A0F030C2E86800300B2DEBA /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + CODE_SIGN_ENTITLEMENTS = Spacedrive.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ZYS9K9Q88U; @@ -553,6 +577,7 @@ 6A0F030D2E86800300B2DEBA /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + CODE_SIGN_ENTITLEMENTS = Spacedrive.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ZYS9K9Q88U; @@ -587,7 +612,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 6A0F03052E86800300B2DEBA /* Build configuration list for PBXNativeTarget "Spacedrive" */ = { + 6A0F03052E86800300B2DEBA /* Build configuration list for PBXNativeTarget "AppIcon" */ = { isa = XCConfigurationList; buildConfigurations = ( 6A0F03062E86800300B2DEBA /* Debug */, @@ -615,6 +640,21 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 6A0F036F2E86817A00B2DEBA /* XCLocalSwiftPackageReference "../../../packages/swift-client" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "../../../packages/swift-client"; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 6ADA37DC2E8684310017BA3A /* SpacedriveClient */ = { + isa = XCSwiftPackageProductDependency; + package = 6A0F036F2E86817A00B2DEBA /* XCLocalSwiftPackageReference "../../../packages/swift-client" */; + productName = SpacedriveClient; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 6A0F02DC2E86800200B2DEBA /* Project object */; } diff --git a/apps/macos2/Spacedrive/Spacedrive.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/apps/macos2/Spacedrive/Spacedrive.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/apps/macos2/Spacedrive/Spacedrive.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/apps/macos2/Spacedrive/Spacedrive/Components/ConnectivityCard.swift b/apps/macos2/Spacedrive/Spacedrive/Components/ConnectivityCard.swift new file mode 100644 index 000000000..4ea860193 --- /dev/null +++ b/apps/macos2/Spacedrive/Spacedrive/Components/ConnectivityCard.swift @@ -0,0 +1,170 @@ +import SpacedriveClient +import SwiftUI + +/// Clean macOS-style card showing daemon connectivity and service status +struct ConnectivityCard: View { + @EnvironmentObject var appState: SharedAppState + + private var coreStatus: CoreStatus? { + appState.coreStatus + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + // Clean header + HStack { + Text("Services") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(SpacedriveColors.Text.primary) + + Spacer() + + // Simple status indicator + HStack(spacing: 6) { + Circle() + .fill(connectionStatusColor) + .frame(width: 6, height: 6) + + Text(appState.connectionStatus.displayName) + .font(.system(size: 12)) + .foregroundColor(SpacedriveColors.Text.secondary) + } + } + + // Services in clean rows + VStack(spacing: 8) { + ServiceRow( + name: "Location Watcher", + status: coreStatus?.services.locationWatcher.running == true ? .online : .offline, + icon: "doc.text.magnifyingglass" + ) + + ServiceRow( + name: "Networking", + status: coreStatus?.services.networking.running == true ? .online : .offline, + icon: "network" + ) + + ServiceRow( + name: "Volume Monitor", + status: coreStatus?.services.volumeMonitor.running == true ? .online : .offline, + icon: "externaldrive" + ) + + ServiceRow( + name: "File Sharing", + status: coreStatus?.services.fileSharing.running == true ? .online : .offline, + icon: "square.and.arrow.up" + ) + } + + // Clean info section + if let status = coreStatus { + Divider() + .background(SpacedriveColors.Border.secondary) + .padding(.vertical, 4) + + HStack(spacing: 24) { + InfoRow(label: "Libraries", value: "\(status.libraryCount)") + InfoRow(label: "Devices", value: "\(status.network.pairedDevices)") + InfoRow(label: "Version", value: status.version) + } + } + } + .padding(16) + .background(SpacedriveColors.Background.tertiary) + .cornerRadius(8) + } + + private var connectionStatusColor: Color { + switch appState.connectionStatus { + case .connected: + return SpacedriveColors.Accent.success + case .connecting: + return SpacedriveColors.Accent.warning + case .disconnected, .error: + return SpacedriveColors.Accent.error + } + } +} + +/// Clean macOS-style service row +struct ServiceRow: View { + let name: String + let status: ServiceBadgeStatus + let icon: String + + var body: some View { + HStack(spacing: 8) { + Image(systemName: icon) + .font(.system(size: 13)) + .foregroundColor(SpacedriveColors.Text.secondary) + .frame(width: 16) + + Text(name) + .font(.system(size: 13)) + .foregroundColor(SpacedriveColors.Text.primary) + + Spacer() + + Circle() + .fill(statusColor) + .frame(width: 6, height: 6) + } + } + + private var statusColor: Color { + switch status { + case .online: + return SpacedriveColors.Accent.success + case .offline: + return SpacedriveColors.Accent.error + case .degraded: + return SpacedriveColors.Accent.warning + } + } +} + +/// Clean macOS-style info row +struct InfoRow: View { + let label: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.system(size: 11)) + .foregroundColor(SpacedriveColors.Text.secondary) + + Text(value) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(SpacedriveColors.Text.primary) + } + } +} + +/// Service status enum +enum ServiceBadgeStatus { + case online + case offline + case degraded + + var displayName: String { + switch self { + case .online: + return "Online" + case .offline: + return "Offline" + case .degraded: + return "Degraded" + } + } +} + +#Preview { + ConnectivityCard() + .environmentObject(SharedAppState.shared) + .frame(width: 280) + .padding() + .background(SpacedriveColors.Background.primary) +} diff --git a/apps/macos2/Spacedrive/Spacedrive/Components/IconDebugView.swift b/apps/macos2/Spacedrive/Spacedrive/Components/IconDebugView.swift new file mode 100644 index 000000000..e69de29bb diff --git a/apps/macos2/Spacedrive/Spacedrive/Components/LibrarySelector.swift b/apps/macos2/Spacedrive/Spacedrive/Components/LibrarySelector.swift new file mode 100644 index 000000000..bd49df4de --- /dev/null +++ b/apps/macos2/Spacedrive/Spacedrive/Components/LibrarySelector.swift @@ -0,0 +1,243 @@ +import SwiftUI +import SpacedriveClient + +struct LibrarySelector: View { + @EnvironmentObject var appState: SharedAppState + @State private var showingLibraryPicker = false + + var body: some View { + VStack(spacing: 12) { + // Current library card + if let currentLibrary = appState.currentLibrary { + LibraryCard(library: currentLibrary, isSelected: true) + } else if let currentLibraryId = appState.currentLibraryId, + let library = appState.availableLibraries.first(where: { $0.id == currentLibraryId }) + { + LibraryCard(library: library, isSelected: true) + } else { + EmptyLibraryCard() + } + + // Switch library button + if appState.availableLibraries.count > 1 { + Button(action: { + showingLibraryPicker = true + }) { + HStack(spacing: 8) { + Image(systemName: "arrow.triangle.2.circlepath") + .font(.system(size: 13, weight: .medium)) + + Text("Switch Library") + .font(.system(size: 13, weight: .medium)) + } + .foregroundColor(SpacedriveColors.Accent.primary) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(SpacedriveColors.Accent.primary.opacity(0.08)) + ) + } + .buttonStyle(PlainButtonStyle()) + } + } + .sheet(isPresented: $showingLibraryPicker) { + LibraryPickerView() + } + } +} + +/// Individual library card +struct LibraryCard: View { + let library: LibraryInfo + let isSelected: Bool + + var body: some View { + VStack(spacing: 16) { + // Top row: Icon, name, and path + HStack(spacing: 12) { + SpacedriveIconView(.database, size: 28) + .foregroundColor(SpacedriveColors.Accent.primary) + + VStack(alignment: .leading, spacing: 4) { + Text(library.name) + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(SpacedriveColors.Text.primary) + + Text(library.path) + .font(.system(size: 12)) + .foregroundColor(SpacedriveColors.Text.secondary) + .lineLimit(1) + } + + Spacer() + } + + // Bottom row: Statistics + if let stats = library.stats { + LibraryStatsView(stats: stats) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 18) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(SpacedriveColors.Background.tertiary) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke( + isSelected ? SpacedriveColors.Border.primary : SpacedriveColors.Border.secondary, + lineWidth: isSelected ? 1 : 0.5 + ) + ) + ) + } +} + +/// Empty state card when no library is selected +struct EmptyLibraryCard: View { + var body: some View { + HStack(spacing: 12) { + SpacedriveIconView(.database, size: 28) + .foregroundColor(SpacedriveColors.Text.tertiary) + + VStack(alignment: .leading, spacing: 4) { + Text("No Library Selected") + .font(.system(size: 18, weight: .medium)) + .foregroundColor(SpacedriveColors.Text.secondary) + + Text("Select a library to view jobs") + .font(.system(size: 12)) + .foregroundColor(SpacedriveColors.Text.tertiary) + } + + Spacer() + } + .padding(.horizontal, 20) + .padding(.vertical, 18) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(SpacedriveColors.Background.tertiary) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(SpacedriveColors.Border.secondary, lineWidth: 0.5) + ) + ) + } +} + +/// Library picker sheet +struct LibraryPickerView: View { + @EnvironmentObject var appState: SharedAppState + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Text("Select Library") + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(SpacedriveColors.Text.primary) + + Spacer() + + Button("Done") { + dismiss() + } + .buttonStyle(.borderedProminent) + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + .background(SpacedriveColors.Background.secondary) + + Divider() + .background(SpacedriveColors.Border.primary) + + // Library list + ScrollView { + LazyVStack(spacing: 16) { + ForEach(appState.availableLibraries) { library in + LibraryCard( + library: library, + isSelected: library.id == appState.currentLibraryId + ) + .onTapGesture { + appState.dispatch(.switchToLibrary(library)) + dismiss() + } + } + } + .padding(.horizontal, 20) + .padding(.vertical, 20) + } + .background(SpacedriveColors.Background.primary) + } + .frame(width: 400, height: 500) + .background(SpacedriveColors.Background.primary) + } +} + +/// Library statistics view with proper hierarchy +struct LibraryStatsView: View { + let stats: LibraryStatistics + + var body: some View { + HStack(spacing: 24) { + // Hero: Total size (most prominent) + VStack(alignment: .leading, spacing: 6) { + Text(formatBytes(stats.totalSize)) + .font(.system(size: 28, weight: .bold, design: .rounded)) + .foregroundColor(SpacedriveColors.Text.primary) + + Text("Total Size") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(SpacedriveColors.Text.secondary) + } + + Spacer() + + // Secondary stats in a clean row + HStack(spacing: 20) { + StatItem(value: "\(stats.totalFiles)", label: "Files") + StatItem(value: "\(stats.locationCount)", label: "Locations") + StatItem(value: "\(stats.tagCount)", label: "Tags") + } + } + } +} + +/// Individual stat item with proper sizing +struct StatItem: View { + let value: String + let label: String + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(value) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(SpacedriveColors.Text.primary) + + Text(label) + .font(.system(size: 12)) + .foregroundColor(SpacedriveColors.Text.secondary) + } + } +} + + +/// Format bytes into human readable format +private func formatBytes(_ bytes: UInt64) -> String { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useKB, .useMB, .useGB, .useTB] + formatter.countStyle = .file + return formatter.string(fromByteCount: Int64(bytes)) +} + +struct LibrarySelector_Previews: PreviewProvider { + static var previews: some View { + LibrarySelector() + .environmentObject(SharedAppState.shared) + .frame(width: 300) + } +} diff --git a/apps/macos2/Spacedrive/Spacedrive/Components/SDButton.swift b/apps/macos2/Spacedrive/Spacedrive/Components/SDButton.swift new file mode 100644 index 000000000..7b8f3b989 --- /dev/null +++ b/apps/macos2/Spacedrive/Spacedrive/Components/SDButton.swift @@ -0,0 +1,236 @@ +import SwiftUI + +/// Spacedrive Button Component - Reusable button with consistent styling +struct SDButton: View { + enum Style { + case primary + case secondary + case tertiary + case destructive + case ghost + + var backgroundColor: Color { + switch self { + case .primary: + return SpacedriveColors.Accent.primary + case .secondary: + return SpacedriveColors.Background.surface + case .tertiary: + return SpacedriveColors.Background.tertiary + case .destructive: + return SpacedriveColors.Accent.error + case .ghost: + return Color.clear + } + } + + var textColor: Color { + switch self { + case .primary: + return .white + case .secondary, .tertiary: + return SpacedriveColors.Text.primary + case .destructive: + return .white + case .ghost: + return SpacedriveColors.Text.primary + } + } + + var borderColor: Color? { + switch self { + case .primary, .destructive: + return nil + case .secondary, .tertiary: + return SpacedriveColors.Border.primary + case .ghost: + return SpacedriveColors.Border.secondary + } + } + } + + enum Size { + case small + case medium + case large + + var padding: EdgeInsets { + switch self { + case .small: + return EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12) + case .medium: + return EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16) + case .large: + return EdgeInsets(top: 12, leading: 20, bottom: 12, trailing: 20) + } + } + + var font: Font { + switch self { + case .small: + return SpacedriveTypography.Scale.labelSmall(.medium) + case .medium: + return SpacedriveTypography.Scale.label(.medium) + case .large: + return SpacedriveTypography.Scale.labelLarge(.medium) + } + } + + var cornerRadius: CGFloat { + switch self { + case .small: + return 6 + case .medium: + return 8 + case .large: + return 10 + } + } + } + + let title: String + let style: Style + let size: Size + let isDisabled: Bool + let isLoading: Bool + let icon: String? + let action: () -> Void + + @State private var isHovered = false + @State private var isPressed = false + + init( + _ title: String, + style: Style = .primary, + size: Size = .medium, + isDisabled: Bool = false, + isLoading: Bool = false, + icon: String? = nil, + action: @escaping () -> Void + ) { + self.title = title + self.style = style + self.size = size + self.isDisabled = isDisabled + self.isLoading = isLoading + self.icon = icon + self.action = action + } + + var body: some View { + Button(action: { + if !isDisabled, !isLoading { + action() + } + }) { + HStack(spacing: 6) { + if isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + .scaleEffect(0.8) + .frame(width: 12, height: 12) + } else if let icon = icon { + Image(systemName: icon) + .font(size.font) + } + + if !title.isEmpty { + Text(title) + .font(size.font) + .foregroundColor(effectiveTextColor) + } + } + .padding(size.padding) + .background(effectiveBackgroundColor) + .cornerRadius(size.cornerRadius) + .overlay( + RoundedRectangle(cornerRadius: size.cornerRadius) + .stroke(style.borderColor ?? Color.clear, lineWidth: 1) + ) + .scaleEffect(isPressed ? 0.98 : 1.0) + .animation(.easeInOut(duration: 0.1), value: isPressed) + .animation(.easeInOut(duration: 0.2), value: isHovered) + } + .buttonStyle(PlainButtonStyle()) + .disabled(isDisabled || isLoading) + .onHover { hovering in + isHovered = hovering + } + .pressEvents { + isPressed = true + } onRelease: { + isPressed = false + } + } + + private var effectiveBackgroundColor: Color { + if isDisabled { + return style.backgroundColor.opacity(0.3) + } else if isPressed { + return style.backgroundColor.opacity(0.8) + } else if isHovered { + return style.backgroundColor.opacity(0.9) + } else { + return style.backgroundColor + } + } + + private var effectiveTextColor: Color { + if isDisabled { + return style.textColor.opacity(0.5) + } else { + return style.textColor + } + } +} + +// MARK: - Press Events Modifier + +struct PressEvents: ViewModifier { + let onPress: () -> Void + let onRelease: () -> Void + + func body(content: Content) -> some View { + content + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + onPress() + } + .onEnded { _ in + onRelease() + } + ) + } +} + +extension View { + func pressEvents(onPress: @escaping () -> Void, onRelease: @escaping () -> Void) -> some View { + modifier(PressEvents(onPress: onPress, onRelease: onRelease)) + } +} + +// MARK: - Preview + +#Preview { + VStack(spacing: 16) { + HStack(spacing: 12) { + SDButton("Primary", style: .primary, size: .small) {} + SDButton("Secondary", style: .secondary, size: .small) {} + SDButton("Tertiary", style: .tertiary, size: .small) {} + } + + HStack(spacing: 12) { + SDButton("Medium Primary", style: .primary, size: .medium, icon: "plus") {} + SDButton("Loading", style: .primary, size: .medium, isLoading: true) {} + SDButton("Disabled", style: .primary, size: .medium, isDisabled: true) {} + } + + HStack(spacing: 12) { + SDButton("Large Ghost", style: .ghost, size: .large, icon: "gear") {} + SDButton("Destructive", style: .destructive, size: .large) {} + } + } + .padding(20) + .background(SpacedriveColors.Background.primary) +} diff --git a/apps/macos2/Spacedrive/Spacedrive/Components/SDCard.swift b/apps/macos2/Spacedrive/Spacedrive/Components/SDCard.swift new file mode 100644 index 000000000..d0fdcba92 --- /dev/null +++ b/apps/macos2/Spacedrive/Spacedrive/Components/SDCard.swift @@ -0,0 +1,182 @@ +import SwiftUI + +/// Spacedrive Card Component - Reusable container with consistent styling +struct SDCard: View { + enum Style { + case elevated + case bordered + case flat + + var backgroundColor: Color { + switch self { + case .elevated: + return SpacedriveColors.Background.surface + case .bordered: + return SpacedriveColors.Background.tertiary + case .flat: + return SpacedriveColors.Background.secondary + } + } + + var borderColor: Color? { + switch self { + case .elevated, .flat: + return nil + case .bordered: + return SpacedriveColors.Border.primary + } + } + + var shadowRadius: CGFloat { + switch self { + case .elevated: + return 8 + case .bordered, .flat: + return 0 + } + } + } + + let style: Style + let padding: EdgeInsets + let cornerRadius: CGFloat + let content: Content + + @State private var isHovered = false + + init( + style: Style = .elevated, + padding: EdgeInsets = EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16), + cornerRadius: CGFloat = 12, + @ViewBuilder content: () -> Content + ) { + self.style = style + self.padding = padding + self.cornerRadius = cornerRadius + self.content = content() + } + + var body: some View { + content + .padding(padding) + .background(effectiveBackgroundColor) + .cornerRadius(cornerRadius) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius) + .stroke(style.borderColor ?? Color.clear, lineWidth: 1) + ) + .shadow( + color: Color.black.opacity(0.2), + radius: style.shadowRadius, + x: 0, + y: style.shadowRadius / 2 + ) + .scaleEffect(isHovered ? 1.02 : 1.0) + .animation(.easeInOut(duration: 0.2), value: isHovered) + .onHover { hovering in + if style == .elevated { + isHovered = hovering + } + } + } + + private var effectiveBackgroundColor: Color { + if isHovered && style == .elevated { + return style.backgroundColor.opacity(0.9) + } else { + return style.backgroundColor + } + } +} + +// MARK: - Specialized Card Types + +struct SDJobCard: View { + let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + SDCard( + style: .bordered, + padding: EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12), + cornerRadius: 8 + ) { + content + } + } +} + +struct SDStatusCard: View { + let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + SDCard( + style: .flat, + padding: EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12), + cornerRadius: 6 + ) { + content + } + } +} + +// MARK: - Preview + +#Preview { + VStack(spacing: 16) { + SDCard(style: .elevated) { + VStack(alignment: .leading, spacing: 8) { + Text("Elevated Card") + .h5() + Text("This is an elevated card with shadow and hover effects.") + .bodySmall(color: SpacedriveColors.Text.secondary) + + HStack { + SDButton("Action", style: .primary, size: .small) {} + SDButton("Cancel", style: .secondary, size: .small) {} + Spacer() + } + } + } + + SDCard(style: .bordered) { + HStack { + VStack(alignment: .leading) { + Text("Bordered Card") + .h6() + Text("With border styling") + .caption() + } + Spacer() + Image(systemName: "folder") + .foregroundColor(SpacedriveColors.Accent.primary) + } + } + + SDJobCard { + HStack { + Circle() + .fill(SpacedriveColors.Accent.success) + .frame(width: 8, height: 8) + + VStack(alignment: .leading) { + Text("Processing Files") + .label() + Text("45% complete") + .caption() + } + + Spacer() + } + } + } + .padding(20) + .background(SpacedriveColors.Background.primary) +} diff --git a/apps/macos2/Spacedrive/Spacedrive/Components/SpacedriveIcon.swift b/apps/macos2/Spacedrive/Spacedrive/Components/SpacedriveIcon.swift new file mode 100644 index 000000000..6ee509c95 --- /dev/null +++ b/apps/macos2/Spacedrive/Spacedrive/Components/SpacedriveIcon.swift @@ -0,0 +1,339 @@ +import SwiftUI + +/// Enum representing all available Spacedrive icons +/// This enum provides type-safe access to all icons from the Spacedrive v1 assets +enum SpacedriveIcon: String, CaseIterable { + // File Type Icons + case album = "Album" + case albumLight = "Album_Light" + case album20 = "Album-20" + case alias = "Alias" + case aliasLight = "Alias_Light" + case alias20 = "Alias-20" + case application = "Application" + case applicationLight = "Application_Light" + case archive = "Archive" + case archiveLight = "Archive_Light" + case archive20 = "Archive-20" + case audio = "Audio" + case audioLight = "Audio_Light" + case audio20 = "Audio-20" + case book = "Book" + case bookLight = "Book_Light" + case book20 = "Book-20" + case bookBlue = "BookBlue" + case code20 = "Code-20" + case collection = "Collection" + case collectionLight = "Collection_Light" + case collection20 = "Collection-20" + case collectionSparkle = "CollectionSparkle" + case collectionSparkleLight = "CollectionSparkle_Light" + case config20 = "Config-20" + case database = "Database" + case databaseLight = "Database_Light" + case database20 = "Database-20" + case document = "Document" + case documentLight = "Document_Light" + case document20 = "Document-20" + case documentDoc = "Document_doc" + case documentDocLight = "Document_doc_Light" + case documentPdf = "Document_pdf" + case documentPdfLight = "Document_pdf_Light" + case documentSrt = "Document_srt" + case documentXls = "Document_xls" + case documentXlsLight = "Document_xls_Light" + case documentXmp = "Document_xmp" + case dotfile20 = "Dotfile-20" + case encrypted = "Encrypted" + case encryptedLight = "Encrypted_Light" + case encrypted20 = "Encrypted-20" + case entity = "Entity" + case entityLight = "Entity_Light" + case executable = "Executable" + case executableLight = "Executable_Light" + case executableLightOld = "Executable_Light_old" + case executableOld = "Executable_old" + case executable20 = "Executable-20" + case faceLight = "Face_Light" + case folder = "Folder" + case folderLight = "Folder_Light" + case folder20 = "Folder-20" + case folderTagXmp = "Folder-tag-xmp" + case folderGrey = "FolderGrey" + case folderGreyLight = "FolderGrey_Light" + case folderNoSpace = "FolderNoSpace" + case folderNoSpaceLight = "FolderNoSpace_Light" + case font20 = "Font-20" + case game = "Game" + case gameLight = "Game_Light" + case image = "Image" + case imageLight = "Image_Light" + case image20 = "Image-20" + case key = "Key" + case keyLight = "Key_Light" + case key20 = "Key-20" + case keys = "Keys" + case keysLight = "Keys_Light" + case link = "Link" + case linkLight = "Link_Light" + case link20 = "Link-20" + case lock = "Lock" + case lockLight = "Lock_Light" + case mesh = "Mesh" + case meshLight = "Mesh_Light" + case mesh20 = "Mesh-20" + case movie = "Movie" + case movieLight = "Movie_Light" + case package = "Package" + case packageLight = "Package_Light" + case package20 = "Package-20" + case screenshot = "Screenshot" + case screenshotLight = "Screenshot_Light" + case screenshot20 = "Screenshot-20" + case screenshotAlt = "ScreenshotAlt" + case text = "Text" + case textLight = "Text_Light" + case text20 = "Text-20" + case textAlt = "TextAlt" + case textAltLight = "TextAlt_Light" + case textTxt = "Text_txt" + case texturedMesh = "TexturedMesh" + case texturedMeshLight = "TexturedMesh_Light" + case trash = "Trash" + case trashLight = "Trash_Light" + case undefined = "Undefined" + case undefinedLight = "Undefined_Light" + case unknown20 = "Unknown-20" + case video = "Video" + case videoLight = "Video_Light" + case video20 = "Video-20" + case webPageArchive20 = "WebPageArchive-20" + case widget = "Widget" + case widgetLight = "Widget_Light" + case widget20 = "Widget-20" + + // Drive/Cloud Service Icons + case amazonS3 = "AmazonS3" + case androidPhotos = "AndroidPhotos" + case appleFiles = "AppleFiles" + case applePhotos = "ApplePhotos" + case backBlaze = "BackBlaze" + case box = "Box" + case cloudSync = "CloudSync" + case cloudSyncLight = "CloudSync_Light" + case dav = "DAV" + case deleteLocation = "DeleteLocation" + case drive = "Drive" + case driveLight = "Drive_Light" + case driveAmazonS3 = "Drive-AmazonS3" + case driveAmazonS3Light = "Drive-AmazonS3_Light" + case driveBackBlaze = "Drive-BackBlaze" + case driveBackBlazeLight = "Drive-BackBlaze_Light" + case driveBox = "Drive-Box" + case driveBoxLight = "Drive-box_Light" + case driveDarker = "Drive-Darker" + case driveDav = "Drive-DAV" + case driveDavLight = "Drive-DAV_Light" + case driveDropbox = "Drive-Dropbox" + case driveDropboxLight = "Drive-Dropbox_Light" + case driveGoogleDrive = "Drive-GoogleDrive" + case driveGoogleDriveLight = "Drive-GoogleDrive_Light" + case driveMega = "Drive-Mega" + case driveMegaLight = "Drive-Mega_Light" + case driveOneDrive = "Drive-OneDrive" + case driveOneDriveLight = "Drive-OneDrive_Light" + case driveOpenStack = "Drive-OpenStack" + case driveOpenStackLight = "Drive-OpenStack_Light" + case drivePCloud = "Drive-PCloud" + case drivePCloudLight = "Drive-PCloud_Light" + case dropbox = "Dropbox" + case googleDrive = "GoogleDrive" + case location = "Location" + case locationManaged = "LocationManaged" + case locationReplica = "LocationReplica" + case mega = "Mega" + case moveLocation = "MoveLocation" + case moveLocationLight = "MoveLocation_Light" + case newLocation = "NewLocation" + case node = "Node" + case nodeLight = "Node_Light" + case oneDrive = "OneDrive" + case openStack = "OpenStack" + case pCloud = "PCloud" + case spacedrop = "Spacedrop" + case spacedropLight = "Spacedrop_Light" + case spacedrop1 = "Spacedrop-1" + case sync = "Sync" + case syncLight = "Sync_Light" + + // Device Icons + case ball = "Ball" + case globe = "Globe" + case globeLight = "Globe_Light" + case globeAlt = "GlobeAlt" + case hdd = "HDD" + case hddLight = "HDD_Light" + case heart = "Heart" + case heartLight = "Heart_Light" + case home = "Home" + case homeLight = "Home_Light" + case laptop = "Laptop" + case laptopLight = "Laptop_Light" + case mobile = "Mobile" + case mobileLight = "Mobile_Light" + case mobileAndroid = "Mobile-Android" + case miniSilverBox = "MiniSilverBox" + case pc = "PC" + case scrapbook = "Scrapbook" + case scrapbookLight = "Scrapbook_Light" + case sd = "SD" + case sdLight = "SD_Light" + case search = "Search" + case searchLight = "Search_Light" + case searchAlt = "SearchAlt" + case server = "Server" + case serverLight = "Server_Light" + case silverBox = "SilverBox" + case tablet = "Tablet" + case tabletLight = "Tablet_Light" + case tags = "Tags" + case tagsLight = "Tags_Light" + case terminal = "Terminal" + case terminalLight = "Terminal_Light" + + /// Returns the filename for the icon (without extension) + var filename: String { + return rawValue + } + + /// Returns the full filename with .png extension + var fullFilename: String { + return "\(rawValue).png" + } + + /// Returns a user-friendly display name for the icon + var displayName: String { + return rawValue.replacingOccurrences(of: "_", with: " ") + .replacingOccurrences(of: "-", with: " ") + .capitalized + } + + /// Returns true if this is a light theme variant + var isLightVariant: Bool { + return rawValue.hasSuffix("_Light") + } + + /// Returns true if this is a 20px variant + var is20pxVariant: Bool { + return rawValue.hasSuffix("-20") + } + + /// Returns the base name without variant suffixes + var baseName: String { + var name = rawValue + if name.hasSuffix("_Light") { + name = String(name.dropLast(6)) // Remove "_Light" + } + if name.hasSuffix("-20") { + name = String(name.dropLast(3)) // Remove "-20" + } + return name + } +} + +/// SwiftUI view component for displaying Spacedrive icons +struct SpacedriveIconView: View { + let icon: SpacedriveIcon + let size: CGFloat + let color: Color? + + init(_ icon: SpacedriveIcon, size: CGFloat = 16, color: Color? = nil) { + self.icon = icon + self.size = size + self.color = color + } + + var body: some View { + if let bundlePath = Bundle.main.path(forResource: "Spacedrive_Spacedrive", ofType: "bundle"), + let bundle = Bundle(path: bundlePath), + let imagePath = bundle.path(forResource: icon.filename, ofType: "png"), + let image = NSImage(contentsOfFile: imagePath) + { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: size, height: size) + .foregroundColor(color) + } else { + // Fallback for missing images + Rectangle() + .fill(Color.gray.opacity(0.3)) + .frame(width: size, height: size) + .overlay( + Text("?") + .font(.caption) + .foregroundColor(SpacedriveColors.Text.secondary) + ) + } + } +} + +/// Convenience extensions for common icon usage patterns +extension SpacedriveIcon { + /// Get the appropriate icon variant based on theme and size preferences + static func preferredVariant( + baseName: String, + isLightTheme: Bool = false, + prefer20px: Bool = false + ) -> SpacedriveIcon? { + // Try to find the preferred variant + let variants = [ + // 20px variants first if preferred + prefer20px ? "\(baseName)-20" : nil, + // Light variants if light theme + isLightTheme ? "\(baseName)_Light" : nil, + // Base variant + baseName, + ].compactMap { $0 } + + for variant in variants { + if let icon = SpacedriveIcon(rawValue: variant) { + return icon + } + } + + return nil + } + + /// Get all variants of a base icon name + static func allVariants(for baseName: String) -> [SpacedriveIcon] { + return SpacedriveIcon.allCases.filter { $0.baseName == baseName } + } +} + +/// Preview for testing the icon component +struct SpacedriveIconView_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 16) { + Text("Spacedrive Icons") + .font(.title) + .padding() + + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 6), spacing: 12) { + ForEach(Array(SpacedriveIcon.allCases.prefix(24)), id: \.self) { icon in + VStack(spacing: 4) { + SpacedriveIconView(icon, size: 24) + Text(icon.displayName) + .font(.caption2) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .padding(8) + .background(Color.gray.opacity(0.1)) + .cornerRadius(8) + } + } + .padding() + } + } +} diff --git a/apps/macos2/Spacedrive/Spacedrive/Components/SpacedriveIconExample.swift b/apps/macos2/Spacedrive/Spacedrive/Components/SpacedriveIconExample.swift new file mode 100644 index 000000000..5d7aebfb4 --- /dev/null +++ b/apps/macos2/Spacedrive/Spacedrive/Components/SpacedriveIconExample.swift @@ -0,0 +1,113 @@ +import SwiftUI + +/// Example usage of the SpacedriveIcon component +struct SpacedriveIconExample: View { + var body: some View { + VStack(spacing: 20) { + Text("Spacedrive Icon Examples") + .font(.title) + .padding() + + // Basic usage examples + VStack(alignment: .leading, spacing: 12) { + Text("Basic Usage") + .font(.headline) + + HStack(spacing: 16) { + SpacedriveIconView(.folder, size: 24) + SpacedriveIconView(.document, size: 24) + SpacedriveIconView(.image, size: 24) + SpacedriveIconView(.video, size: 24) + SpacedriveIconView(.audio, size: 24) + } + + Text("Different Sizes") + .font(.headline) + .padding(.top) + + HStack(spacing: 16) { + SpacedriveIconView(.folder, size: 16) + SpacedriveIconView(.folder, size: 24) + SpacedriveIconView(.folder, size: 32) + SpacedriveIconView(.folder, size: 48) + } + + Text("Light Theme Variants") + .font(.headline) + .padding(.top) + + HStack(spacing: 16) { + SpacedriveIconView(.folder, size: 24) + SpacedriveIconView(.folderLight, size: 24) + SpacedriveIconView(.document, size: 24) + SpacedriveIconView(.documentLight, size: 24) + } + + Text("20px Variants") + .font(.headline) + .padding(.top) + + HStack(spacing: 16) { + SpacedriveIconView(.folder, size: 24) + SpacedriveIconView(.folder20, size: 24) + SpacedriveIconView(.image, size: 24) + SpacedriveIconView(.image20, size: 24) + } + + Text("Drive/Cloud Services") + .font(.headline) + .padding(.top) + + HStack(spacing: 16) { + SpacedriveIconView(.drive, size: 24) + SpacedriveIconView(.driveDropbox, size: 24) + SpacedriveIconView(.driveGoogleDrive, size: 24) + SpacedriveIconView(.driveAmazonS3, size: 24) + SpacedriveIconView(.driveOneDrive, size: 24) + } + + Text("Device Icons") + .font(.headline) + .padding(.top) + + HStack(spacing: 16) { + SpacedriveIconView(.laptop, size: 24) + SpacedriveIconView(.mobile, size: 24) + SpacedriveIconView(.tablet, size: 24) + SpacedriveIconView(.server, size: 24) + SpacedriveIconView(.hdd, size: 24) + } + } + .padding() + + // Icon picker example + VStack(alignment: .leading, spacing: 12) { + Text("Icon Information") + .font(.headline) + + let sampleIcon = SpacedriveIcon.folder + VStack(alignment: .leading, spacing: 4) { + Text("Icon: \(sampleIcon.displayName)") + Text("Filename: \(sampleIcon.fullFilename)") + Text("Base Name: \(sampleIcon.baseName)") + Text("Is Light Variant: \(sampleIcon.isLightVariant ? "Yes" : "No")") + Text("Is 20px Variant: \(sampleIcon.is20pxVariant ? "Yes" : "No")") + } + .font(.caption) + .foregroundColor(.secondary) + } + .padding() + .background(Color.gray.opacity(0.1)) + .cornerRadius(8) + .padding() + } + } +} + +/// Preview for the example +struct SpacedriveIconExample_Previews: PreviewProvider { + static var previews: some View { + SpacedriveIconExample() + .frame(width: 600, height: 800) + } +} diff --git a/apps/macos2/Spacedrive/Spacedrive/Components/Window/CustomTitleBar.swift b/apps/macos2/Spacedrive/Spacedrive/Components/Window/CustomTitleBar.swift new file mode 100644 index 000000000..ce8ec82e1 --- /dev/null +++ b/apps/macos2/Spacedrive/Spacedrive/Components/Window/CustomTitleBar.swift @@ -0,0 +1,23 @@ +import AppKit +import SwiftUI + +// Environment key for window reference (kept for compatibility) +private struct WindowEnvironmentKey: EnvironmentKey { + static let defaultValue: NSWindow? = nil +} + +extension EnvironmentValues { + var window: NSWindow? { + get { self[WindowEnvironmentKey.self] } + set { self[WindowEnvironmentKey.self] = newValue } + } +} + +struct CustomTitleBar: View { + var body: some View { + // Provide spacing for native traffic lights - they will appear in the transparent title bar area + Spacer() + .frame(height: 28) // Standard macOS title bar height + } +} + diff --git a/apps/macos2/Spacedrive/Spacedrive/Components/Window/NativeTrafficLights.swift b/apps/macos2/Spacedrive/Spacedrive/Components/Window/NativeTrafficLights.swift new file mode 100644 index 000000000..dd49ec20a --- /dev/null +++ b/apps/macos2/Spacedrive/Spacedrive/Components/Window/NativeTrafficLights.swift @@ -0,0 +1,158 @@ +import SwiftUI +import AppKit + +/// Modifier to configure native macOS traffic lights for SwiftUI windows +struct NativeTrafficLightsModifier: ViewModifier { + func body(content: Content) -> some View { + content + .background(NativeTrafficLightsView()) + } +} + +/// Background view that configures the window for native traffic lights +struct NativeTrafficLightsView: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { + let view = NSView() + + // Configure the window when it becomes available + DispatchQueue.main.async { + if let window = view.window { + configureWindow(window) + } + } + + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + // Update if needed + } + + private func configureWindow(_ window: NSWindow) { + // Configure for seamless native traffic light integration + window.titlebarAppearsTransparent = true + window.titleVisibility = .hidden + window.isMovableByWindowBackground = true + + // Customize title bar appearance + customizeTitleBarAppearance(window) + + // Ensure proper window behavior + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + } + + private func customizeTitleBarAppearance(_ window: NSWindow) { + // Set title bar background color to match your app + DispatchQueue.main.async { + // Find the title bar view and customize it + if let titlebarView = window.standardWindowButton(.closeButton)?.superview { + titlebarView.wantsLayer = true + titlebarView.layer?.backgroundColor = SpacedriveColors.NSColors.backgroundPrimary.cgColor + } + + // You can also set the window's background color + window.backgroundColor = SpacedriveColors.NSColors.backgroundPrimary + } + } +} + +/// Advanced title bar modifier that allows custom components +struct CustomTitleBarModifier: ViewModifier { + let centerContent: AnyView? + let rightContent: AnyView? + + init( + center: CenterContent? = nil, + right: RightContent? = nil + ) { + self.centerContent = center.map { AnyView($0) } + self.rightContent = right.map { AnyView($0) } + } + + func body(content: Content) -> some View { + content + .background(CustomTitleBarView(centerContent: centerContent, rightContent: rightContent)) + } +} + +struct CustomTitleBarView: NSViewRepresentable { + let centerContent: AnyView? + let rightContent: AnyView? + + func makeNSView(context: Context) -> NSView { + let view = NSView() + + DispatchQueue.main.async { + if let window = view.window { + configureWindow(window) + addCustomComponents(to: window) + } + } + + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + // Update if needed + } + + private func configureWindow(_ window: NSWindow) { + window.titlebarAppearsTransparent = true + window.titleVisibility = .hidden + window.isMovableByWindowBackground = true + + // Customize appearance + if let titlebarView = window.standardWindowButton(.closeButton)?.superview { + titlebarView.wantsLayer = true + titlebarView.layer?.backgroundColor = SpacedriveColors.NSColors.backgroundPrimary.cgColor + } + + window.backgroundColor = SpacedriveColors.NSColors.backgroundPrimary + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + } + + private func addCustomComponents(to window: NSWindow) { + guard let titlebarView = window.standardWindowButton(.closeButton)?.superview else { return } + + // Add center content + if let centerContent = centerContent { + let hostingView = NSHostingView(rootView: centerContent) + titlebarView.addSubview(hostingView) + + // Center the content + hostingView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + hostingView.centerXAnchor.constraint(equalTo: titlebarView.centerXAnchor), + hostingView.centerYAnchor.constraint(equalTo: titlebarView.centerYAnchor) + ]) + } + + // Add right content + if let rightContent = rightContent { + let hostingView = NSHostingView(rootView: rightContent) + titlebarView.addSubview(hostingView) + + // Position on the right + hostingView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + hostingView.trailingAnchor.constraint(equalTo: titlebarView.trailingAnchor, constant: -12), + hostingView.centerYAnchor.constraint(equalTo: titlebarView.centerYAnchor) + ]) + } + } +} + +extension View { + /// Apply native traffic lights configuration to a SwiftUI view + func nativeTrafficLights() -> some View { + self.modifier(NativeTrafficLightsModifier()) + } + + /// Apply custom title bar with optional center and right components + func customTitleBar( + center: CenterContent? = nil, + right: RightContent? = nil + ) -> some View { + self.modifier(CustomTitleBarModifier(center: center, right: right)) + } +} diff --git a/apps/macos2/Spacedrive/Spacedrive/Components/Window/WindowContainer.swift b/apps/macos2/Spacedrive/Spacedrive/Components/Window/WindowContainer.swift new file mode 100644 index 000000000..f598fe4a8 --- /dev/null +++ b/apps/macos2/Spacedrive/Spacedrive/Components/Window/WindowContainer.swift @@ -0,0 +1,47 @@ +import AppKit +import SwiftUI + +/// Shared Window Container - Provides consistent window chrome with native traffic lights for all windows +struct WindowContainer: View { + let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + ZStack { + // Rounded background using design system colors with Tahoe-style corners + UnevenRoundedRectangle( + topLeadingRadius: 13, // 8% increase from 12 + bottomLeadingRadius: 13, + bottomTrailingRadius: 13, + topTrailingRadius: 13 + ) + .fill(Color(SpacedriveColors.NSColors.backgroundPrimary)) + + VStack(spacing: 0) { + // Spacer for native title bar area (where traffic lights will be) + Spacer() + .frame(height: 28) + + // Main content area + content + } + } + } +} + +#Preview { + WindowContainer { + VStack { + Text("Sample Window Content") + .h3() + Text("This is how content appears inside the window container") + .bodySmall(color: SpacedriveColors.Text.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(20) + } + .frame(width: 400, height: 300) +} diff --git a/apps/macos2/Spacedrive/Spacedrive/ContentView.swift b/apps/macos2/Spacedrive/Spacedrive/ContentView.swift deleted file mode 100644 index 22cce699d..000000000 --- a/apps/macos2/Spacedrive/Spacedrive/ContentView.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// ContentView.swift -// Spacedrive -// -// Created by jamie on 2025-09-26. -// - -import SwiftUI - -struct ContentView: View { - var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") - } - .padding() - } -} - -#Preview { - ContentView() -} diff --git a/apps/macos2/Spacedrive/Spacedrive/DesignSystem/SpacedriveColors.swift b/apps/macos2/Spacedrive/Spacedrive/DesignSystem/SpacedriveColors.swift new file mode 100644 index 000000000..af1084275 --- /dev/null +++ b/apps/macos2/Spacedrive/Spacedrive/DesignSystem/SpacedriveColors.swift @@ -0,0 +1,83 @@ +import AppKit +import SwiftUI + +/// Spacedrive Design System - Color Palette +/// Centralized color definitions for consistent theming across all windows +enum SpacedriveColors { + // MARK: - Background Colors + + enum Background { + static let primary = Color(red: 0.07, green: 0.07, blue: 0.09) // Darker main background + static let secondary = Color(red: 0.12, green: 0.12, blue: 0.14) // Darker box/card backgrounds + static let tertiary = Color(red: 0.11, green: 0.11, blue: 0.14) // Darker card backgrounds + static let surface = Color(red: 0.08, green: 0.08, blue: 0.12) // Darker elevated surfaces + } + + // MARK: - Text Colors + + enum Text { + static let primary = Color.white + static let secondary = Color.white.opacity(0.7) + static let tertiary = Color.white.opacity(0.5) + static let disabled = Color.white.opacity(0.3) + } + + // MARK: - Accent Colors + + enum Accent { + static let primary = Color(red: 0.0, green: 0.48, blue: 1.0) // Spacedrive blue + static let secondary = Color(red: 0.34, green: 0.34, blue: 0.34) // Neutral + static let success = Color.green + static let warning = Color.orange + static let error = Color.red + static let info = Color.blue + } + + // MARK: - Interactive Colors + + enum Interactive { + static let hover = Color(red: 0.20, green: 0.20, blue: 0.25).opacity(0.5) // Hover effect for #1C1D26 backgrounds + static let pressed = Color(red: 0.25, green: 0.25, blue: 0.30).opacity(0.6) // Pressed effect + static let selected = Accent.primary.opacity(0.2) + static let focus = Accent.primary + } + + // MARK: - Border Colors + + enum Border { + static let primary = Color(red: 0.25, green: 0.25, blue: 0.30).opacity(0.3) // Border for #1C1D26 backgrounds + static let secondary = Color(red: 0.20, green: 0.20, blue: 0.25).opacity(0.2) // Lighter border + static let focus = Accent.primary + } + + // MARK: - Traffic Light Colors (Using Native macOS) + // Native traffic lights are handled by the system - no custom colors needed +} + +// MARK: - NSColor Extensions for AppKit Integration + +extension SpacedriveColors { + enum NSColors { + static let backgroundPrimary = NSColor(red: 0.07, green: 0.07, blue: 0.09, alpha: 1.0) // Darker main background + static let backgroundSecondary = NSColor(red: 0.08, green: 0.08, blue: 0.12, alpha: 1.0) // Darker box/card backgrounds + static let accentPrimary = NSColor(red: 0.0, green: 0.48, blue: 1.0, alpha: 1.0) + } +} + +// MARK: - Environment Key for Theme + +private struct ThemeEnvironmentKey: EnvironmentKey { + static let defaultValue: SpacedriveTheme = .dark +} + +extension EnvironmentValues { + var spacedriveTheme: SpacedriveTheme { + get { self[ThemeEnvironmentKey.self] } + set { self[ThemeEnvironmentKey.self] = newValue } + } +} + +enum SpacedriveTheme: Codable { + case dark + case light // Future support +} diff --git a/apps/macos2/Spacedrive/Spacedrive/DesignSystem/SpacedriveTypography.swift b/apps/macos2/Spacedrive/Spacedrive/DesignSystem/SpacedriveTypography.swift new file mode 100644 index 000000000..7e69297db --- /dev/null +++ b/apps/macos2/Spacedrive/Spacedrive/DesignSystem/SpacedriveTypography.swift @@ -0,0 +1,183 @@ +import SwiftUI + +/// Spacedrive Design System - Typography +/// Centralized font definitions for consistent text styling +enum SpacedriveTypography { + // MARK: - Font Weights + + enum Weight { + case thin + case light + case regular + case medium + case semibold + case bold + case heavy + case black + + var systemWeight: Font.Weight { + switch self { + case .thin: return .thin + case .light: return .light + case .regular: return .regular + case .medium: return .medium + case .semibold: return .semibold + case .bold: return .bold + case .heavy: return .heavy + case .black: return .black + } + } + } + + // MARK: - Typography Scale + + enum Scale { + // Headlines + static func h1(_ weight: Weight = .bold) -> Font { + return .system(size: 32, weight: weight.systemWeight, design: .default) + } + + static func h2(_ weight: Weight = .bold) -> Font { + return .system(size: 24, weight: weight.systemWeight, design: .default) + } + + static func h3(_ weight: Weight = .semibold) -> Font { + return .system(size: 20, weight: weight.systemWeight, design: .default) + } + + static func h4(_ weight: Weight = .semibold) -> Font { + return .system(size: 18, weight: weight.systemWeight, design: .default) + } + + static func h5(_ weight: Weight = .medium) -> Font { + return .system(size: 16, weight: weight.systemWeight, design: .default) + } + + static func h6(_ weight: Weight = .medium) -> Font { + return .system(size: 14, weight: weight.systemWeight, design: .default) + } + + // Body Text + static func body(_ weight: Weight = .regular) -> Font { + return .system(size: 14, weight: weight.systemWeight, design: .default) + } + + static func bodyLarge(_ weight: Weight = .regular) -> Font { + return .system(size: 16, weight: weight.systemWeight, design: .default) + } + + static func bodySmall(_ weight: Weight = .regular) -> Font { + return .system(size: 12, weight: weight.systemWeight, design: .default) + } + + // Labels + static func label(_ weight: Weight = .medium) -> Font { + return .system(size: 12, weight: weight.systemWeight, design: .default) + } + + static func labelLarge(_ weight: Weight = .medium) -> Font { + return .system(size: 14, weight: weight.systemWeight, design: .default) + } + + static func labelSmall(_ weight: Weight = .medium) -> Font { + return .system(size: 10, weight: weight.systemWeight, design: .default) + } + + // Caption + static func caption(_ weight: Weight = .regular) -> Font { + return .system(size: 11, weight: weight.systemWeight, design: .default) + } + + static func captionSmall(_ weight: Weight = .regular) -> Font { + return .system(size: 9, weight: weight.systemWeight, design: .default) + } + + // Code/Monospace + static func code(_ weight: Weight = .regular) -> Font { + return .system(size: 12, weight: weight.systemWeight, design: .monospaced) + } + + static func codeSmall(_ weight: Weight = .regular) -> Font { + return .system(size: 10, weight: weight.systemWeight, design: .monospaced) + } + } +} + +// MARK: - Text Style Modifiers + +extension Text { + // Headlines + func h1(_ weight: SpacedriveTypography.Weight = .bold, color: Color = SpacedriveColors.Text.primary) -> some View { + font(SpacedriveTypography.Scale.h1(weight)) + .foregroundColor(color) + } + + func h2(_ weight: SpacedriveTypography.Weight = .bold, color: Color = SpacedriveColors.Text.primary) -> some View { + font(SpacedriveTypography.Scale.h2(weight)) + .foregroundColor(color) + } + + func h3(_ weight: SpacedriveTypography.Weight = .semibold, color: Color = SpacedriveColors.Text.primary) -> some View { + font(SpacedriveTypography.Scale.h3(weight)) + .foregroundColor(color) + } + + func h4(_ weight: SpacedriveTypography.Weight = .semibold, color: Color = SpacedriveColors.Text.primary) -> some View { + font(SpacedriveTypography.Scale.h4(weight)) + .foregroundColor(color) + } + + func h5(_ weight: SpacedriveTypography.Weight = .medium, color: Color = SpacedriveColors.Text.primary) -> some View { + font(SpacedriveTypography.Scale.h5(weight)) + .foregroundColor(color) + } + + func h6(_ weight: SpacedriveTypography.Weight = .medium, color: Color = SpacedriveColors.Text.primary) -> some View { + font(SpacedriveTypography.Scale.h6(weight)) + .foregroundColor(color) + } + + // Body + func body(_ weight: SpacedriveTypography.Weight = .regular, color: Color = SpacedriveColors.Text.primary) -> some View { + font(SpacedriveTypography.Scale.body(weight)) + .foregroundColor(color) + } + + func bodyLarge(_ weight: SpacedriveTypography.Weight = .regular, color: Color = SpacedriveColors.Text.primary) -> some View { + font(SpacedriveTypography.Scale.bodyLarge(weight)) + .foregroundColor(color) + } + + func bodySmall(_ weight: SpacedriveTypography.Weight = .regular, color: Color = SpacedriveColors.Text.secondary) -> some View { + font(SpacedriveTypography.Scale.bodySmall(weight)) + .foregroundColor(color) + } + + // Labels + func label(_ weight: SpacedriveTypography.Weight = .medium, color: Color = SpacedriveColors.Text.secondary) -> some View { + font(SpacedriveTypography.Scale.label(weight)) + .foregroundColor(color) + } + + func labelLarge(_ weight: SpacedriveTypography.Weight = .medium, color: Color = SpacedriveColors.Text.secondary) -> some View { + font(SpacedriveTypography.Scale.labelLarge(weight)) + .foregroundColor(color) + } + + func labelSmall(_ weight: SpacedriveTypography.Weight = .medium, color: Color = SpacedriveColors.Text.tertiary) -> some View { + font(SpacedriveTypography.Scale.labelSmall(weight)) + .foregroundColor(color) + } + + // Caption + func caption(_ weight: SpacedriveTypography.Weight = .regular, color: Color = SpacedriveColors.Text.tertiary) -> some View { + font(SpacedriveTypography.Scale.caption(weight)) + .foregroundColor(color) + } + + // Code + func code(_ weight: SpacedriveTypography.Weight = .regular, color: Color = SpacedriveColors.Text.primary) -> some View { + font(SpacedriveTypography.Scale.code(weight)) + .foregroundColor(color) + } +} diff --git a/apps/macos2/Spacedrive/Spacedrive/Development/DevControlsView.swift b/apps/macos2/Spacedrive/Spacedrive/Development/DevControlsView.swift new file mode 100644 index 000000000..2f72cf12d --- /dev/null +++ b/apps/macos2/Spacedrive/Spacedrive/Development/DevControlsView.swift @@ -0,0 +1,164 @@ +import SwiftUI + +/// Development Controls View +/// A compact view that can be added to any window for quick development window switching +struct DevControlsView: View { + @StateObject private var devManager = DevWindowManager.shared + @State private var showingQuickActions = false + + var body: some View { + VStack(spacing: 8) { + // Compact toggle button + Button(action: { showingQuickActions.toggle() }) { + HStack(spacing: 6) { + Image(systemName: "rectangle.3.group") + .font(.caption) + Text("Dev") + .font(.caption) + .fontWeight(.medium) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.blue.opacity(0.1)) + .foregroundColor(.blue) + .cornerRadius(6) + } + .buttonStyle(PlainButtonStyle()) + + // Quick actions popup + if showingQuickActions { + VStack(spacing: 4) { + ForEach(DevWindowConfiguration.allCases, id: \.self) { config in + Button(action: { + devManager.switchTo(config) + showingQuickActions = false + }) { + HStack { + Text(config.displayName) + .font(.caption) + .foregroundColor(.primary) + Spacer() + if devManager.currentConfiguration == config { + Image(systemName: "checkmark") + .font(.caption2) + .foregroundColor(.blue) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(devManager.currentConfiguration == config ? + Color.blue.opacity(0.1) : Color.clear) + ) + } + .buttonStyle(PlainButtonStyle()) + } + } + .padding(8) + .background(Color(.windowBackgroundColor)) + .cornerRadius(8) + .shadow(radius: 4) + } + } + .onTapGesture { + // Close popup when tapping outside + showingQuickActions = false + } + } +} + +/// Development Status Bar +/// Shows current configuration and allows quick switching +struct DevStatusBar: View { + @StateObject private var devManager = DevWindowManager.shared + + var body: some View { + HStack { + Image(systemName: "hammer.fill") + .foregroundColor(.orange) + .font(.caption) + + Text("Dev:") + .font(.caption) + .foregroundColor(.secondary) + + Text(devManager.currentConfiguration.displayName) + .font(.caption) + .fontWeight(.medium) + .foregroundColor(.primary) + + Spacer() + + Button("Switch") { + // Open the full configuration selector + NSApp.sendAction(Selector(("showDevConfiguration:")), to: nil, from: nil) + } + .font(.caption) + .buttonStyle(.borderless) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.orange.opacity(0.1)) + .cornerRadius(4) + } +} + +/// Development Window Overlay +/// Can be overlaid on any window to show development controls +struct DevWindowOverlay: View { + @State private var isVisible = true + + var body: some View { + if isVisible { + VStack { + HStack { + Spacer() + DevControlsView() + .padding(.trailing, 12) + .padding(.top, 8) + } + Spacer() + } + .allowsHitTesting(true) + } + } +} + +// MARK: - View Extensions for Easy Integration + +extension View { + /// Adds development controls to any view + func withDevControls() -> some View { + ZStack { + self + DevWindowOverlay() + } + } + + /// Adds a development status bar at the top + func withDevStatusBar() -> some View { + VStack(spacing: 0) { + DevStatusBar() + self + } + } +} + +// MARK: - Keyboard Shortcuts for Quick Switching + +#if DEBUG +struct DevKeyboardShortcuts: View { + var body: some View { + EmptyView() + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("devShortcut"))) { notification in + if let config = notification.object as? DevWindowConfiguration { + DevWindowManager.shared.switchTo(config) + } + } + } +} + +// Add this to your main view for keyboard shortcuts +// DevKeyboardShortcuts() +#endif diff --git a/apps/macos2/Spacedrive/Spacedrive/Development/DevWindowConfiguration.swift b/apps/macos2/Spacedrive/Spacedrive/Development/DevWindowConfiguration.swift new file mode 100644 index 000000000..ef12b9383 --- /dev/null +++ b/apps/macos2/Spacedrive/Spacedrive/Development/DevWindowConfiguration.swift @@ -0,0 +1,204 @@ +import SwiftUI +import Combine + +/// Development Window Configuration Helper +/// This file provides easy-to-use functions for switching between different window configurations during development. +/// +/// Usage: +/// - Change the `currentDevConfiguration` variable below to switch between different setups +/// - Rebuild and run to see the new configuration +/// - Use the DevWindowToggleView for runtime switching + +// MARK: - Quick Toggle Configuration +// 🔧 CHANGE THIS VALUE TO SWITCH WINDOW CONFIGURATIONS 🔧 +// Available options: .default, .browserOnly, .companionOnly, .allWindows, .compact, .development +let currentDevConfiguration: DevWindowConfiguration = .development + +// MARK: - Development Window Manager + +@MainActor +class DevWindowManager: ObservableObject { + static let shared = DevWindowManager() + + @Published var currentConfiguration: DevWindowConfiguration = currentDevConfiguration + + private init() { + // Set the initial configuration in SharedAppState + SharedAppState.shared.setDevWindowConfiguration(currentDevConfiguration) + } + + func switchTo(_ config: DevWindowConfiguration) { + currentConfiguration = config + SharedAppState.shared.setDevWindowConfiguration(config) + + // Close all existing windows + SharedAppState.shared.dispatch(.closeAllWindows) + + // Open new windows after a brief delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + SharedAppState.shared.dispatch(.openDevWindows) + } + + print("🔧 Switched to development configuration: \(config.displayName)") + } + + func getConfigurationDescription() -> String { + return currentConfiguration.description + } +} + +// MARK: - Development Window Toggle View + +struct DevWindowToggleView: View { + @StateObject private var devManager = DevWindowManager.shared + @State private var showingConfiguration = false + + var body: some View { + VStack(spacing: 12) { + // Current Configuration Display + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Dev Config") + .font(.caption) + .foregroundColor(.secondary) + Text(devManager.currentConfiguration.displayName) + .font(.headline) + .foregroundColor(.primary) + } + + Spacer() + + Button("Switch") { + showingConfiguration.toggle() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + + // Configuration Description + Text(devManager.getConfigurationDescription()) + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + } + .padding() + .background(Color.gray.opacity(0.1)) + .cornerRadius(8) + .sheet(isPresented: $showingConfiguration) { + DevConfigurationSelector() + } + } +} + +struct DevConfigurationSelector: View { + @Environment(\.dismiss) private var dismiss + @StateObject private var devManager = DevWindowManager.shared + + var body: some View { + NavigationView { + VStack(spacing: 20) { + Text("Development Window Configuration") + .font(.title2) + .fontWeight(.semibold) + .padding(.top) + + Text("Choose a window configuration for development. This will close all current windows and open the selected configuration.") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + LazyVGrid(columns: [ + GridItem(.flexible()), + GridItem(.flexible()) + ], spacing: 16) { + ForEach(DevWindowConfiguration.allCases, id: \.self) { config in + DevConfigCard( + configuration: config, + isSelected: devManager.currentConfiguration == config + ) { + devManager.switchTo(config) + dismiss() + } + } + } + .padding(.horizontal) + + Spacer() + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button("Done") { + dismiss() + } + } + } + } + .frame(width: 600, height: 500) + } +} + +struct DevConfigCard: View { + let configuration: DevWindowConfiguration + let isSelected: Bool + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + VStack(alignment: .leading, spacing: 8) { + Text(configuration.displayName) + .font(.headline) + .foregroundColor(isSelected ? .white : .primary) + .multilineTextAlignment(.leading) + + Text(configuration.description) + .font(.caption) + .foregroundColor(isSelected ? .white.opacity(0.8) : .secondary) + .multilineTextAlignment(.leading) + + Spacer() + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: 120) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(isSelected ? Color.blue : Color.gray.opacity(0.1)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(isSelected ? Color.blue : Color.clear, lineWidth: 2) + ) + ) + } + .buttonStyle(PlainButtonStyle()) + } +} + +// MARK: - Quick Access Functions + +/// Quick function to switch to browser-only mode +@MainActor +func switchToBrowserOnly() { + DevWindowManager.shared.switchTo(.browserOnly) +} + +/// Quick function to switch to development mode (browser + inspector) +@MainActor +func switchToDevelopmentMode() { + DevWindowManager.shared.switchTo(.development) +} + +/// Quick function to switch to companion-only mode +@MainActor +func switchToCompanionOnly() { + DevWindowManager.shared.switchTo(.companionOnly) +} + +/// Quick function to open all windows +@MainActor +func openAllWindows() { + DevWindowManager.shared.switchTo(.allWindows) +} + +// MARK: - Window Management Extensions +// (Extensions removed to avoid redeclaration conflicts) diff --git a/apps/macos2/Spacedrive/Spacedrive/MenuBar/MenuBarManager.swift b/apps/macos2/Spacedrive/Spacedrive/MenuBar/MenuBarManager.swift new file mode 100644 index 000000000..a285c8881 --- /dev/null +++ b/apps/macos2/Spacedrive/Spacedrive/MenuBar/MenuBarManager.swift @@ -0,0 +1,508 @@ +import AppKit +import SwiftUI +import Combine + +/// Manages the macOS menu bar for Spacedrive +@MainActor +class MenuBarManager: ObservableObject { + static let shared = MenuBarManager() + + private var cancellables = Set() + + private init() { + setupObservers() + } + + private func setupObservers() { + // Observe changes to available libraries and current library + SharedAppState.shared.$availableLibraries + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateLibrariesMenu() + } + .store(in: &cancellables) + + SharedAppState.shared.$currentLibraryId + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateLibrariesMenu() + } + .store(in: &cancellables) + } + + private var mainMenu: NSMenu? + + func setupMenuBar() { + let mainMenu = NSMenu() + self.mainMenu = mainMenu + NSApp.mainMenu = mainMenu + + // App Menu (Spacedrive) + setupAppMenu(mainMenu) + + // Daemon Menu + setupDaemonMenu(mainMenu) + + // File Menu + setupFileMenu(mainMenu) + + // Edit Menu + setupEditMenu(mainMenu) + + // View Menu + setupViewMenu(mainMenu) + + // Window Menu + setupWindowMenu(mainMenu) + + // Help Menu + setupHelpMenu(mainMenu) + } + + func getMainMenu() -> NSMenu? { + return mainMenu + } + + func ensureMenuBarVisible() { + // Force menu bar to be visible and properly set + if let menu = mainMenu { + NSApp.mainMenu = menu + } else { + // Recreate menu bar if it's missing + setupMenuBar() + } + } + + // MARK: - App Menu (Spacedrive) + + private func setupAppMenu(_ mainMenu: NSMenu) { + let appMenuItem = NSMenuItem() + mainMenu.addItem(appMenuItem) + + let appMenu = NSMenu() + appMenuItem.submenu = appMenu + + // About Spacedrive + let aboutItem = NSMenuItem( + title: "About Spacedrive", + action: #selector(showAbout), + keyEquivalent: "" + ) + aboutItem.target = self + appMenu.addItem(aboutItem) + + appMenu.addItem(NSMenuItem.separator()) + + // Preferences + let preferencesItem = NSMenuItem( + title: "Preferences...", + action: #selector(showPreferences), + keyEquivalent: "," + ) + preferencesItem.target = self + appMenu.addItem(preferencesItem) + + appMenu.addItem(NSMenuItem.separator()) + + // Services + let servicesMenuItem = NSMenuItem(title: "Services", action: nil, keyEquivalent: "") + let servicesMenu = NSMenu() + servicesMenuItem.submenu = servicesMenu + appMenu.addItem(servicesMenuItem) + NSApp.servicesMenu = servicesMenu + + appMenu.addItem(NSMenuItem.separator()) + + // Hide Spacedrive + let hideItem = NSMenuItem( + title: "Hide Spacedrive", + action: #selector(NSApp.hide(_:)), + keyEquivalent: "h" + ) + appMenu.addItem(hideItem) + + // Hide Others + let hideOthersItem = NSMenuItem( + title: "Hide Others", + action: #selector(NSApp.hideOtherApplications(_:)), + keyEquivalent: "h" + ) + hideOthersItem.keyEquivalentModifierMask = [.command, .option] + appMenu.addItem(hideOthersItem) + + // Show All + let showAllItem = NSMenuItem( + title: "Show All", + action: #selector(NSApp.unhideAllApplications(_:)), + keyEquivalent: "" + ) + appMenu.addItem(showAllItem) + + appMenu.addItem(NSMenuItem.separator()) + + // Quit Spacedrive + let quitItem = NSMenuItem( + title: "Quit Spacedrive", + action: #selector(NSApp.terminate(_:)), + keyEquivalent: "q" + ) + appMenu.addItem(quitItem) + } + + // MARK: - Daemon Menu + + private func setupDaemonMenu(_ mainMenu: NSMenu) { + let daemonMenuItem = NSMenuItem(title: "Daemon", action: nil, keyEquivalent: "") + mainMenu.addItem(daemonMenuItem) + + let daemonMenu = NSMenu(title: "Daemon") + daemonMenuItem.submenu = daemonMenu + + // Connection Status (disabled item showing current status) + let statusItem = NSMenuItem( + title: "Status: Disconnected", + action: nil, + keyEquivalent: "" + ) + statusItem.isEnabled = false + daemonMenu.addItem(statusItem) + + daemonMenu.addItem(NSMenuItem.separator()) + + // Connect to Daemon + let connectItem = NSMenuItem( + title: "Connect to Daemon", + action: #selector(connectToDaemon), + keyEquivalent: "" + ) + connectItem.target = self + daemonMenu.addItem(connectItem) + + // Disconnect from Daemon + let disconnectItem = NSMenuItem( + title: "Disconnect from Daemon", + action: #selector(disconnectFromDaemon), + keyEquivalent: "" + ) + disconnectItem.target = self + disconnectItem.isEnabled = false + daemonMenu.addItem(disconnectItem) + + daemonMenu.addItem(NSMenuItem.separator()) + + // Refresh Jobs + let refreshItem = NSMenuItem( + title: "Refresh Jobs", + action: #selector(refreshJobs), + keyEquivalent: "r" + ) + refreshItem.target = self + daemonMenu.addItem(refreshItem) + + // Store references for dynamic updates + for item in daemonMenu.items { + switch item.title { + case "Status: Disconnected", "Status: Connected", "Status: Connecting": + item.representedObject = "status" + case "Connect to Daemon": + item.representedObject = "connect" + case "Disconnect from Daemon": + item.representedObject = "disconnect" + default: + break + } + } + + // Store menu reference for updates + self.daemonMenu = daemonMenu + } + + private var daemonMenu: NSMenu? + + // MARK: - File Menu + + private func setupFileMenu(_ mainMenu: NSMenu) { + let fileMenuItem = NSMenuItem(title: "File", action: nil, keyEquivalent: "") + mainMenu.addItem(fileMenuItem) + + let fileMenu = NSMenu(title: "File") + fileMenuItem.submenu = fileMenu + + // New Window + let newWindowItem = NSMenuItem( + title: "New Browser Window", + action: #selector(newBrowserWindow), + keyEquivalent: "n" + ) + newWindowItem.target = self + fileMenu.addItem(newWindowItem) + + fileMenu.addItem(NSMenuItem.separator()) + + // Libraries subsection + setupLibrariesSubmenu(fileMenu) + + fileMenu.addItem(NSMenuItem.separator()) + + // Close Window + let closeItem = NSMenuItem( + title: "Close Window", + action: #selector(performClose), + keyEquivalent: "w" + ) + closeItem.target = self + fileMenu.addItem(closeItem) + } + + // MARK: - Libraries Submenu + + private func setupLibrariesSubmenu(_ fileMenu: NSMenu) { + let librariesMenuItem = NSMenuItem(title: "Libraries", action: nil, keyEquivalent: "") + fileMenu.addItem(librariesMenuItem) + + let librariesMenu = NSMenu(title: "Libraries") + librariesMenuItem.submenu = librariesMenu + + // Store reference for dynamic updates + self.librariesMenu = librariesMenu + + // Initial population + updateLibrariesMenu() + } + + private var librariesMenu: NSMenu? + + // MARK: - Edit Menu + + private func setupEditMenu(_ mainMenu: NSMenu) { + let editMenuItem = NSMenuItem(title: "Edit", action: nil, keyEquivalent: "") + mainMenu.addItem(editMenuItem) + + let editMenu = NSMenu(title: "Edit") + editMenuItem.submenu = editMenu + + // Standard edit items + editMenu.addItem(NSMenuItem(title: "Undo", action: Selector(("undo:")), keyEquivalent: "z")) + editMenu.addItem(NSMenuItem(title: "Redo", action: Selector(("redo:")), keyEquivalent: "Z")) + editMenu.addItem(NSMenuItem.separator()) + editMenu.addItem(NSMenuItem(title: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")) + editMenu.addItem(NSMenuItem(title: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")) + editMenu.addItem(NSMenuItem(title: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")) + editMenu.addItem(NSMenuItem(title: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")) + } + + // MARK: - View Menu + + private func setupViewMenu(_ mainMenu: NSMenu) { + let viewMenuItem = NSMenuItem(title: "View", action: nil, keyEquivalent: "") + mainMenu.addItem(viewMenuItem) + + let viewMenu = NSMenu(title: "View") + viewMenuItem.submenu = viewMenu + + // Show Job Monitor + let jobMonitorItem = NSMenuItem( + title: "Show Job Monitor", + action: #selector(showJobMonitor), + keyEquivalent: "j" + ) + jobMonitorItem.target = self + viewMenu.addItem(jobMonitorItem) + + // Show Icon Showcase + let iconShowcaseItem = NSMenuItem( + title: "Show Icon Showcase", + action: #selector(showIconShowcase), + keyEquivalent: "i" + ) + iconShowcaseItem.target = self + viewMenu.addItem(iconShowcaseItem) + + // Show Inspector + let inspectorItem = NSMenuItem( + title: "Show Inspector", + action: #selector(showInspector), + keyEquivalent: "f" + ) + inspectorItem.target = self + viewMenu.addItem(inspectorItem) + + viewMenu.addItem(NSMenuItem.separator()) + + // Enter Full Screen + let fullScreenItem = NSMenuItem( + title: "Enter Full Screen", + action: #selector(toggleFullScreen), + keyEquivalent: "f" + ) + fullScreenItem.keyEquivalentModifierMask = [.command, .control] + fullScreenItem.target = self + viewMenu.addItem(fullScreenItem) + } + + // MARK: - Window Menu + + private func setupWindowMenu(_ mainMenu: NSMenu) { + let windowMenuItem = NSMenuItem(title: "Window", action: nil, keyEquivalent: "") + mainMenu.addItem(windowMenuItem) + + let windowMenu = NSMenu(title: "Window") + windowMenuItem.submenu = windowMenu + NSApp.windowsMenu = windowMenu + + // Minimize + windowMenu.addItem(NSMenuItem(title: "Minimize", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m")) + + // Zoom + windowMenu.addItem(NSMenuItem(title: "Zoom", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "")) + + windowMenu.addItem(NSMenuItem.separator()) + + // Bring All to Front + windowMenu.addItem(NSMenuItem(title: "Bring All to Front", action: #selector(NSApp.arrangeInFront(_:)), keyEquivalent: "")) + } + + // MARK: - Help Menu + + private func setupHelpMenu(_ mainMenu: NSMenu) { + let helpMenuItem = NSMenuItem(title: "Help", action: nil, keyEquivalent: "") + mainMenu.addItem(helpMenuItem) + + let helpMenu = NSMenu(title: "Help") + helpMenuItem.submenu = helpMenu + NSApp.helpMenu = helpMenu + + let helpItem = NSMenuItem( + title: "Spacedrive Help", + action: #selector(showHelp), + keyEquivalent: "?" + ) + helpItem.target = self + helpMenu.addItem(helpItem) + } + + // MARK: - Menu Actions + + @objc private func showAbout() { + NSApp.orderFrontStandardAboutPanel(nil) + } + + @objc private func showPreferences() { + // SwiftUI handles settings window automatically + NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) + } + + @objc private func connectToDaemon() { + SharedAppState.shared.dispatch(.connectToDaemon) + } + + @objc private func disconnectFromDaemon() { + SharedAppState.shared.dispatch(.disconnectFromDaemon) + } + + @objc private func refreshJobs() { + SharedAppState.shared.dispatch(.refreshJobs) + } + + @objc private func newBrowserWindow() { + // SwiftUI handles window opening automatically + print("Browser window request - handled by SwiftUI WindowGroup") + } + + @objc private func showJobMonitor() { + // SwiftUI handles window opening automatically + print("Job monitor request - handled by SwiftUI WindowGroup") + } + + @objc private func showIconShowcase() { + // SwiftUI handles window opening automatically + print("Icon showcase request - handled by SwiftUI WindowGroup") + } + + @objc private func showInspector() { + // SwiftUI handles window opening automatically + print("Inspector request - handled by SwiftUI WindowGroup") + } + + @objc private func performClose() { + NSApp.keyWindow?.performClose(nil) + } + + @objc private func toggleFullScreen() { + NSApp.keyWindow?.toggleFullScreen(nil) + } + + @objc private func showHelp() { + if let url = URL(string: "https://spacedrive.com/docs") { + NSWorkspace.shared.open(url) + } + } + + @objc private func switchToLibrary(_ sender: NSMenuItem) { + guard let library = sender.representedObject as? LibraryInfo else { return } + SharedAppState.shared.dispatch(.switchToLibrary(library)) + } + + // MARK: - Dynamic Menu Updates + + func updateDaemonMenuStatus(_ status: ConnectionStatus) { + guard let daemonMenu = daemonMenu else { return } + + for item in daemonMenu.items { + guard let representedObject = item.representedObject as? String else { continue } + + switch representedObject { + case "status": + item.title = "Status: \(status.displayName)" + case "connect": + item.isEnabled = status != .connected && status != .connecting + case "disconnect": + item.isEnabled = status == .connected + default: + break + } + } + } + + func updateLibrariesMenu() { + guard let librariesMenu = librariesMenu else { return } + + // Clear existing items + librariesMenu.removeAllItems() + + // Get current libraries from SharedAppState + let availableLibraries = SharedAppState.shared.availableLibraries + let currentLibraryId = SharedAppState.shared.currentLibraryId + + if availableLibraries.isEmpty { + // No libraries available + let noLibrariesItem = NSMenuItem( + title: "No Libraries Available", + action: nil, + keyEquivalent: "" + ) + noLibrariesItem.isEnabled = false + librariesMenu.addItem(noLibrariesItem) + } else { + // Add each library as a menu item + for library in availableLibraries { + let libraryItem = NSMenuItem( + title: library.name, + action: #selector(switchToLibrary), + keyEquivalent: "" + ) + libraryItem.target = self + libraryItem.representedObject = library + + // Add checkmark for selected library + if library.id == currentLibraryId { + libraryItem.state = .on + } + + librariesMenu.addItem(libraryItem) + } + } + } +} + +// ConnectionStatus displayName is already defined in JobModels.swift diff --git a/apps/macos2/Spacedrive/Spacedrive/Services/DaemonConnector.swift b/apps/macos2/Spacedrive/Spacedrive/Services/DaemonConnector.swift new file mode 100644 index 000000000..bd371f44a --- /dev/null +++ b/apps/macos2/Spacedrive/Spacedrive/Services/DaemonConnector.swift @@ -0,0 +1,567 @@ +import Foundation +import os.log +import Combine +@preconcurrency import SpacedriveClient + +// Now using generated types from SpacedriveClient - no manual definitions needed! + +@MainActor +class DaemonConnector: ObservableObject { + @Published var connectionStatus: ConnectionStatus = .disconnected + @Published var jobs: [JobInfo] = [] + @Published var currentLibraryId: String? + @Published var availableLibraries: [LibraryInfo] = [] + @Published var coreStatus: CoreStatus? + + private let socketPath = "/Users/jamespine/Library/Application Support/spacedrive/daemon/daemon.sock" + let client: SpacedriveClient + private var eventTask: Task? + private let logger = Logger(subsystem: "com.spacedrive.daemon", category: "DaemonConnector") + + init() { + logger.info("DaemonConnector initializing with socket path: \(self.socketPath)") + client = SpacedriveClient(socketPath: self.socketPath) + connect() + } + + nonisolated deinit { + logger.info("DaemonConnector deinitializing") + eventTask?.cancel() + eventTask = nil + } + + func connect() { + guard connectionStatus != .connecting, connectionStatus != .connected else { + return + } + + connectionStatus = .connecting + + // Check if socket file exists + guard FileManager.default.fileExists(atPath: socketPath) else { + print("❌ Daemon socket not found at: \(socketPath)") + connectionStatus = .error("Daemon socket not found. Is Spacedrive daemon running?") + return + } + + // Start connection and event subscription + Task { + await startConnection() + } + } + + func disconnect() { + eventTask?.cancel() + eventTask = nil + connectionStatus = .disconnected + } + + private func startConnection() async { + do { + // Test connection with ping (with timeout) + try await withTimeout(seconds: 5) { [self] in + try await client.ping() + } + + connectionStatus = .connected + + await fetchCoreStatus() + + // Start event subscription + await subscribeToEvents() + + } catch { + print("❌ Connection failed: \(error)") + connectionStatus = .error("Failed to connect: \(error.localizedDescription)") + } + } + + private func withTimeout(seconds: TimeInterval, operation: @escaping @Sendable () async throws -> T) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + try await operation() + } + + group.addTask { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + throw SpacedriveError.connectionFailed("Connection timeout after \(seconds) seconds") + } + + let result = try await group.next()! + group.cancelAll() + return result + } + } + + private func subscribeToEvents() async { + let eventTypes = [ + "JobStarted", + "JobProgress", + "JobCompleted", + "JobFailed", + "JobPaused", + "JobResumed", + "LibraryCreated", + "LibraryOpened", + "LibraryClosed", + "LibraryDeleted", + "EntryCreated", + "EntryModified", + "EntryDeleted", + "EntryMoved", + ] + + // First, fetch the current job list to establish baseline state + await fetchJobList() + + // Also fetch core status + await fetchCoreStatus() + + // Then subscribe to events for real-time updates + eventTask = Task { + do { + for try await event in client.subscribe(to: eventTypes) { + await handleEvent(event) + } + } catch { + connectionStatus = .error("Event subscription failed: \(error.localizedDescription)") + } + } + } + + private func handleEvent(_ event: SpacedriveEvent) async { + // Handle events using the new type-safe Event enum + switch event { + // Core lifecycle events + case .coreStarted: + break + + case .coreShutdown: + break + + // Job events - update existing jobs in real-time + case let .jobStarted(data): + self.handleJobStarted(jobId: data.jobId, jobType: data.jobType) + + case let .jobProgress(data): + self.handleJobProgress( + jobId: data.jobId, + jobType: data.jobType, + progress: data.progress, + message: data.message, + genericProgress: data.genericProgress + ) + + case let .jobCompleted(data): + self.handleJobCompleted(jobId: data.jobId, jobType: data.jobType, output: data.output) + + case let .jobFailed(data): + self.handleJobFailed(jobId: data.jobId, jobType: data.jobType, error: data.error) + + case let .jobPaused(data): + self.handleJobPaused(jobId: data.jobId) + + case let .jobResumed(data): + self.handleJobResumed(jobId: data.jobId) + + // Library events + case .libraryCreated: + Task { await self.refreshLibraryStats() } + + case .libraryOpened: + Task { await self.refreshLibraryStats() } + + case .libraryClosed: + Task { await self.refreshLibraryStats() } + + case .libraryDeleted: + Task { await self.refreshLibraryStats() } + + // Entry events that affect library statistics + case .entryCreated: + Task { await self.refreshLibraryStats() } + + case .entryModified: + Task { await self.refreshLibraryStats() } + + case .entryDeleted: + Task { await self.refreshLibraryStats() } + + case .entryMoved: + Task { await self.refreshLibraryStats() } + + // Other events can be handled as needed + default: + break + } + } + + // MARK: - Job Event Handlers + + private func handleJobStarted(jobId: String, jobType: String) { + // Only create a new job if it doesn't exist + if job(withId: jobId) == nil { + let newJob = JobInfo( + id: jobId, + name: jobType.capitalized, + status: .running, + progress: 0.0, + startedAt: Date(), + completedAt: nil, + errorMessage: nil + ) + updateOrAddJob(newJob) + } + } + + private func handleJobProgress(jobId: String, jobType: String, progress: Double, message _: String?, genericProgress: GenericProgress?) { + if var existingJob = job(withId: jobId) { + existingJob.progress = progress + + // Keep the actual job type as the title + existingJob.name = formatJobTitle(jobType) + existingJob.jobType = jobType + + // Use enhanced progress information for better UX + if let generic = genericProgress { + // Store additional progress details + existingJob.currentPhase = generic.phase + existingJob.completionInfo = "\(generic.completion.completed)/\(generic.completion.total)" + + // Extract current path from genericProgress, avoiding fake status messages + existingJob.currentPath = extractCurrentPath(from: generic) + + // Check for errors/warnings + if generic.performance.errorCount > 0 || generic.performance.warningCount > 0 { + existingJob.hasIssues = true + existingJob.issuesInfo = "⚠️ \(generic.performance.errorCount) errors, \(generic.performance.warningCount) warnings" + } + } + + existingJob.status = .running + updateOrAddJob(existingJob) + + // Enhanced logging with more details + if genericProgress != nil { + } else {} + } else { + // If we receive progress for a job we don't know about, create it + let newJob = JobInfo( + id: jobId, + name: formatJobTitle(jobType), + status: .running, + progress: progress, + startedAt: Date(), + completedAt: nil, + errorMessage: nil, + currentPhase: genericProgress?.phase, + completionInfo: genericProgress != nil ? "\(genericProgress!.completion.completed)/\(genericProgress!.completion.total)" : nil, + hasIssues: false, + issuesInfo: nil, + currentPath: genericProgress != nil ? extractCurrentPath(from: genericProgress!) : nil, + jobType: jobType + ) + updateOrAddJob(newJob) + } + } + + // Helper to format job type into a user-friendly title + private func formatJobTitle(_ jobType: String) -> String { + switch jobType.lowercased() { + case "indexer": + return "File Indexer" + case "file_copy": + return "File Copy" + case "thumbnail_generator": + return "Thumbnail Generator" + case "file_move": + return "File Move" + case "file_delete": + return "File Delete" + case "media_processor": + return "Media Processor" + default: + return jobType.replacingOccurrences(of: "_", with: " ").capitalized + } + } + + // Helper to extract real current path from genericProgress, avoiding fake status messages + private func extractCurrentPath(from genericProgress: GenericProgress) -> String? { + guard let currentPath = genericProgress.currentPath else { return nil } + + switch currentPath { + case let .physical(pathData): + let path = pathData.path + + // Check if this looks like a real file path vs a status message + // Status messages often contain patterns like "(X/Y)" or "- XX.X%" + let isStatusMessage = path.contains(#"\(\d+/\d+\)"#) || + path.contains(#" - \d+\.?\d*%"#) || + path.hasPrefix("Generating content identities") || + path.hasPrefix("Aggregating directory") + + // Only return if it looks like a real file path + return isStatusMessage ? nil : path + + default: + return nil + } + } + + private func handleJobCompleted(jobId: String, jobType _: String, output _: JobOutput) { + if var existingJob = job(withId: jobId) { + existingJob.status = .completed + existingJob.progress = 1.0 + existingJob.completedAt = Date() + updateOrAddJob(existingJob) + } + } + + private func handleJobFailed(jobId: String, jobType _: String, error: String) { + if var existingJob = job(withId: jobId) { + existingJob.status = .failed + existingJob.errorMessage = error + updateOrAddJob(existingJob) + } + } + + private func handleJobPaused(jobId: String) { + if var existingJob = job(withId: jobId) { + existingJob.status = .paused + updateOrAddJob(existingJob) + } + } + + private func handleJobResumed(jobId: String) { + if var existingJob = job(withId: jobId) { + existingJob.status = .running + updateOrAddJob(existingJob) + } + } + + private func updateOrAddJob(_ jobInfo: JobInfo) { + if let index = jobs.firstIndex(where: { $0.id == jobInfo.id }) { + jobs[index] = jobInfo + } else { + jobs.append(jobInfo) + } + + // Sort jobs by start date (newest first) + jobs.sort { $0.startedAt > $1.startedAt } + } + + private func job(withId id: String) -> JobInfo? { + return jobs.first { $0.id == id } + } + + func reconnect() { + disconnect() + Task { @MainActor in + try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second + connect() + } + } + + // MARK: - Job Control + + func pauseJob(_ jobId: String) { + guard connectionStatus == .connected else { + return + } + + Task { + do { + // Call the jobs.pause action using the new type-safe method + _ = try await client.jobs.pause(JobPauseInput(jobId: jobId)) + + } catch {} + } + } + + func resumeJob(_ jobId: String) { + guard connectionStatus == .connected else { + return + } + + Task { + do { + // Call the jobs.resume action using the new type-safe method + _ = try await client.jobs.resume(JobResumeInput(jobId: jobId)) + + } catch {} + } + } + + // MARK: - Library Management + + + func switchToLibrary(_ library: LibraryInfo) async { + do { + // Switch to the library using the Swift client's async method + try await client.switchToLibrary(library.id) + + // Set the current library ID synchronously + currentLibraryId = library.id + + // Refresh job list for the new library + await fetchJobList() + + } catch {} + } + + func getCurrentLibraryInfo() -> LibraryInfo? { + return availableLibraries.first { $0.id == currentLibraryId } + } + + // MARK: - Library Statistics Refresh + + private func refreshLibraryStats() async { + do { + let libraries = try await client.libraries.list(ListLibrariesInput(includeStats: true)) + + availableLibraries = libraries.map { library in + LibraryInfo( + id: library.id, + name: library.name, + path: library.path, + isDefault: false, // TODO: Determine if this is the default library + stats: library.stats + ) + } + } catch { + logger.error("Failed to refresh library statistics: \(error.localizedDescription)") + } + } + + // MARK: - Job List Management + + private func fetchJobList() async { + do { + // Check if we have a current library + guard let libraryId = currentLibraryId else { + logger.info("No current library ID, skipping job fetch") + return + } + + logger.info("Fetching jobs for library: \(libraryId)") + + // Get jobs using the new type-safe method + let jobsResponse = try await client.jobs.list(JobListInput(status: nil)) + + logger.info("Received \(jobsResponse.jobs.count) jobs from daemon") + + // Convert JobListItem to JobInfo for the UI using convenience initializer + let convertedJobs = jobsResponse.jobs.map { jobItem in + JobInfo(from: jobItem) + } + + // Update the UI with the converted jobs + jobs = convertedJobs + logger.info("Updated UI with \(convertedJobs.count) jobs") + + } catch { + logger.error("Failed to fetch jobs: \(error.localizedDescription)") + connectionStatus = .error("Failed to fetch jobs: \(error.localizedDescription)") + } + } + + private func fetchCoreStatus() async { + do { + // Create empty input for core status query + let emptyData = Data("{}".utf8) + let emptyInput = try JSONDecoder().decode(Empty.self, from: emptyData) + + // Get libraries with stats + let libraries = try await client.getLibraries(includeStats: true) + + // Update available libraries + availableLibraries = libraries.map { library in + LibraryInfo( + id: library.id, + name: library.name, + path: library.path, + isDefault: false, // TODO: Determine if this is the default library + stats: library.stats + ) + } + + // Auto-select first library if none is selected + if currentLibraryId == nil, !libraries.isEmpty { + do { + try await client.switchToLibrary(libraries[0].id) + // Set the library ID synchronously + currentLibraryId = libraries[0].id + // Fetch jobs after setting the library ID + await fetchJobList() + } catch { + logger.error("Failed to auto-select first library: \(error.localizedDescription)") + } + } else if currentLibraryId != nil { + // Fetch jobs for the existing library + await fetchJobList() + } + + // Now try core status + let status = try await client.core.status(emptyInput) + + // Update the UI with the core status + coreStatus = status + + } catch { + logger.error("Failed to fetch core status: \(error.localizedDescription)") + // Don't update connection status for core status failures + } + } + + // MARK: - Query Execution + + /// Execute a library query + nonisolated func executeLibraryQuery(_ query: T) async throws -> T { + logger.info("Executing library query: \(String(describing: type(of: query)))") + do { + let result = try await client.query(query) + logger.info("Library query executed successfully") + return result + } catch { + logger.error("Library query failed: \(error.localizedDescription)") + logger.error("Query error details: \(String(describing: error))") + throw error + } + } + + /// Execute a query using the internal client + nonisolated func query(_ query: T) async throws -> T { + logger.info("Executing generic query: \(String(describing: type(of: query)))") + do { + let result = try await client.query(query) + logger.info("Generic query executed successfully") + return result + } catch { + logger.error("Generic query failed: \(error.localizedDescription)") + logger.error("Query error details: \(String(describing: error))") + throw error + } + } + + /// Execute a FileByPathQuery and return the File result + nonisolated func queryFileByPath(_ query: FileByPathQuery) async throws -> File? { + logger.info("Executing FileByPathQuery for path: \(query.path)") + logger.info("Query details - path: \(query.path)") + + do { + let result = try await client.queryFileByPath(query) + if let file = result { + logger.info("FileByPathQuery successful - found file: \(file.name)") + logger.info("File details - size: \(file.size), extension: \(file.extension ?? "none")") + } else { + logger.warning("FileByPathQuery successful but no file found for path: \(query.path)") + } + return result + } catch { + logger.error("FileByPathQuery failed for path: \(query.path)") + logger.error("Error: \(error.localizedDescription)") + logger.error("Error details: \(String(describing: error))") + throw error + } + } +} diff --git a/apps/macos2/Spacedrive/Spacedrive/SpacedriveApp.swift b/apps/macos2/Spacedrive/Spacedrive/SpacedriveApp.swift index 8ea80875b..469de9635 100644 --- a/apps/macos2/Spacedrive/Spacedrive/SpacedriveApp.swift +++ b/apps/macos2/Spacedrive/Spacedrive/SpacedriveApp.swift @@ -6,12 +6,146 @@ // import SwiftUI +import AppKit @main struct SpacedriveApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + var body: some Scene { - WindowGroup { - ContentView() + // Main companion window + WindowGroup("Spacedrive") { + JobCompanionView() + .withSharedState() + .customTitleBar( + center: Text("Jamie's Library") + .font(.headline) + .foregroundColor(.white), + right: HStack(spacing: 8) { + Button(action: {}) { + Image(systemName: "gear") + .foregroundColor(.white) + } + .buttonStyle(PlainButtonStyle()) + + Button(action: {}) { + Image(systemName: "person.circle") + .foregroundColor(.white) + } + .buttonStyle(PlainButtonStyle()) + } + ) + } + .windowStyle(.titleBar) + .windowResizability(.contentSize) + .defaultSize(width: 400, height: 600) + + // Browser window + WindowGroup("Browser") { + BrowserView() + .withSharedState() + .customTitleBar( + center: HStack(spacing: 12) { + Image(systemName: "folder") + .foregroundColor(.white) + Text("File Browser") + .font(.headline) + .foregroundColor(.white) + }, + right: HStack(spacing: 8) { + Button(action: {}) { + Image(systemName: "magnifyingglass") + .foregroundColor(.white) + } + .buttonStyle(PlainButtonStyle()) + + Button(action: {}) { + Image(systemName: "list.bullet") + .foregroundColor(.white) + } + .buttonStyle(PlainButtonStyle()) + } + ) + } + .windowStyle(.titleBar) + .windowResizability(.contentSize) + .defaultSize(width: 1200, height: 800) + + // Icon Showcase window + WindowGroup("Icon Showcase") { + IconShowcaseView() + .withSharedState() + .nativeTrafficLights() + } + .windowStyle(.titleBar) + .windowResizability(.contentSize) + .defaultSize(width: 1000, height: 800) + + // Inspector window + WindowGroup("Inspector") { + InspectorView() + .withSharedState() + .nativeTrafficLights() + } + .windowStyle(.titleBar) + .windowResizability(.contentSize) + .defaultSize(width: 500, height: 700) + + // Settings window + Settings { + SettingsView() + .withSharedState() } } } + +class AppDelegate: NSObject, NSApplicationDelegate { + func applicationDidFinishLaunching(_: Notification) { + print("Spacedrive for macOS") + + // Configure app as foreground application + NSApp.setActivationPolicy(.regular) + + setupMenuBar() + initializeAppState() + } + + func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool { + return true + } + + func applicationShouldHandleReopen(_: NSApplication, hasVisibleWindows _: Bool) -> Bool { + // When clicking on dock icon or app, activate and show window + NSApp.activate(ignoringOtherApps: true) + return true + } + + @MainActor + private func setupMenuBar() { + MenuBarManager.shared.setupMenuBar() + } + + @MainActor + private func initializeAppState() { + // Initialize shared app state + SharedAppState.shared.initializeDaemonConnection() + + // Apply development window configuration + applyDevWindowConfiguration() + } + + @MainActor + private func applyDevWindowConfiguration() { + // Set the development window configuration + SharedAppState.shared.setDevWindowConfiguration(currentDevConfiguration) + + // Open windows based on the configuration + if currentDevConfiguration.shouldAutoOpen { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + SharedAppState.shared.openWindowsForCurrentDevConfiguration() + } + } + + print("🔧 Development window configuration set to: \(currentDevConfiguration.displayName)") + } +} diff --git a/apps/macos2/Spacedrive/Spacedrive/State/SharedAppState.swift b/apps/macos2/Spacedrive/Spacedrive/State/SharedAppState.swift new file mode 100644 index 000000000..90ec4c8d5 --- /dev/null +++ b/apps/macos2/Spacedrive/Spacedrive/State/SharedAppState.swift @@ -0,0 +1,426 @@ +import Combine +import SwiftUI +import SpacedriveClient + +/// Shared Application State - Global state management across all windows +/// Similar to Redux store or React Context, but with Combine/ObservableObject +@MainActor +class SharedAppState: ObservableObject { + static let shared = SharedAppState() + + // MARK: - Connection State + + @Published var connectionStatus: ConnectionStatus = .disconnected + @Published var daemonConnector: DaemonConnector? + + // MARK: - User Preferences + + @Published var userPreferences = UserPreferences() + + // MARK: - Jobs State + + @Published var globalJobs: [JobInfo] = [] + @Published var jobsLastUpdated: Date = .init() + + // MARK: - Core Status + + @Published var coreStatus: CoreStatus? + + // MARK: - Library State + + @Published var currentLibrary: LibraryInfo? + @Published var availableLibraries: [LibraryInfo] = [] + @Published var currentLibraryId: String? + + // MARK: - UI State + + @Published var theme: SpacedriveTheme = .dark + @Published var sidebarCollapsed: Bool = false + + private var cancellables = Set() + + private init() { + setupConnections() + loadUserPreferences() + } + + // MARK: - Connection Management + + func initializeDaemonConnection() { + if daemonConnector == nil { + daemonConnector = DaemonConnector() + + // Subscribe to daemon connector state + daemonConnector?.$connectionStatus + .receive(on: DispatchQueue.main) + .sink { [weak self] status in + self?.connectionStatus = status + // Update menu bar status + MenuBarManager.shared.updateDaemonMenuStatus(status) + } + .store(in: &cancellables) + + daemonConnector?.$jobs + .receive(on: DispatchQueue.main) + .sink { [weak self] jobs in + self?.globalJobs = jobs + self?.jobsLastUpdated = Date() + } + .store(in: &cancellables) + + daemonConnector?.$availableLibraries + .receive(on: DispatchQueue.main) + .sink { [weak self] libraries in + self?.availableLibraries = libraries + // Update currentLibrary if we have a currentLibraryId but currentLibrary is nil + if let currentLibraryId = self?.currentLibraryId, + self?.currentLibrary == nil, + let library = libraries.first(where: { $0.id == currentLibraryId }) + { + self?.currentLibrary = library + } + } + .store(in: &cancellables) + + daemonConnector?.$currentLibraryId + .receive(on: DispatchQueue.main) + .sink { [weak self] libraryId in + self?.currentLibraryId = libraryId + if let libraryId = libraryId { + self?.currentLibrary = self?.availableLibraries.first { $0.id == libraryId } + } else { + self?.currentLibrary = nil + } + } + .store(in: &cancellables) + + daemonConnector?.$coreStatus + .receive(on: DispatchQueue.main) + .sink { [weak self] coreStatus in + print("🔍 SharedAppState received coreStatus update: \(coreStatus != nil ? "loaded" : "nil")") + if let status = coreStatus { + print("🔍 Core status services: location_watcher=\(status.services.locationWatcher.running), networking=\(status.services.networking.running), volume_monitor=\(status.services.volumeMonitor.running), file_sharing=\(status.services.fileSharing.running)") + } + self?.coreStatus = coreStatus + } + .store(in: &cancellables) + + daemonConnector?.connect() + } + } + + func disconnectDaemon() { + daemonConnector?.disconnect() + connectionStatus = .disconnected + } + + // MARK: - Library Management + + func selectLibrary(_ library: LibraryInfo) { + currentLibrary = library + currentLibraryId = library.id + userPreferences.lastSelectedLibraryId = library.id + saveUserPreferences() + + // Switch library in the daemon connector + Task { + await daemonConnector?.switchToLibrary(library) + } + } + + // MARK: - Preferences Management + + private func loadUserPreferences() { + // Load from UserDefaults + if let data = UserDefaults.standard.data(forKey: "SpacedriveUserPreferences"), + let preferences = try? JSONDecoder().decode(UserPreferences.self, from: data) + { + userPreferences = preferences + theme = preferences.theme + sidebarCollapsed = preferences.sidebarCollapsed + } + } + + func saveUserPreferences() { + if let data = try? JSONEncoder().encode(userPreferences) { + UserDefaults.standard.set(data, forKey: "SpacedriveUserPreferences") + } + } + + func updatePreference(_ keyPath: WritableKeyPath, to value: T) { + userPreferences[keyPath: keyPath] = value + + // Update published properties if needed + if keyPath == \.theme { + theme = userPreferences.theme + } else if keyPath == \.sidebarCollapsed { + sidebarCollapsed = userPreferences.sidebarCollapsed + } + + saveUserPreferences() + } + + // MARK: - Development Window Configuration + + func setDevWindowConfiguration(_ config: DevWindowConfiguration) { + updatePreference(\.devWindowConfiguration, to: config) + } + + func openWindowsForCurrentDevConfiguration() { + let config = userPreferences.devWindowConfiguration + print("🔧 Dev configuration set to: \(config.displayName)") + + // Open windows using SwiftUI's window management + DispatchQueue.main.async { + switch config { + case .browserOnly, .development: + // Open browser window + if let browserWindow = NSApp.windows.first(where: { $0.title == "Browser" }) { + browserWindow.makeKeyAndOrderFront(nil) + } else { + // Create new browser window + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 1200, height: 800), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.title = "Browser" + window.contentView = NSHostingView(rootView: BrowserView().withSharedState()) + window.center() + window.makeKeyAndOrderFront(nil) + } + + if config == .development { + // Also open inspector for development mode + if let inspectorWindow = NSApp.windows.first(where: { $0.title == "Inspector" }) { + inspectorWindow.makeKeyAndOrderFront(nil) + } else { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 700), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.title = "Inspector" + window.contentView = NSHostingView(rootView: InspectorView().withSharedState()) + window.center() + window.makeKeyAndOrderFront(nil) + } + } + + case .companionOnly, .default: + // Main companion window is already open + if let companionWindow = NSApp.windows.first(where: { $0.title == "Spacedrive" }) { + companionWindow.makeKeyAndOrderFront(nil) + } + + case .allWindows: + // Open all windows + self.openWindowsForCurrentDevConfiguration() // Recursive call for browser + inspector + // Add other windows as needed + + case .compact: + // Compact mode - just companion + if let companionWindow = NSApp.windows.first(where: { $0.title == "Spacedrive" }) { + companionWindow.makeKeyAndOrderFront(nil) + } + } + } + } + + func getDevWindowConfiguration() -> DevWindowConfiguration { + return userPreferences.devWindowConfiguration + } + + func closeAllWindows() { + NSApp.windows.forEach { $0.close() } + } + + // MARK: - Action Dispatchers (Redux-like) + + func dispatch(_ action: AppAction) { + switch action { + case .connectToDaemon: + initializeDaemonConnection() + + case .disconnectFromDaemon: + disconnectDaemon() + + case let .selectLibrary(library): + selectLibrary(library) + + case let .switchToLibrary(library): + selectLibrary(library) + + case let .updateTheme(newTheme): + updatePreference(\.theme, to: newTheme) + + case .toggleSidebar: + updatePreference(\.sidebarCollapsed, to: !sidebarCollapsed) + + case .refreshJobs: + daemonConnector?.reconnect() + + case let .pauseJob(jobId): + daemonConnector?.pauseJob(jobId) + + case let .resumeJob(jobId): + daemonConnector?.resumeJob(jobId) + + case let .setDevWindowConfiguration(config): + setDevWindowConfiguration(config) + + case .openDevWindows: + openWindowsForCurrentDevConfiguration() + + case .closeAllWindows: + closeAllWindows() + } + } + + private func setupConnections() { + // Auto-connect on app launch + initializeDaemonConnection() + } + + // MARK: - SwiftUI Window Management + // Note: SwiftUI handles window management automatically via WindowGroup +} + +// MARK: - Actions (Redux-like action system) + +enum AppAction { + case connectToDaemon + case disconnectFromDaemon + case selectLibrary(LibraryInfo) + case switchToLibrary(LibraryInfo) + case updateTheme(SpacedriveTheme) + case toggleSidebar + case refreshJobs + case pauseJob(String) + case resumeJob(String) + case setDevWindowConfiguration(DevWindowConfiguration) + case openDevWindows + case closeAllWindows +} + +// MARK: - User Preferences + +struct UserPreferences: Codable { + var theme: SpacedriveTheme = .dark + var lastSelectedLibraryId: String? + var sidebarCollapsed: Bool = false + var windowPositions: [String: WindowPosition] = [:] + var autoConnectToDaemon: Bool = true + var showJobNotifications: Bool = true + var compactMode: Bool = false + + // Development preferences + var devWindowConfiguration: DevWindowConfiguration = .default + var devShowAllWindows: Bool = false + var devAutoOpenBrowser: Bool = false +} + +struct WindowPosition: Codable { + let x: Double + let y: Double + let width: Double + let height: Double +} + +// MARK: - Development Window Configuration + +enum DevWindowConfiguration: String, CaseIterable, Codable { + case `default` = "default" + case browserOnly = "browser_only" + case companionOnly = "companion_only" + case allWindows = "all_windows" + case compact = "compact" + case development = "development" + + var displayName: String { + switch self { + case .default: + return "Default (Companion + Browser)" + case .browserOnly: + return "Browser Only" + case .companionOnly: + return "Companion Only" + case .allWindows: + return "All Windows" + case .compact: + return "Compact Mode" + case .development: + return "Development Mode" + } + } + + var description: String { + switch self { + case .default: + return "Opens companion window and browser window" + case .browserOnly: + return "Opens only the browser window" + case .companionOnly: + return "Opens only the companion window" + case .allWindows: + return "Opens all available windows" + case .compact: + return "Opens companion with compact settings" + case .development: + return "Opens browser + inspector for development" + } + } + + var shouldAutoOpen: Bool { + switch self { + case .browserOnly, .development: + return true + default: + return false + } + } +} + +// MARK: - Library Info + +struct LibraryInfo: Codable, Identifiable { + let id: String + let name: String + let path: String + let isDefault: Bool + let stats: LibraryStatistics? + + init(id: String, name: String, path: String, isDefault: Bool = false, stats: LibraryStatistics? = nil) { + self.id = id + self.name = name + self.path = path + self.isDefault = isDefault + self.stats = stats + } +} + +// SpacedriveTheme is already Codable in its declaration + +// MARK: - View Modifiers for Shared State + +struct WithSharedState: View { + let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + content + .environmentObject(SharedAppState.shared) + .environment(\.spacedriveTheme, SharedAppState.shared.theme) + } +} + +extension View { + func withSharedState() -> some View { + WithSharedState { self } + } +} diff --git a/apps/macos2/Spacedrive/Spacedrive/Windows/Browser/Components/ContentArea.swift b/apps/macos2/Spacedrive/Spacedrive/Windows/Browser/Components/ContentArea.swift new file mode 100644 index 000000000..e8b24028a --- /dev/null +++ b/apps/macos2/Spacedrive/Spacedrive/Windows/Browser/Components/ContentArea.swift @@ -0,0 +1,159 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct ContentArea: View { + @ObservedObject var browserState: BrowserState + + var body: some View { + VStack(spacing: 0) { + // Content header + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(browserState.selectedLocation?.name ?? "Select a location") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(SpacedriveColors.Text.primary) + + Text(browserState.currentPath) + .font(.system(size: 12)) + .foregroundColor(SpacedriveColors.Text.tertiary) + } + + Spacer() + + // Content actions + HStack(spacing: 8) { + Button(action: {}) { + Image(systemName: "list.bullet") + .foregroundColor(SpacedriveColors.Text.secondary) + } + .buttonStyle(PlainButtonStyle()) + + Button(action: {}) { + Image(systemName: "grid") + .foregroundColor(SpacedriveColors.Text.secondary) + } + .buttonStyle(PlainButtonStyle()) + + Button(action: {}) { + Image(systemName: "slider.horizontal.3") + .foregroundColor(SpacedriveColors.Text.secondary) + } + .buttonStyle(PlainButtonStyle()) + + // Liquid Glass Button + LiquidGlassButton( + action: { + print("Liquid Glass button tapped!") + }, + icon: "sparkles", + title: "Glass" + ) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + + Divider() + .background(SpacedriveColors.Border.primary) + + // Main content area + if browserState.selectedLocation != nil { + ScrollView { + LazyVGrid(columns: Array(repeating: GridItem(.adaptive(minimum: 120), spacing: 16), count: 4), spacing: 16) { + ForEach(0..<20, id: \.self) { index in + ContentItemView( + name: "Item \(index + 1)", + isDirectory: index % 3 == 0 + ) + } + } + .padding(20) + } + } else { + // Empty state + VStack(spacing: 16) { + Image(systemName: "folder") + .font(.system(size: 48)) + .foregroundColor(SpacedriveColors.Text.tertiary) + + Text("Select a location to browse") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(SpacedriveColors.Text.secondary) + + Text("Choose a location from the sidebar to view its contents") + .font(.system(size: 14)) + .foregroundColor(SpacedriveColors.Text.tertiary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + Spacer() + } + .background(SpacedriveColors.Background.primary) + .clipShape(UnevenRoundedRectangle( + topLeadingRadius: 13, // 8% increase from 12 + bottomLeadingRadius: 13, + bottomTrailingRadius: 0, + topTrailingRadius: 0 + )) + .onDrop(of: [.fileURL], isTargeted: nil) { providers in + handleFileDrop(providers) + } + } + + private func handleFileDrop(_ providers: [NSItemProvider]) -> Bool { + guard let provider = providers.first else { return false } + + provider.loadItem(forTypeIdentifier: "public.file-url", options: nil) { item, error in + guard let data = item as? Data, + let url = URL(dataRepresentation: data, relativeTo: nil) else { + return + } + + let path = url.path + let fileExtension = url.pathExtension.lowercased() + + // Check if it's an image file + let imageExtensions = ["jpg", "jpeg", "png", "gif", "bmp", "tiff", "heic", "webp"] + if imageExtensions.contains(fileExtension) { + DispatchQueue.main.async { + browserState.showImagePreview(path: path) + } + } + } + + return true + } +} + +struct ContentItemView: View { + let name: String + let isDirectory: Bool + + var body: some View { + VStack(spacing: 8) { + Image(systemName: isDirectory ? "folder.fill" : "doc.fill") + .font(.system(size: 32)) + .foregroundColor(isDirectory ? SpacedriveColors.Accent.primary : SpacedriveColors.Text.secondary) + + Text(name) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(SpacedriveColors.Text.primary) + .lineLimit(2) + .multilineTextAlignment(.center) + } + .frame(width: 80, height: 80) + .background(SpacedriveColors.Background.secondary) + .clipShape(UnevenRoundedRectangle( + topLeadingRadius: 9, // 8% increase from 8 + bottomLeadingRadius: 9, + bottomTrailingRadius: 9, + topTrailingRadius: 9 + )) + .onTapGesture { + print("Tapped \(name)") + } + } +} + diff --git a/apps/macos2/Spacedrive/Spacedrive/Windows/Browser/Components/ImagePreviewView.swift b/apps/macos2/Spacedrive/Spacedrive/Windows/Browser/Components/ImagePreviewView.swift new file mode 100644 index 000000000..433675413 --- /dev/null +++ b/apps/macos2/Spacedrive/Spacedrive/Windows/Browser/Components/ImagePreviewView.swift @@ -0,0 +1,143 @@ +import SwiftUI +import AppKit + +/// Image preview view with zoom and pan capabilities +struct ImagePreviewView: View { + let imagePath: String + let onClose: () -> Void + + @State private var scale: CGFloat = 1.0 + @State private var lastScale: CGFloat = 1.0 + @State private var offset: CGSize = .zero + @State private var lastOffset: CGSize = .zero + @State private var image: NSImage? + @State private var imageSize: CGSize = .zero + + // Sidebar width for initial positioning + private let sidebarWidth: CGFloat = 200 + + var body: some View { + ZStack { + // Background + Color.black + .ignoresSafeArea() + + if let image = image { + // Image with zoom and pan + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .scaleEffect(scale) + .offset(offset) + .gesture( + SimultaneousGesture( + // Pinch to zoom + MagnificationGesture() + .onChanged { value in + let delta = value / lastScale + lastScale = value + scale *= delta + } + .onEnded { _ in + lastScale = 1.0 + // Constrain scale + scale = max(0.5, min(scale, 5.0)) + }, + + // Drag to pan + DragGesture() + .onChanged { value in + offset = CGSize( + width: lastOffset.width + value.translation.width, + height: lastOffset.height + value.translation.height + ) + } + .onEnded { _ in + lastOffset = offset + } + ) + ) + .onTapGesture(count: 2) { + // Double tap to reset zoom + withAnimation(.easeInOut(duration: 0.3)) { + scale = 1.0 + offset = .zero + lastOffset = .zero + } + } + } else { + // Loading state + VStack(spacing: 16) { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(1.5) + + Text("Loading image...") + .foregroundColor(.white) + .font(.system(size: 16)) + } + } + + // Close button + VStack { + HStack { + Spacer() + Button(action: onClose) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 24)) + .foregroundColor(.white.opacity(0.8)) + .background(Color.black.opacity(0.3)) + .clipShape(Circle()) + } + .buttonStyle(PlainButtonStyle()) + .padding(.top, 20) + .padding(.trailing, 20) + } + Spacer() + } + } + .onAppear { + loadImage() + // Initial positioning to compensate for sidebar + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + adjustInitialPosition() + } + } + } + + private func loadImage() { + guard let nsImage = NSImage(contentsOfFile: imagePath) else { + print("Failed to load image from: \(imagePath)") + return + } + + self.image = nsImage + self.imageSize = nsImage.size + } + + private func adjustInitialPosition() { + // Center the image in the available space (accounting for sidebar) + // This ensures the image appears centered in the content area initially + let availableWidth = NSScreen.main?.frame.width ?? 1200 + + // Calculate the offset needed to center the image in the content area + // (not the full screen, but the area to the right of the sidebar) + let contentAreaWidth = availableWidth - sidebarWidth + let contentAreaCenterX = (contentAreaWidth / 2) + sidebarWidth + + // Center horizontally in the content area + offset = CGSize( + width: contentAreaCenterX - (availableWidth / 2), + height: 0 + ) + lastOffset = offset + } +} + +#Preview { + ImagePreviewView( + imagePath: "/System/Library/Desktop Pictures/Monterey.heic", + onClose: {} + ) + .frame(width: 1200, height: 800) +} diff --git a/apps/macos2/Spacedrive/Spacedrive/Windows/Browser/Components/LiquidGlassButton.swift b/apps/macos2/Spacedrive/Spacedrive/Windows/Browser/Components/LiquidGlassButton.swift new file mode 100644 index 000000000..edfce73e8 --- /dev/null +++ b/apps/macos2/Spacedrive/Spacedrive/Windows/Browser/Components/LiquidGlassButton.swift @@ -0,0 +1,67 @@ +import SwiftUI + +/// A button with liquid glass effect styling +struct LiquidGlassButton: View { + let action: () -> Void + let icon: String + let title: String? + + @State private var isPressed = false + + init(action: @escaping () -> Void, icon: String, title: String? = nil) { + self.action = action + self.icon = icon + self.title = title + } + + var body: some View { + Button(action: action) { + HStack(spacing: 6) { + Image(systemName: icon) + .font(.system(size: 14, weight: .medium)) + + if let title = title { + Text(title) + .font(.system(size: 12, weight: .medium)) + } + } + .foregroundColor(.white) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + UnevenRoundedRectangle( + topLeadingRadius: 9, // 8% increase from 8 + bottomLeadingRadius: 9, + bottomTrailingRadius: 9, + topTrailingRadius: 9 + ) + .fill(.ultraThinMaterial) + .overlay( + UnevenRoundedRectangle( + topLeadingRadius: 9, + bottomLeadingRadius: 9, + bottomTrailingRadius: 9, + topTrailingRadius: 9 + ) + .stroke(.white.opacity(0.2), lineWidth: 1) + ) + ) + .scaleEffect(isPressed ? 0.95 : 1.0) + .animation(.easeInOut(duration: 0.1), value: isPressed) + } + .buttonStyle(PlainButtonStyle()) + .onLongPressGesture(minimumDuration: 0, maximumDistance: .infinity, pressing: { pressing in + isPressed = pressing + }, perform: {}) + } +} + +#Preview { + HStack(spacing: 12) { + LiquidGlassButton(action: {}, icon: "sparkles", title: "Liquid Glass") + LiquidGlassButton(action: {}, icon: "wand.and.stars") + LiquidGlassButton(action: {}, icon: "crystal.ball", title: "Magic") + } + .padding() + .background(Color.black) +} diff --git a/apps/macos2/Spacedrive/Spacedrive/Windows/Browser/Components/LiquidGlassSidebar.swift b/apps/macos2/Spacedrive/Spacedrive/Windows/Browser/Components/LiquidGlassSidebar.swift new file mode 100644 index 000000000..22c8c4512 --- /dev/null +++ b/apps/macos2/Spacedrive/Spacedrive/Windows/Browser/Components/LiquidGlassSidebar.swift @@ -0,0 +1,134 @@ +import SwiftUI + +/// Liquid glass version of the Tahoe sidebar +struct LiquidGlassSidebar: View { + @ObservedObject var browserState: BrowserState + + var body: some View { + VStack(spacing: 0) { + // Sidebar header + HStack { + Text("Locations") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.white.opacity(0.9)) + .textCase(.uppercase) + .tracking(0.5) + + Spacer() + + Button(action: { + withAnimation(.easeInOut(duration: 0.2)) { + browserState.sidebarCollapsed.toggle() + } + }) { + Image(systemName: browserState.sidebarCollapsed ? "chevron.right" : "chevron.left") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.white.opacity(0.8)) + } + .buttonStyle(PlainButtonStyle()) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + + if !browserState.sidebarCollapsed { + // Location buttons + VStack(spacing: 2) { + ForEach(browserState.locations) { location in + LiquidGlassSidebarLocationButton( + location: location, + isSelected: browserState.selectedLocation?.id == location.id + ) { + browserState.selectLocation(location) + } + } + } + .padding(.horizontal, 12) + .padding(.bottom, 12) + } + + Spacer() + } + .frame(width: browserState.sidebarCollapsed ? 60 : 200) + .background(.ultraThinMaterial) + .overlay( + UnevenRoundedRectangle( + topLeadingRadius: 0, + bottomLeadingRadius: 0, + bottomTrailingRadius: 13, // 8% increase from 12 + topTrailingRadius: 13 + ) + .stroke(.white.opacity(0.2), lineWidth: 1) + ) + .clipShape(UnevenRoundedRectangle( + topLeadingRadius: 0, + bottomLeadingRadius: 0, + bottomTrailingRadius: 13, + topTrailingRadius: 13 + )) + .shadow(color: Color.black.opacity(0.1), radius: 8, x: 2, y: 0) + } +} + +/// Liquid glass version of the sidebar location button +struct LiquidGlassSidebarLocationButton: View { + let location: BrowserLocation + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + Image(systemName: location.iconName) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white.opacity(0.9)) + .frame(width: 16) + + if !isSelected { + Text(location.name) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.white.opacity(0.8)) + .lineLimit(1) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + UnevenRoundedRectangle( + topLeadingRadius: 9, // 8% increase from 8 + bottomLeadingRadius: 9, + bottomTrailingRadius: 9, + topTrailingRadius: 9 + ) + .fill(isSelected ? .white.opacity(0.2) : .clear) + .overlay( + UnevenRoundedRectangle( + topLeadingRadius: 9, + bottomLeadingRadius: 9, + bottomTrailingRadius: 9, + topTrailingRadius: 9 + ) + .stroke(.white.opacity(0.1), lineWidth: 1) + ) + ) + } + .buttonStyle(PlainButtonStyle()) + } +} + +#Preview { + ZStack { + // Background to show the glass effect + LinearGradient( + colors: [.blue, .purple, .pink], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + + HStack { + LiquidGlassSidebar(browserState: BrowserState()) + Spacer() + } + } + .frame(width: 1200, height: 800) +} diff --git a/apps/macos2/Spacedrive/Spacedrive/Windows/Browser/Components/SidebarLocationButton.swift b/apps/macos2/Spacedrive/Spacedrive/Windows/Browser/Components/SidebarLocationButton.swift new file mode 100644 index 000000000..5e3305cc4 --- /dev/null +++ b/apps/macos2/Spacedrive/Spacedrive/Windows/Browser/Components/SidebarLocationButton.swift @@ -0,0 +1,54 @@ +import SwiftUI + +struct SidebarLocationButton: View { + let location: BrowserLocation + let isSelected: Bool + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + HStack(spacing: 8) { + Image(systemName: location.iconName) + .foregroundColor(iconColor) + .frame(width: 16, height: 16) + + Text(location.name) + .foregroundColor(textColor) + .font(.system(size: 13, weight: .medium)) + + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(backgroundColor) + .cornerRadius(6) + } + .buttonStyle(PlainButtonStyle()) + } + + private var backgroundColor: Color { + if isSelected { + return SpacedriveColors.Interactive.selected.opacity(0.3) + } else { + return Color.clear + } + } + + private var textColor: Color { + if isSelected { + return SpacedriveColors.Text.primary + } else { + return SpacedriveColors.Text.secondary + } + } + + private var iconColor: Color { + if isSelected { + return SpacedriveColors.Accent.primary + } else { + return SpacedriveColors.Text.tertiary + } + } +} + + diff --git a/apps/macos2/Spacedrive/Spacedrive/Windows/Browser/Components/TahoeSidebar.swift b/apps/macos2/Spacedrive/Spacedrive/Windows/Browser/Components/TahoeSidebar.swift new file mode 100644 index 000000000..01a341143 --- /dev/null +++ b/apps/macos2/Spacedrive/Spacedrive/Windows/Browser/Components/TahoeSidebar.swift @@ -0,0 +1,65 @@ +import SwiftUI + +struct TahoeSidebar: View { + @ObservedObject var browserState: BrowserState + + var body: some View { + VStack(spacing: 0) { + // Sidebar header + HStack { + Text("Locations") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(SpacedriveColors.Text.tertiary) + .textCase(.uppercase) + .tracking(0.5) + + Spacer() + + Button(action: { + withAnimation(.easeInOut(duration: 0.2)) { + browserState.sidebarCollapsed.toggle() + } + }) { + Image(systemName: browserState.sidebarCollapsed ? "chevron.right" : "chevron.left") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(SpacedriveColors.Text.tertiary) + } + .buttonStyle(PlainButtonStyle()) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + + if !browserState.sidebarCollapsed { + // Location buttons + VStack(spacing: 2) { + ForEach(browserState.locations) { location in + SidebarLocationButton( + location: location, + isSelected: browserState.selectedLocation?.id == location.id + ) { + browserState.selectLocation(location) + } + } + } + .padding(.horizontal, 12) + .padding(.bottom, 12) + } + + Spacer() + } + .frame(width: browserState.sidebarCollapsed ? 60 : 200) + .background(SpacedriveColors.Background.secondary) + .clipShape(UnevenRoundedRectangle( + topLeadingRadius: 0, + bottomLeadingRadius: 0, + bottomTrailingRadius: 13, // 8% increase from 12 + topTrailingRadius: 13 + )) + .shadow(color: Color.black.opacity(0.1), radius: 8, x: 2, y: 0) + } +} + +#Preview { + TahoeSidebar(browserState: BrowserState()) + .frame(width: 200, height: 600) +} diff --git a/apps/macos2/Spacedrive/Spacedrive/Windows/Browser/Models/BrowserModels.swift b/apps/macos2/Spacedrive/Spacedrive/Windows/Browser/Models/BrowserModels.swift new file mode 100644 index 000000000..ab92d7967 --- /dev/null +++ b/apps/macos2/Spacedrive/Spacedrive/Windows/Browser/Models/BrowserModels.swift @@ -0,0 +1,143 @@ +import Foundation +import SwiftUI +import Combine + +/// Represents a location/folder in the browser +struct BrowserLocation: Identifiable, Hashable { + let id = UUID() + let name: String + let path: String + let iconName: String + let isDirectory: Bool + + init(name: String, path: String, iconName: String, isDirectory: Bool = true) { + self.name = name + self.path = path + self.iconName = iconName + self.isDirectory = isDirectory + } + + // Computed property for SwiftUI compatibility + var icon: String { iconName } +} + +/// Represents a file or folder item in the browser +struct BrowserItem: Identifiable, Hashable { + let id = UUID() + let name: String + let path: String + let icon: String + let type: String + let size: String + let modifiedDate: String + let isDirectory: Bool +} + +/// Browser state management +@MainActor +class BrowserState: ObservableObject { + @Published var selectedLocation: BrowserLocation? + @Published var currentPath: String = "/" + @Published var locations: [BrowserLocation] = [] + @Published var sidebarCollapsed: Bool = false + @Published var hasNewNotifications: Bool = false + + // Image preview state + @Published var previewImagePath: String? + @Published var isShowingImagePreview: Bool = false + + // Computed properties for the new UI + @Published var currentItems: [BrowserItem] = [] + @Published var recentItems: [BrowserItem] = [] + + init() { + setupDefaultLocations() + setupSampleData() + } + + private func setupDefaultLocations() { + locations = [ + BrowserLocation(name: "Home", path: "/Users/jamespine", iconName: "house.fill"), + BrowserLocation(name: "Desktop", path: "/Users/jamespine/Desktop", iconName: "desktopcomputer"), + BrowserLocation(name: "Documents", path: "/Users/jamespine/Documents", iconName: "doc.fill"), + BrowserLocation(name: "Downloads", path: "/Users/jamespine/Downloads", iconName: "arrow.down.circle.fill"), + BrowserLocation(name: "Pictures", path: "/Users/jamespine/Pictures", iconName: "photo.fill"), + BrowserLocation(name: "Music", path: "/Users/jamespine/Music", iconName: "music.note"), + BrowserLocation(name: "Movies", path: "/Users/jamespine/Movies", iconName: "video.fill"), + BrowserLocation(name: "Applications", path: "/Applications", iconName: "app.fill"), + BrowserLocation(name: "Library", path: "/Users/jamespine/Library", iconName: "folder.fill"), + BrowserLocation(name: "System", path: "/System", iconName: "gear.circle.fill"), + BrowserLocation(name: "Volumes", path: "/Volumes", iconName: "externaldrive.fill") + ] + + selectedLocation = locations.first + } + + private func setupSampleData() { + // Sample current items + currentItems = [ + BrowserItem(name: "Project Files", path: "/Users/jamespine/Desktop/Project Files", icon: "folder", type: "Folder", size: "2.3 GB", modifiedDate: "Today", isDirectory: true), + BrowserItem(name: "Screenshot.png", path: "/Users/jamespine/Desktop/Screenshot.png", icon: "photo", type: "PNG Image", size: "1.2 MB", modifiedDate: "Yesterday", isDirectory: false), + BrowserItem(name: "Document.pdf", path: "/Users/jamespine/Desktop/Document.pdf", icon: "doc.text", type: "PDF Document", size: "856 KB", modifiedDate: "2 days ago", isDirectory: false), + BrowserItem(name: "Video.mp4", path: "/Users/jamespine/Desktop/Video.mp4", icon: "video", type: "MP4 Video", size: "45.2 MB", modifiedDate: "3 days ago", isDirectory: false), + BrowserItem(name: "Archive.zip", path: "/Users/jamespine/Desktop/Archive.zip", icon: "archivebox", type: "ZIP Archive", size: "12.8 MB", modifiedDate: "1 week ago", isDirectory: false), + BrowserItem(name: "Code Project", path: "/Users/jamespine/Desktop/Code Project", icon: "folder.fill", type: "Folder", size: "156 MB", modifiedDate: "1 week ago", isDirectory: true) + ] + + // Sample recent items + recentItems = [ + BrowserItem(name: "Recent Document.docx", path: "/Users/jamespine/Documents/Recent Document.docx", icon: "doc.text.fill", type: "Word Document", size: "234 KB", modifiedDate: "1 hour ago", isDirectory: false), + BrowserItem(name: "Presentation.key", path: "/Users/jamespine/Documents/Presentation.key", icon: "presentation", type: "Keynote Presentation", size: "8.7 MB", modifiedDate: "3 hours ago", isDirectory: false), + BrowserItem(name: "Spreadsheet.xlsx", path: "/Users/jamespine/Documents/Spreadsheet.xlsx", icon: "tablecells", type: "Excel Spreadsheet", size: "1.1 MB", modifiedDate: "5 hours ago", isDirectory: false) + ] + + // Simulate notifications + Task { + try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds + hasNewNotifications = true + } + } + + func selectLocation(_ location: BrowserLocation) { + selectedLocation = location + currentPath = location.path + // In a real app, this would load the actual files from the location + loadItemsForLocation(location) + } + + private func loadItemsForLocation(_ location: BrowserLocation) { + // Simulate loading different items for different locations + switch location.name { + case "Pictures": + currentItems = [ + BrowserItem(name: "Vacation Photos", path: "\(location.path)/Vacation Photos", icon: "photo.on.rectangle", type: "Folder", size: "2.1 GB", modifiedDate: "Today", isDirectory: true), + BrowserItem(name: "Screenshot 2024.png", path: "\(location.path)/Screenshot 2024.png", icon: "photo", type: "PNG Image", size: "2.3 MB", modifiedDate: "Yesterday", isDirectory: false), + BrowserItem(name: "Family Portrait.jpg", path: "\(location.path)/Family Portrait.jpg", icon: "photo", type: "JPEG Image", size: "4.7 MB", modifiedDate: "2 days ago", isDirectory: false) + ] + case "Documents": + currentItems = [ + BrowserItem(name: "Work Projects", path: "\(location.path)/Work Projects", icon: "folder", type: "Folder", size: "1.8 GB", modifiedDate: "Today", isDirectory: true), + BrowserItem(name: "Meeting Notes.txt", path: "\(location.path)/Meeting Notes.txt", icon: "doc.text", type: "Text Document", size: "12 KB", modifiedDate: "Yesterday", isDirectory: false), + BrowserItem(name: "Budget.xlsx", path: "\(location.path)/Budget.xlsx", icon: "tablecells", type: "Excel Spreadsheet", size: "456 KB", modifiedDate: "3 days ago", isDirectory: false) + ] + default: + // Use default sample data + break + } + } + + func toggleSidebar() { + sidebarCollapsed.toggle() + } + + func showImagePreview(path: String) { + previewImagePath = path + isShowingImagePreview = true + } + + func hideImagePreview() { + previewImagePath = nil + isShowingImagePreview = false + } +} + diff --git a/apps/macos2/Spacedrive/Spacedrive/Windows/Browser/Views/BrowserView.swift b/apps/macos2/Spacedrive/Spacedrive/Windows/Browser/Views/BrowserView.swift new file mode 100644 index 000000000..abaa37c3e --- /dev/null +++ b/apps/macos2/Spacedrive/Spacedrive/Windows/Browser/Views/BrowserView.swift @@ -0,0 +1,314 @@ +import SwiftUI + +struct BrowserView: View { + @StateObject private var browserState = BrowserState() + @State private var searchText = "" + @State private var showingInspector = true + @State private var selectedItem: BrowserItem? + + var body: some View { + NavigationSplitView { + // Sidebar + SidebarView(browserState: browserState) + .navigationSplitViewColumnWidth(min: 200, ideal: 250) + } content: { + // Main content + ContentView(browserState: browserState, selectedItem: $selectedItem) + } detail: { + // Inspector + if showingInspector { + InspectorDetailView(selectedItem: selectedItem) + .navigationSplitViewColumnWidth(min: 200, ideal: 220) + } + } + .searchable(text: $searchText, prompt: "Search files...") + .toolbar { + // View controls + ToolbarItemGroup(placement: .primaryAction) { + Button(action: { browserState.toggleSidebar() }) { + Image(systemName: "sidebar.left") + } + .buttonStyle(.glass) + + Button(action: { showingInspector.toggle() }) { + Image(systemName: "sidebar.right") + } + .buttonStyle(.glass) + } + + // Action buttons + ToolbarItemGroup(placement: .secondaryAction) { + Button("New Folder") { + // TODO: Implement new folder + } + .buttonStyle(.glass) + + Button("Import") { + // TODO: Implement import + } + .buttonStyle(.glass) + } + } + .backgroundExtensionEffect() // Enable content to extend behind translucent elements + .onChange(of: selectedItem) { _, newValue in + // Handle selection changes + } + } +} + +// MARK: - Sidebar View +struct SidebarView: View { + @ObservedObject var browserState: BrowserState + + var body: some View { + List(selection: $browserState.selectedLocation) { + locationsSection + recentSection + } + .listStyle(.sidebar) + .navigationTitle("Spacedrive") + } + + private var locationsSection: some View { + Section("Locations") { + ForEach(browserState.locations) { location in + FinderSidebarLocationRow( + location: location, + isSelected: browserState.selectedLocation?.id == location.id + ) { + browserState.selectLocation(location) + } + } + } + } + + private var recentSection: some View { + Section("Recent") { + ForEach(browserState.recentItems.prefix(5)) { item in + Button(action: { + // TODO: Handle recent item selection + }) { + HStack(spacing: 8) { + Image(systemName: item.icon) + .font(.system(size: 16)) + .foregroundColor(.secondary) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 2) { + Text(item.name) + .font(.system(size: 13)) + .foregroundColor(.primary) + .lineLimit(1) + + Text(item.modifiedDate) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + + Spacer() + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + } + .buttonStyle(PlainButtonStyle()) + } + } + } +} + +// MARK: - Content View +struct ContentView: View { + @ObservedObject var browserState: BrowserState + @Binding var selectedItem: BrowserItem? + + var body: some View { + VStack(spacing: 0) { + // Breadcrumb navigation + HStack { + Button(action: {}) { + HStack { + Image(systemName: "chevron.left") + Text("Back") + } + } + .buttonStyle(.glass) + + Spacer() + + Text(browserState.currentPath) + .font(.caption) + .foregroundColor(.secondary) + } + .padding() + + Divider() + + // Content grid + ScrollView { + LazyVGrid(columns: Array(repeating: GridItem(.adaptive(minimum: 120)), count: 1)) { + ForEach(browserState.currentItems) { item in + ContentItemCard(item: item) { + selectedItem = item + } + } + } + .padding() + } + } + .navigationTitle(browserState.selectedLocation?.name ?? "Browser") + } +} + +// MARK: - Content Item Card +struct ContentItemCard: View { + let item: BrowserItem + let onTap: () -> Void + + var body: some View { + VStack(spacing: 8) { + Image(systemName: item.icon) + .font(.system(size: 32)) + .foregroundColor(.primary) + + Text(item.name) + .font(.caption) + .lineLimit(2) + .multilineTextAlignment(.center) + + Text(item.size) + .font(.caption2) + .foregroundColor(.secondary) + } + .frame(width: 100, height: 100) + .background(.ultraThinMaterial) + .clipShape(UnevenRoundedRectangle( + topLeadingRadius: 9, + bottomLeadingRadius: 9, + bottomTrailingRadius: 9, + topTrailingRadius: 9 + )) + .onTapGesture { + onTap() + } + } +} + +// MARK: - Inspector Detail View +struct InspectorDetailView: View { + let selectedItem: BrowserItem? + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + if let item = selectedItem { + // File preview + VStack { + Image(systemName: item.icon) + .font(.system(size: 48)) + .foregroundColor(.primary) + + Text(item.name) + .font(.subheadline) + .multilineTextAlignment(.center) + .lineLimit(2) + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.ultraThinMaterial) + .clipShape(UnevenRoundedRectangle( + topLeadingRadius: 9, + bottomLeadingRadius: 9, + bottomTrailingRadius: 9, + topTrailingRadius: 9 + )) + + // File details + VStack(alignment: .leading, spacing: 6) { + Text("Details") + .font(.subheadline) + .fontWeight(.semibold) + + BrowserPropertyRow(label: "Type", value: item.type) + BrowserPropertyRow(label: "Size", value: item.size) + BrowserPropertyRow(label: "Modified", value: item.modifiedDate) + BrowserPropertyRow(label: "Path", value: item.path) + } + + Spacer() + } else { + VStack { + Image(systemName: "doc") + .font(.system(size: 32)) + .foregroundColor(.secondary) + + Text("No Selection") + .font(.subheadline) + .foregroundColor(.secondary) + + Text("Select a file to view its details") + .font(.caption) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .navigationTitle("Inspector") + } +} + +// MARK: - Property Row +struct BrowserPropertyRow: View { + let label: String + let value: String + + var body: some View { + HStack { + Text(label) + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Text(value) + .font(.caption) + .textSelection(.enabled) + } + } +} + +// MARK: - Finder-Style Sidebar Location Row +struct FinderSidebarLocationRow: View { + let location: BrowserLocation + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + Image(systemName: location.iconName) + .font(.system(size: 16)) + .foregroundColor(.primary) + .frame(width: 20) + + Text(location.name) + .font(.system(size: 13)) + .foregroundColor(.primary) + .lineLimit(1) + + Spacer() + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(isSelected ? Color.accentColor.opacity(0.15) : Color.clear) + ) + } + .buttonStyle(PlainButtonStyle()) + } +} + +#Preview { + BrowserView() + .frame(width: 1200, height: 800) +} diff --git a/apps/macos2/Spacedrive/Spacedrive/Windows/IconShowcase/Views/IconShowcaseView.swift b/apps/macos2/Spacedrive/Spacedrive/Windows/IconShowcase/Views/IconShowcaseView.swift new file mode 100644 index 000000000..64c61826e --- /dev/null +++ b/apps/macos2/Spacedrive/Spacedrive/Windows/IconShowcase/Views/IconShowcaseView.swift @@ -0,0 +1,292 @@ +import SwiftUI + +/// Comprehensive icon showcase window for Spacedrive icons +struct IconShowcaseView: View { + @State private var searchText = "" + @State private var selectedCategory: IconCategory = .all + @State private var selectedSize: IconSize = .default + @State private var showLightVariants = false + @State private var show20pxVariants = false + @EnvironmentObject var appState: SharedAppState + + enum IconCategory: String, CaseIterable { + case all = "All Icons" + case fileTypes = "File Types" + case drives = "Drives & Cloud" + case devices = "Devices" + case ui = "UI Elements" + + var icons: [SpacedriveIcon] { + switch self { + case .all: + return SpacedriveIcon.allCases + case .fileTypes: + return SpacedriveIcon.allCases.filter { icon in + ["Album", "Alias", "Application", "Archive", "Audio", "Book", "Collection", + "Database", "Document", "Encrypted", "Entity", "Executable", "Folder", + "Game", "Image", "Key", "Link", "Lock", "Mesh", "Movie", "Package", + "Screenshot", "Text", "TexturedMesh", "Trash", "Undefined", "Video", "Widget"] + .contains(icon.baseName) + } + case .drives: + return SpacedriveIcon.allCases.filter { icon in + icon.rawValue.contains("Drive") || + ["AmazonS3", "BackBlaze", "Box", "DAV", "Dropbox", "GoogleDrive", "Mega", + "OneDrive", "OpenStack", "PCloud", "Location", "Sync", "Spacedrop"] + .contains(icon.baseName) + } + case .devices: + return SpacedriveIcon.allCases.filter { icon in + ["Ball", "Globe", "HDD", "Heart", "Home", "Laptop", "Mobile", "PC", + "Server", "Tablet", "Terminal"] + .contains(icon.baseName) + } + case .ui: + return SpacedriveIcon.allCases.filter { icon in + ["Search", "Tags", "Scrapbook", "Screenshot", "Face", "Entity"] + .contains(icon.baseName) + } + } + } + } + + enum IconSize: String, CaseIterable { + case small = "Small (16px)" + case medium = "Medium (24px)" + case large = "Large (32px)" + case xlarge = "Extra Large (48px)" + + var size: CGFloat { + switch self { + case .small: return 16 + case .medium: return 24 + case .large: return 32 + case .xlarge: return 48 + } + } + + static let `default` = IconSize.xlarge + } + + private var filteredIcons: [SpacedriveIcon] { + let categoryIcons = selectedCategory.icons + + let filtered = categoryIcons.filter { icon in + // Search filter + let matchesSearch = searchText.isEmpty || + icon.displayName.localizedCaseInsensitiveContains(searchText) || + icon.rawValue.localizedCaseInsensitiveContains(searchText) + + // Variant filters - hide variants by default unless explicitly shown + let matchesLightFilter = showLightVariants || !icon.isLightVariant + let matches20pxFilter = show20pxVariants || !icon.is20pxVariant + + return matchesSearch && matchesLightFilter && matches20pxFilter + } + + return filtered.sorted { $0.displayName < $1.displayName } + } + + var body: some View { + WindowContainer { + NavigationView { + // Sidebar + VStack(alignment: .leading, spacing: 16) { + Text("Icon Showcase") + .font(.title2) + .fontWeight(.bold) + .foregroundColor(SpacedriveColors.Text.primary) + .padding(.horizontal) + .padding(.top, 16) + + // Search + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(SpacedriveColors.Text.secondary) + TextField("Search icons...", text: $searchText) + .textFieldStyle(RoundedBorderTextFieldStyle()) + } + .padding(.horizontal) + + // Category Selection + VStack(alignment: .leading, spacing: 8) { + Text("Categories") + .font(.headline) + .foregroundColor(SpacedriveColors.Text.primary) + .padding(.horizontal) + + ForEach(IconCategory.allCases, id: \.self) { category in + Button(action: { + selectedCategory = category + }) { + HStack { + Text(category.rawValue) + Spacer() + if selectedCategory == category { + Image(systemName: "checkmark") + .foregroundColor(.accentColor) + } + } + .padding(.horizontal) + .padding(.vertical, 4) + } + .buttonStyle(PlainButtonStyle()) + .background(selectedCategory == category ? Color.accentColor.opacity(0.1) : Color.clear) + .cornerRadius(6) + .padding(.horizontal) + } + } + + Divider() + + // Size Selection + VStack(alignment: .leading, spacing: 8) { + Text("Icon Size") + .font(.headline) + .foregroundColor(SpacedriveColors.Text.primary) + .padding(.horizontal) + + ForEach(IconSize.allCases, id: \.self) { size in + Button(action: { + selectedSize = size + }) { + HStack { + Text(size.rawValue) + Spacer() + if selectedSize == size { + Image(systemName: "checkmark") + .foregroundColor(.accentColor) + } + } + .padding(.horizontal) + .padding(.vertical, 4) + } + .buttonStyle(PlainButtonStyle()) + .background(selectedSize == size ? Color.accentColor.opacity(0.1) : Color.clear) + .cornerRadius(6) + .padding(.horizontal) + } + } + + Divider() + + // Variant Filters + VStack(alignment: .leading, spacing: 8) { + Text("Show Variants") + .font(.headline) + .foregroundColor(SpacedriveColors.Text.primary) + .padding(.horizontal) + + Toggle("Light Theme", isOn: $showLightVariants) + .padding(.horizontal) + + Toggle("20px Variants", isOn: $show20pxVariants) + .padding(.horizontal) + } + + Spacer() + + // Stats + VStack(alignment: .leading, spacing: 4) { + Text("Showing \(filteredIcons.count) icons") + .font(.caption) + .foregroundColor(SpacedriveColors.Text.secondary) + .padding(.horizontal) + } + } + .frame(width: 280) + .background(SpacedriveColors.Background.secondary) + + // Main Content + ScrollView { + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 16), count: 5), spacing: 20) { + ForEach(filteredIcons, id: \.self) { icon in + IconShowcaseCard( + icon: icon, + size: selectedSize.size, + showLightVariants: showLightVariants, + show20pxVariants: show20pxVariants + ) + } + } + .padding(24) + } + .background(SpacedriveColors.Background.primary) + } + .navigationTitle("Spacedrive Icons") + .frame(minWidth: 800, minHeight: 600) + } + } +} + +/// Individual icon card in the showcase +struct IconShowcaseCard: View { + let icon: SpacedriveIcon + let size: CGFloat + let showLightVariants: Bool + let show20pxVariants: Bool + @State private var isHovered = false + + var body: some View { + VStack(spacing: 8) { + // Icon display + SpacedriveIconView(icon, size: size) + .frame(width: size + 8, height: size + 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(isHovered ? Color.accentColor.opacity(0.1) : Color.clear) + ) + + // Variant indicators + HStack(spacing: 4) { + if icon.isLightVariant { + Text("L") + .font(.caption2) + .foregroundColor(.white) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background(Color.blue) + .cornerRadius(4) + } + + if icon.is20pxVariant { + Text("20") + .font(.caption2) + .foregroundColor(.white) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background(Color.green) + .cornerRadius(4) + } + } + + // Filename + Text(icon.filename) + .font(.caption2) + .foregroundColor(SpacedriveColors.Text.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(SpacedriveColors.Background.tertiary) + .shadow(color: Color.black.opacity(0.1), radius: 2, x: 0, y: 1) + ) + .scaleEffect(isHovered ? 1.02 : 1.0) + .animation(.easeInOut(duration: 0.2), value: isHovered) + .onHover { hovering in + isHovered = hovering + } + .help("\(icon.displayName)\n\(icon.filename)") + } +} + +/// Preview for the icon showcase +struct IconShowcaseView_Previews: PreviewProvider { + static var previews: some View { + IconShowcaseView() + .environmentObject(SharedAppState.shared) + .frame(width: 1000, height: 800) + } +} diff --git a/apps/macos2/Spacedrive/Spacedrive/Windows/Inspector/ViewModels/InspectorViewModel.swift b/apps/macos2/Spacedrive/Spacedrive/Windows/Inspector/ViewModels/InspectorViewModel.swift new file mode 100644 index 000000000..6e6f8aa98 --- /dev/null +++ b/apps/macos2/Spacedrive/Spacedrive/Windows/Inspector/ViewModels/InspectorViewModel.swift @@ -0,0 +1,135 @@ +import Foundation +import os.log +import SpacedriveClient +import SwiftUI +import Combine + +/// Custom errors for the Inspector +enum InspectorError: LocalizedError { + case daemonConnectorNotAvailable + case invalidFileURL + case fileNotFound + + var errorDescription: String? { + switch self { + case .daemonConnectorNotAvailable: + return "Daemon connector is not available" + case .invalidFileURL: + return "Invalid file URL provided" + case .fileNotFound: + return "File not found" + } + } +} + +/// ViewModel for the Inspector window +@MainActor +class InspectorViewModel: ObservableObject { + @Published var file: File? + @Published var isLoading = false + @Published var errorMessage: String? + @Published var draggedFileURL: URL? + + private var daemonConnector: DaemonConnector? + private let logger = Logger(subsystem: "com.spacedrive.inspector", category: "InspectorViewModel") + + init() { + logger.info("InspectorViewModel initialized") + setupDaemonConnector() + } + + deinit { + logger.info("InspectorViewModel deinitialized") + } + + private func setupDaemonConnector() { + logger.info("Setting up DaemonConnector") + daemonConnector = DaemonConnector() + logger.info("DaemonConnector setup complete") + } + + /// Load file information by path + func loadFileByPath(_ url: URL) { + logger.info("Starting to load file: \(url.path)") + + isLoading = true + errorMessage = nil + draggedFileURL = url + + Task { + do { + logger.info("Creating FileByPathQuery for path: \(url.path)") + + // Create the query with the local path directly + let query = FileByPathQuery(path: url.path) + + logger.info("Query created successfully, executing...") + + // Execute the query + guard let daemonConnector = daemonConnector else { + logger.error("DaemonConnector is nil") + throw InspectorError.daemonConnectorNotAvailable + } + + let result = try await daemonConnector.queryFileByPath(query) + + logger.info("Query executed successfully, result: \(result != nil ? "file found" : "file not found")") + + await MainActor.run { + self.file = result + self.isLoading = false + if result != nil { + self.logger.info("File loaded successfully: \(url.lastPathComponent)") + } else { + self.logger.warning("File not found in database: \(url.path)") + self.errorMessage = "File not found in Spacedrive database" + } + } + } catch { + logger.error("Failed to load file: \(error.localizedDescription)") + logger.error("Error details: \(String(describing: error))") + + await MainActor.run { + self.errorMessage = "Failed to load file: \(error.localizedDescription)" + self.isLoading = false + } + } + } + } + + /// Clear the current file + func clearFile() { + logger.info("Clearing current file") + file = nil + errorMessage = nil + draggedFileURL = nil + logger.info("File cleared successfully") + } + + /// Check if a file URL is valid for inspection + func isValidFileURL(_ url: URL) -> Bool { + logger.info("Validating file URL: \(url.path)") + + // Check if it's a file URL and the file exists + guard url.isFileURL else { + logger.warning("Invalid file URL (not a file URL): \(url)") + return false + } + + var isDirectory: ObjCBool = false + let exists = FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) + + // Only allow files, not directories + let isValid = exists && !isDirectory.boolValue + + if !exists { + logger.warning("File does not exist: \(url.path)") + } else if isDirectory.boolValue { + logger.warning("Path is a directory, not a file: \(url.path)") + } else { + logger.info("File URL is valid: \(url.lastPathComponent)") + } + + return isValid + } +} diff --git a/apps/macos2/Spacedrive/Spacedrive/Windows/Inspector/Views/InspectorView.swift b/apps/macos2/Spacedrive/Spacedrive/Windows/Inspector/Views/InspectorView.swift new file mode 100644 index 000000000..1bd0ca56e --- /dev/null +++ b/apps/macos2/Spacedrive/Spacedrive/Windows/Inspector/Views/InspectorView.swift @@ -0,0 +1,422 @@ +import AppKit +import SpacedriveClient +import SwiftUI +import UniformTypeIdentifiers + +/// Inspector window view for displaying file information +struct InspectorView: View { + @StateObject private var viewModel = InspectorViewModel() + + var body: some View { + VStack(spacing: 0) { + // Header + headerView + + Divider() + + // Content + contentView + } + .background(Color(NSColor.windowBackgroundColor)) + .onDrop(of: [.fileURL], isTargeted: nil) { providers in + handleFileDrop(providers) + } + } + + // MARK: - Header View + + private var headerView: some View { + HStack { + Image(systemName: "doc.text.magnifyingglass") + .foregroundColor(.accentColor) + .font(.title2) + + VStack(alignment: .leading, spacing: 2) { + Text("File Inspector") + .font(.headline) + .foregroundColor(.primary) + + Text("Drag a file here to inspect") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + if viewModel.file != nil { + Button("Clear") { + viewModel.clearFile() + } + .buttonStyle(.borderless) + } + } + .padding() + } + + // MARK: - Content View + + private var contentView: some View { + Group { + if viewModel.isLoading { + loadingView + } else if let errorMessage = viewModel.errorMessage { + errorView(errorMessage) + } else if let file = viewModel.file { + fileInfoView(file) + } else { + emptyStateView + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Loading View + + private var loadingView: some View { + VStack(spacing: 16) { + ProgressView() + .scaleEffect(1.2) + + Text("Loading file information...") + .font(.subheadline) + .foregroundColor(.secondary) + } + } + + // MARK: - Error View + + private func errorView(_ message: String) -> some View { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.largeTitle) + .foregroundColor(.red) + + Text("Error") + .font(.headline) + .foregroundColor(.primary) + + Text(message) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + } + + // MARK: - Empty State View + + private var emptyStateView: some View { + VStack(spacing: 20) { + Image(systemName: "doc.badge.plus") + .font(.system(size: 60)) + .foregroundColor(.secondary) + + VStack(spacing: 8) { + Text("No File Selected") + .font(.headline) + .foregroundColor(.primary) + + Text("Drag and drop a file from your system to inspect its properties") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + } + } + + // MARK: - File Info View + + private func fileInfoView(_ file: File) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + // File Header + fileHeaderView(file) + + Divider() + + // Basic Properties + basicPropertiesView(file) + + Divider() + + // Content Identity + if let contentIdentity = file.contentIdentity { + contentIdentityView(contentIdentity) + + Divider() + } + + // Tags + if !file.tags.isEmpty { + tagsView(file.tags) + + Divider() + } + + // Sidecars + if !file.sidecars.isEmpty { + sidecarsView(file.sidecars) + + Divider() + } + + // Alternate Paths + if !file.alternatePaths.isEmpty { + alternatePathsView(file.alternatePaths) + } + } + .padding() + } + } + + // MARK: - File Header + + private func fileHeaderView(_ file: File) -> some View { + HStack(spacing: 12) { + // File Icon + Image(systemName: "doc") + .font(.system(size: 40)) + .foregroundColor(.accentColor) + + VStack(alignment: .leading, spacing: 4) { + Text(file.name) + .font(.headline) + .foregroundColor(.primary) + + Text("File Path") + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(2) + } + + Spacer() + } + } + + // MARK: - Basic Properties + + private func basicPropertiesView(_ file: File) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Properties") + .font(.headline) + .foregroundColor(.primary) + + LazyVGrid(columns: [ + GridItem(.flexible()), + GridItem(.flexible()), + ], spacing: 8) { + PropertyRow(label: "Size", value: ByteCountFormatter.string(fromByteCount: Int64(file.size), countStyle: .file)) + PropertyRow(label: "Created", value: file.createdAt) + PropertyRow(label: "Modified", value: file.modifiedAt) + PropertyRow(label: "Content Kind", value: file.contentKind.rawValue) + PropertyRow(label: "Extension", value: file.extension ?? "None") + PropertyRow(label: "Is Local", value: file.isLocal ? "Yes" : "No") + } + } + } + + // MARK: - Content Identity + + private func contentIdentityView(_ contentIdentity: ContentIdentity) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Content Identity") + .font(.headline) + .foregroundColor(.primary) + + VStack(alignment: .leading, spacing: 4) { + PropertyRow(label: "UUID", value: contentIdentity.uuid) + PropertyRow(label: "Kind", value: contentIdentity.kind.rawValue) + PropertyRow(label: "Hash", value: String(contentIdentity.hash.prefix(16)) + "...") + PropertyRow(label: "Created", value: contentIdentity.createdAt) + } + } + } + + // MARK: - Tags + + private func tagsView(_ tags: [Tag]) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Tags") + .font(.headline) + .foregroundColor(.primary) + + LazyVGrid(columns: [ + GridItem(.flexible()), + GridItem(.flexible()), + ], spacing: 8) { + ForEach(tags, id: \.id) { tag in + TagChip(tag: tag) + } + } + } + } + + // MARK: - Sidecars + + private func sidecarsView(_ sidecars: [Sidecar]) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Sidecars") + .font(.headline) + .foregroundColor(.primary) + + ForEach(sidecars, id: \.id) { sidecar in + SidecarRow(sidecar: sidecar) + } + } + } + + // MARK: - Alternate Paths + + private func alternatePathsView(_ paths: [SdPath]) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Alternate Paths") + .font(.headline) + .foregroundColor(.primary) + + ForEach(paths.indices, id: \.self) { _ in + HStack { + Image(systemName: "doc.on.doc") + .foregroundColor(.secondary) + + Text("Alternate Path") + .font(.caption) + .foregroundColor(.secondary) + + Spacer() + } + .padding(.vertical, 2) + } + } + } + + // MARK: - File Drop Handler + + private func handleFileDrop(_ providers: [NSItemProvider]) -> Bool { + guard let provider = providers.first else { return false } + + provider.loadItem(forTypeIdentifier: "public.file-url", options: nil) { item, _ in + guard let data = item as? Data, + let url = URL(dataRepresentation: data, relativeTo: nil) + else { + return + } + + DispatchQueue.main.async { + if viewModel.isValidFileURL(url) { + viewModel.loadFileByPath(url) + } + } + } + + return true + } +} + +// MARK: - Helper Views + +struct PropertyRow: View { + let label: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.caption) + .foregroundColor(.secondary) + + Text(value) + .font(.subheadline) + .foregroundColor(.primary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct TagChip: View { + let tag: Tag + + var body: some View { + HStack { + Circle() + .fill(Color(hex: tag.color ?? "#666666")) + .frame(width: 8, height: 8) + + Text(tag.displayName ?? tag.canonicalName) + .font(.caption) + .foregroundColor(.primary) + + Spacer() + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color(NSColor.controlBackgroundColor)) + .cornerRadius(4) + } +} + +struct SidecarRow: View { + let sidecar: Sidecar + + var body: some View { + HStack { + Image(systemName: "doc.badge.gearshape") + .foregroundColor(.secondary) + + VStack(alignment: .leading, spacing: 2) { + Text(sidecar.kind) + .font(.subheadline) + .foregroundColor(.primary) + + Text("\(ByteCountFormatter.string(fromByteCount: sidecar.size, countStyle: .file)) • \(sidecar.status)") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + Text(sidecar.format) + .font(.caption) + .foregroundColor(.secondary) + } + .padding(.vertical, 4) + } +} + +// MARK: - Extensions + +extension DateFormatter { + static let shortDateTime: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .short + return formatter + }() +} + +extension Color { + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 3: // RGB (12-bit) + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: // RGB (24-bit) + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: // ARGB (32-bit) + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (1, 1, 1, 0) + } + + self.init( + .sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255 + ) + } +} diff --git a/apps/macos2/Spacedrive/Spacedrive/Windows/Jobs/Models/JobModels.swift b/apps/macos2/Spacedrive/Spacedrive/Windows/Jobs/Models/JobModels.swift new file mode 100644 index 000000000..01655924c --- /dev/null +++ b/apps/macos2/Spacedrive/Spacedrive/Windows/Jobs/Models/JobModels.swift @@ -0,0 +1,311 @@ +import Foundation +import SpacedriveClient + +extension JsonValue { + var stringValue: String? { + if case let .string(string) = self { + return string + } + return nil + } + + var dictionaryValue: [String: JsonValue]? { + if case let .object(dict) = self { + return dict + } + return nil + } +} + +// MARK: - Job Models + +struct JobInfo: Codable, Identifiable { + let id: String + var name: String + var status: JobStatus + var progress: Double + let startedAt: Date + var completedAt: Date? + var errorMessage: String? + + // Enhanced fields from improved job progress events + var currentPhase: String? + var completionInfo: String? + var hasIssues: Bool = false + var issuesInfo: String? + var currentPath: String? // Current file/directory being processed + var jobType: String? // Store the original job type for title display + + // Action context fields for richer job information + var actionType: String? + var actionContext: ActionContextInfo? + + enum CodingKeys: String, CodingKey { + case id + case name + case status + case progress + case startedAt = "started_at" + case completedAt = "completed_at" + case errorMessage = "error_message" + case currentPhase = "current_phase" + case completionInfo = "completion_info" + case hasIssues = "has_issues" + case issuesInfo = "issues_info" + case currentPath = "current_path" + case jobType = "job_type" + case actionType = "action_type" + case actionContext = "action_context" + } + + // Convenience initializer from generated JobListItem + init(from jobListItem: JobListItem) { + id = jobListItem.id + name = jobListItem.name + status = JobStatus(rawValue: jobListItem.status.rawValue) ?? .failed + progress = Double(jobListItem.progress) + startedAt = Date() // TODO: Get actual start time from daemon + completedAt = nil // TODO: Get actual completion time from daemon + errorMessage = nil // TODO: Get error message from daemon if status is failed + currentPhase = nil + completionInfo = nil + hasIssues = false + issuesInfo = nil + currentPath = nil + jobType = nil + actionType = jobListItem.actionType + actionContext = jobListItem.actionContext + } + + // Convenience initializer for creating JobInfo with individual parameters + init(id: String, name: String, status: JobStatus, progress: Double, startedAt: Date, completedAt: Date? = nil, errorMessage: String? = nil, currentPhase: String? = nil, completionInfo: String? = nil, hasIssues: Bool = false, issuesInfo: String? = nil, currentPath: String? = nil, jobType: String? = nil, actionType: String? = nil, actionContext: ActionContextInfo? = nil) { + self.id = id + self.name = name + self.status = status + self.progress = progress + self.startedAt = startedAt + self.completedAt = completedAt + self.errorMessage = errorMessage + self.currentPhase = currentPhase + self.completionInfo = completionInfo + self.hasIssues = hasIssues + self.issuesInfo = issuesInfo + self.currentPath = currentPath + self.jobType = jobType + self.actionType = actionType + self.actionContext = actionContext + } + + // Computed property for richer job name display using raw data + var displayName: String { + // If we have action context, extract meaningful info from the raw data + if let actionContext = actionContext { + switch actionContext.actionType { + case "locations.add": + let inputObj = actionContext.actionInput.dictionaryValue + if let pathValue = inputObj?["path"], + let path = pathValue.stringValue + { + // Extract directory name from path + let directoryName = URL(fileURLWithPath: path).lastPathComponent + return "Adding '\(directoryName)'" + } + return "Adding Location" + case "files.copy": + let inputObj = actionContext.actionInput.dictionaryValue + if let sourceValue = inputObj?["source"], + let source = sourceValue.stringValue + { + let sourceName = URL(fileURLWithPath: source).lastPathComponent + return "Copying '\(sourceName)'" + } + return "Copying Files" + case "files.move": + let inputObj = actionContext.actionInput.dictionaryValue + if let sourceValue = inputObj?["source"], + let source = sourceValue.stringValue + { + let sourceName = URL(fileURLWithPath: source).lastPathComponent + return "Moving '\(sourceName)'" + } + return "Moving Files" + case "files.delete": + let inputObj = actionContext.actionInput.dictionaryValue + if let targetValue = inputObj?["target"], + let target = targetValue.stringValue + { + let targetName = URL(fileURLWithPath: target).lastPathComponent + return "Deleting '\(targetName)'" + } + return "Deleting Files" + case "media.thumbnail": + return "Generating Thumbnails" + case "media.extract": + return "Extracting Media" + default: + // For unknown action types, try to format them nicely + return actionContext.actionType.replacingOccurrences(of: ".", with: " ").capitalized + } + } + + // Fallback to the original name or job type + return jobType?.capitalized ?? name.capitalized + } + + // Computed property for additional context information + var contextInfo: String? { + guard let actionContext = actionContext else { return nil } + + // Extract meaningful info from raw action input + switch actionContext.actionType { + case "locations.add": + let inputObj = actionContext.actionInput.dictionaryValue + if let pathValue = inputObj?["path"], + let path = pathValue.stringValue + { + // Extract directory name from path + let directoryName = URL(fileURLWithPath: path).lastPathComponent + return "Adding '\(directoryName)' at \(path)" + } + case "files.copy": + let inputObj = actionContext.actionInput.dictionaryValue + if let sourceValue = inputObj?["source"], + let source = sourceValue.stringValue + { + if let destValue = inputObj?["destination"], + let destination = destValue.stringValue + { + return "Copying from \(source) to \(destination)" + } else { + return "Copying from \(source)" + } + } + case "files.move": + let inputObj = actionContext.actionInput.dictionaryValue + if let sourceValue = inputObj?["source"], + let source = sourceValue.stringValue + { + if let destValue = inputObj?["destination"], + let destination = destValue.stringValue + { + return "Moving from \(source) to \(destination)" + } else { + return "Moving from \(source)" + } + } + case "files.delete": + let inputObj = actionContext.actionInput.dictionaryValue + if let targetValue = inputObj?["target"], + let target = targetValue.stringValue + { + return "Deleting \(target)" + } + case "media.thumbnail": + return "Generating thumbnails for media files" + case "media.extract": + return "Extracting metadata from media files" + default: + break + } + + return nil + } +} + +enum JobStatus: String, Codable, CaseIterable { + case running + case completed + case failed + case paused + case queued + case cancelled + + var displayName: String { + switch self { + case .running: + return "Running" + case .completed: + return "Completed" + case .failed: + return "Failed" + case .paused: + return "Paused" + case .queued: + return "Queued" + case .cancelled: + return "Cancelled" + } + } + + init(from generatedStatus: JobStatus) { + switch generatedStatus { + case .running: + self = .running + case .completed: + self = .completed + case .failed: + self = .failed + case .paused: + self = .paused + case .queued: + self = .queued + case .cancelled: + self = .cancelled + } + } + + var icon: String { + switch self { + case .running: + return "circle.fill" + case .completed: + return "checkmark.circle.fill" + case .failed: + return "xmark.circle.fill" + case .paused: + return "pause.circle.fill" + case .queued: + return "clock.fill" + case .cancelled: + return "xmark.circle.fill" + } + } +} + +// MARK: - Legacy Models (to be replaced with generated types) + +// TODO: Replace these with generated types once Event has JsonSchema derive +// For now, keeping minimal models for the companion app + +// MARK: - Connection Status + +enum ConnectionStatus: Equatable { + case disconnected + case connecting + case connected + case error(String) + + var displayName: String { + switch self { + case .disconnected: + return "Disconnected" + case .connecting: + return "Connecting..." + case .connected: + return "Connected" + case let .error(message): + return "Error: \(message)" + } + } + + var color: String { + switch self { + case .disconnected, .error: + return "red" + case .connecting: + return "yellow" + case .connected: + return "green" + } + } +} diff --git a/apps/macos2/Spacedrive/Spacedrive/Windows/Jobs/ViewModels/JobListViewModel.swift b/apps/macos2/Spacedrive/Spacedrive/Windows/Jobs/ViewModels/JobListViewModel.swift new file mode 100644 index 000000000..924f7db93 --- /dev/null +++ b/apps/macos2/Spacedrive/Spacedrive/Windows/Jobs/ViewModels/JobListViewModel.swift @@ -0,0 +1,88 @@ +import Combine +import Foundation + +@MainActor +class JobListViewModel: ObservableObject { + @Published var jobs: [JobInfo] = [] + @Published var connectionStatus: ConnectionStatus = .disconnected + + private var daemonConnector: DaemonConnector? + private var cancellables = Set() + + init() { + setupDaemonConnector() + } + + deinit { + daemonConnector = nil + } + + private func setupDaemonConnector() { + daemonConnector = DaemonConnector() + + // Bind daemon connector's published properties to our own + daemonConnector?.$jobs + .receive(on: DispatchQueue.main) + .assign(to: \.jobs, on: self) + .store(in: &cancellables) + + daemonConnector?.$connectionStatus + .receive(on: DispatchQueue.main) + .assign(to: \.connectionStatus, on: self) + .store(in: &cancellables) + } + + func reconnect() { + daemonConnector?.reconnect() + } + + func disconnect() { + daemonConnector?.disconnect() + } + + // MARK: - Computed Properties + + var activeJobs: [JobInfo] { + jobs.filter { job in + job.status == .running || job.status == .queued + } + } + + var completedJobs: [JobInfo] { + jobs.filter { job in + job.status == .completed + } + } + + var failedJobs: [JobInfo] { + jobs.filter { job in + job.status == .failed + } + } + + var jobCounts: (active: Int, completed: Int, failed: Int) { + return ( + active: activeJobs.count, + completed: completedJobs.count, + failed: failedJobs.count + ) + } + + // MARK: - Helper Methods + + func job(withId id: String) -> JobInfo? { + return jobs.first { $0.id == id } + } + + func removeCompletedJobs() { + jobs.removeAll { job in + job.status == .completed && + job.completedAt != nil && + Date().timeIntervalSince(job.completedAt!) > 3600 // Remove completed jobs older than 1 hour + } + } + + func clearAllJobs() { + jobs.removeAll() + } +} diff --git a/apps/macos2/Spacedrive/Spacedrive/Windows/Jobs/Views/JobCompanionView.swift b/apps/macos2/Spacedrive/Spacedrive/Windows/Jobs/Views/JobCompanionView.swift new file mode 100644 index 000000000..a6a7aa7dd --- /dev/null +++ b/apps/macos2/Spacedrive/Spacedrive/Windows/Jobs/Views/JobCompanionView.swift @@ -0,0 +1,45 @@ +import AppKit +import SwiftUI + +/// Job Companion Window - The floating job monitor window +struct JobCompanionView: View { + @EnvironmentObject var appState: SharedAppState + + var body: some View { + WindowContainer { + JobMonitorView() + .frame(minWidth: 300, minHeight: 400) + } + } +} + +#Preview { + let appState = SharedAppState.shared + + // Add some sample jobs for preview + appState.globalJobs = [ + JobInfo( + id: "1", + name: "file_indexer", + status: .running, + progress: 0.65, + startedAt: Date().addingTimeInterval(-300), + completedAt: nil, + errorMessage: nil + ), + JobInfo( + id: "2", + name: "thumbnail_generator", + status: .completed, + progress: 1.0, + startedAt: Date().addingTimeInterval(-600), + completedAt: Date().addingTimeInterval(-60), + errorMessage: nil + ), + ] + + return JobCompanionView() + .environmentObject(appState) + .frame(width: 400, height: 600) + .background(SpacedriveColors.Background.primary) +} diff --git a/apps/macos2/Spacedrive/Spacedrive/Windows/Jobs/Views/JobMonitorView.swift b/apps/macos2/Spacedrive/Spacedrive/Windows/Jobs/Views/JobMonitorView.swift new file mode 100644 index 000000000..0eef8e669 --- /dev/null +++ b/apps/macos2/Spacedrive/Spacedrive/Windows/Jobs/Views/JobMonitorView.swift @@ -0,0 +1,103 @@ +import SwiftUI + +struct JobMonitorView: View { + @EnvironmentObject var appState: SharedAppState + + var body: some View { + VStack(spacing: 0) { + // Library selector at the top + LibrarySelector() + .padding(.horizontal, 12) + .padding(.vertical, 8) + + Rectangle() + .fill(SpacedriveColors.Border.secondary) + .frame(height: 1) + + // Job list + if appState.globalJobs.isEmpty { + emptyStateView + } else { + jobListView + } + + Spacer() + } + // .background(SpacedriveColors.Background.primary) + } + + private var emptyStateView: some View { + VStack(spacing: 16) { + SpacedriveIconView(.folderNoSpace, size: 100) + .foregroundColor(SpacedriveColors.Accent.success.opacity(0.6)) + + VStack(spacing: 8) { + Text("No Active Jobs") + .h4() + + Text("All jobs are completed or no jobs are currently running.") + .bodySmall(color: SpacedriveColors.Text.secondary) + .multilineTextAlignment(.center) + } + } + .frame(maxWidth: .infinity) + .padding(32) + } + + private var jobListView: some View { + ScrollView { + LazyVStack(spacing: 6) { + ForEach(appState.globalJobs) { job in + JobRowView(job: job) + .transition(.asymmetric( + insertion: .scale.combined(with: .opacity), + removal: .opacity + )) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + .animation(.easeInOut(duration: 0.2), value: appState.globalJobs.count) + } +} + +#Preview { + let appState = SharedAppState.shared + + // Add some sample jobs for preview + appState.globalJobs = [ + JobInfo( + id: "1", + name: "file_indexer", + status: .running, + progress: 0.65, + startedAt: Date().addingTimeInterval(-300), + completedAt: nil, + errorMessage: nil + ), + JobInfo( + id: "2", + name: "thumbnail_generator", + status: .completed, + progress: 1.0, + startedAt: Date().addingTimeInterval(-600), + completedAt: Date().addingTimeInterval(-60), + errorMessage: nil + ), + JobInfo( + id: "3", + name: "file_copy", + status: .failed, + progress: 0.3, + startedAt: Date().addingTimeInterval(-120), + completedAt: Date().addingTimeInterval(-30), + errorMessage: "Permission denied" + ), + ] + + return JobMonitorView() + .environmentObject(appState) + .frame(width: 400, height: 600) + .background(SpacedriveColors.Background.primary) +} diff --git a/apps/macos2/Spacedrive/Spacedrive/Windows/Jobs/Views/JobRowView.swift b/apps/macos2/Spacedrive/Spacedrive/Windows/Jobs/Views/JobRowView.swift new file mode 100644 index 000000000..15aaff400 --- /dev/null +++ b/apps/macos2/Spacedrive/Spacedrive/Windows/Jobs/Views/JobRowView.swift @@ -0,0 +1,368 @@ +import SpacedriveClient +import SwiftUI + +struct JobRowView: View { + let job: JobInfo + @Environment(\.window) private var window + @State private var isWindowActive = true + + private var progressPercentage: String { + return String(format: "%.0f%%", job.progress * 100) + } + + private var timeAgo: String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: job.startedAt, relativeTo: Date()) + } + + private var duration: String? { + guard let completedAt = job.completedAt else { return nil } + let duration = completedAt.timeIntervalSince(job.startedAt) + + if duration < 60 { + return String(format: "%.0fs", duration) + } else if duration < 3600 { + return String(format: "%.0fm", duration / 60) + } else { + return String(format: "%.1fh", duration / 3600) + } + } + + private var subtextForJob: String { + // Show current path when available and job is running + if job.status == .running, let currentPath = job.currentPath, !currentPath.isEmpty { + return currentPath + } + + // Show context info if available (for queued jobs especially) + if let contextInfo = job.contextInfo, !contextInfo.isEmpty { + return contextInfo + } + + // Default subtext based on job status and type + switch job.status { + case .running: + if let phase = job.currentPhase { + return phase + } else { + return "Processing..." + } + case .completed: + if let duration = duration { + return "Completed in \(duration)" + } else { + return "Completed successfully" + } + case .failed: + return job.errorMessage ?? "Job failed" + case .paused: + return "Paused" + case .queued: + return "Waiting to start" + case .cancelled: + return "Cancelled" + } + } + + var body: some View { + SDJobCard { + HStack(spacing: 10) { + // Status indicator - simple colored circle + Circle() + .fill(statusColor) + .frame(width: 8, height: 8) + .opacity(job.status == .completed ? 1.0 : 0.8) + + // Main content area with equal spacing between rows + VStack(alignment: .leading, spacing: 6) { + // Row 1: Job title and status + HStack { + Text(job.displayName) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(SpacedriveColors.Text.primary) + .lineLimit(1) + .padding(.bottom, -2) + .truncationMode(.tail) + + Spacer() + // Status and progress info + HStack(spacing: 6) { + // Pause/Resume button for active jobs + if job.status == .running || job.status == .paused { + JobActionButton(job: job) + } + if job.status == .running { + // Show phase if available, otherwise percentage + if let phase = job.currentPhase, !phase.isEmpty { + Text(phase) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(SpacedriveColors.Text.secondary) + } else { + Text(progressPercentage) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(SpacedriveColors.Text.secondary) + } + } else { + Text(job.status.displayName) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(job.status == .failed ? SpacedriveColors.Accent.error : SpacedriveColors.Text.secondary) + } + + // Show completion info or time info + if let completionInfo = job.completionInfo, !completionInfo.isEmpty, job.status == .running { + Text("• \(completionInfo)") + .font(.system(size: 11)) + .foregroundColor(SpacedriveColors.Text.secondary) + .opacity(0.7) + } else if let duration = duration { + Text("• \(duration)") + .font(.system(size: 11)) + .foregroundColor(SpacedriveColors.Text.secondary) + .opacity(0.7) + } else if job.status == .running { + Text("• \(timeAgo)") + .font(.system(size: 11)) + .foregroundColor(SpacedriveColors.Text.secondary) + .opacity(0.7) + } + + // Show issues indicator if present + if job.hasIssues, let issuesInfo = job.issuesInfo { + Text("⚠️") + .font(.system(size: 10)) + .foregroundColor(SpacedriveColors.Accent.warning) + .help(issuesInfo) + } + } + } + + // Row 2: Subtext + Text(subtextForJob) + .font(.system(size: 10)) + .foregroundColor(SpacedriveColors.Text.secondary) + .opacity(0.7) + .padding(.bottom, 2) + .lineLimit(1) + .truncationMode(.tail) + + // Row 3: Progress bar + HStack { + if job.status == .running || job.status == .paused { + ProgressView(value: job.progress, total: 1.0) + .progressViewStyle(LinearProgressViewStyle(tint: statusColor)) + .frame(height: 4) + } else if job.status == .completed { + // Completed indicator line + ProgressView(value: job.progress, total: 1.0) + .progressViewStyle(LinearProgressViewStyle(tint: SpacedriveColors.Accent.success.opacity(0.6))) + .frame(height: 4) + } else { + // Empty space for other statuses to maintain consistent height + Rectangle() + .fill(Color.clear) + .frame(height: 4) + } + + Spacer() + } + + // Error message (if present) + if let errorMessage = job.errorMessage, !errorMessage.isEmpty { + Text(errorMessage) + .font(.system(size: 11)) + .foregroundColor(SpacedriveColors.Accent.error) + .lineLimit(1) + .truncationMode(.tail) + } + } + .frame(height: 52) // Reduced total height with better spacing + } + } + .onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { _ in + updateWindowActiveState() + } + .onReceive(NotificationCenter.default.publisher(for: NSWindow.didResignKeyNotification)) { _ in + updateWindowActiveState() + } + .onAppear { + updateWindowActiveState() + } + } + + private func updateWindowActiveState() { + isWindowActive = window?.isKeyWindow ?? false + } + + private var statusColor: Color { + switch job.status { + case .running: + return SpacedriveColors.Accent.info + case .completed: + return SpacedriveColors.Accent.success + case .failed: + return SpacedriveColors.Accent.error + case .paused: + return SpacedriveColors.Accent.warning + case .queued: + return SpacedriveColors.Text.tertiary + case .cancelled: + return SpacedriveColors.Accent.error + } + } +} + +/// Small action button for pause/resume job functionality +struct JobActionButton: View { + let job: JobInfo + @EnvironmentObject var appState: SharedAppState + @State private var isHovered = false + + var body: some View { + Button(action: { + performAction() + }) { + Image(systemName: iconName) + .font(.system(size: 10, weight: .medium)) + .foregroundColor(iconColor) + .frame(width: 16, height: 16) + .background( + Circle() + .fill(backgroundColor) + .frame(width: 16, height: 16) + ) + } + .buttonStyle(PlainButtonStyle()) + .help(helpText) + .onHover { hovering in + isHovered = hovering + } + } + + private var iconName: String { + switch job.status { + case .running: + return "pause.fill" + case .paused: + return "play.fill" + default: + return "pause.fill" + } + } + + private var iconColor: Color { + if isHovered { + return SpacedriveColors.Text.primary + } else { + return SpacedriveColors.Text.secondary + } + } + + private var backgroundColor: Color { + if isHovered { + return SpacedriveColors.Interactive.hover + } else { + return Color.clear + } + } + + private var helpText: String { + switch job.status { + case .running: + return "Pause job" + case .paused: + return "Resume job" + default: + return "Job action" + } + } + + private func performAction() { + switch job.status { + case .running: + appState.dispatch(.pauseJob(job.id)) + case .paused: + appState.dispatch(.resumeJob(job.id)) + default: + break + } + } +} + +#Preview { + VStack(spacing: 12) { + JobRowView(job: JobInfo( + id: "12345678-1234-1234-1234-123456789012", + name: "indexer", + status: .running, + progress: 0.65, + startedAt: Date().addingTimeInterval(-300), + completedAt: nil, + errorMessage: nil, + actionType: "locations.add", + actionContext: ActionContextInfo( + actionType: "locations.add", + initiatedAt: "2025-01-24T20:24:00.820790Z", + initiatedBy: "user", + actionInput: JsonValue.objectValue([ + "path": JsonValue.stringValue("/Users/jamespine/Downloads"), + "name": JsonValue.stringValue("Downloads"), + ]), + context: JsonValue.objectValue([ + "operation": JsonValue.stringValue("add_location"), + "trigger": JsonValue.stringValue("user_action"), + ]) + ) + )) + + JobRowView(job: JobInfo( + id: "87654321-4321-4321-4321-210987654321", + name: "indexer", + status: .completed, + progress: 1.0, + startedAt: Date().addingTimeInterval(-600), + completedAt: Date().addingTimeInterval(-60), + errorMessage: nil, + actionType: "files.copy", + actionContext: ActionContextInfo( + actionType: "files.copy", + initiatedAt: "2025-01-24T19:30:00.820790Z", + initiatedBy: "user", + actionInput: JsonValue.objectValue([ + "source": JsonValue.stringValue("/Users/jamespine/Documents"), + "destination": JsonValue.stringValue("/Users/jamespine/Backup"), + ]), + context: JsonValue.objectValue([ + "operation": JsonValue.stringValue("copy_files"), + "trigger": JsonValue.stringValue("user_action"), + ]) + ) + )) + + JobRowView(job: JobInfo( + id: "11111111-2222-3333-4444-555555555555", + name: "thumbnail_generator", + status: .failed, + progress: 0.3, + startedAt: Date().addingTimeInterval(-120), + completedAt: Date().addingTimeInterval(-30), + errorMessage: "Failed to process image: unsupported format", + actionType: "media.thumbnail", + actionContext: ActionContextInfo( + actionType: "media.thumbnail", + initiatedAt: "2025-01-24T20:00:00.820790Z", + initiatedBy: "system", + actionInput: JsonValue.objectValue([ + "file_count": JsonValue.stringValue("25"), + "file_types": JsonValue.arrayValue([JsonValue.stringValue("jpg"), JsonValue.stringValue("png")]), + ]), + context: JsonValue.objectValue([ + "operation": JsonValue.stringValue("generate_thumbnails"), + "trigger": JsonValue.stringValue("system"), + ]) + ) + )) + } + .padding() + .background(Color.black.opacity(0.1)) +} diff --git a/apps/macos2/Spacedrive/Spacedrive/Windows/Settings/Components/SettingsNavigationView.swift b/apps/macos2/Spacedrive/Spacedrive/Windows/Settings/Components/SettingsNavigationView.swift new file mode 100644 index 000000000..6d9a80025 --- /dev/null +++ b/apps/macos2/Spacedrive/Spacedrive/Windows/Settings/Components/SettingsNavigationView.swift @@ -0,0 +1,193 @@ +import SwiftUI + +/// Settings Navigation Sidebar - Left navigation for settings sections +struct SettingsNavigationView: View { + @Binding var selectedSection: SettingsSection + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header + VStack(alignment: .leading, spacing: 6) { + Text("Settings") + .h3(.semibold) + .padding(.horizontal, 20) + .padding(.top, 20) + + Text("Configure Spacedrive") + .bodySmall(color: SpacedriveColors.Text.secondary) + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + + Rectangle() + .fill(SpacedriveColors.Border.secondary) + .frame(height: 1) + + // Navigation List + ScrollView { + LazyVStack(spacing: 2) { + ForEach(SettingsSection.allCases) { section in + SettingsNavigationItem( + section: section, + isSelected: selectedSection == section + ) { + selectedSection = section + } + } + } + .padding(.vertical, 12) + } + + Spacer() + + // Footer + Rectangle() + .fill(SpacedriveColors.Border.secondary) + .frame(height: 1) + + VStack(alignment: .leading, spacing: 4) { + HStack { + Circle() + .fill(connectionStatusColor) + .frame(width: 8, height: 8) + + Text("Daemon: \(connectionStatusText)") + .caption(color: SpacedriveColors.Text.secondary) + + Spacer() + } + .padding(.horizontal, 20) + .padding(.top, 16) + .padding(.bottom, 20) + } + } + .background(SpacedriveColors.Background.secondary) + } + + @EnvironmentObject var appState: SharedAppState + + private var connectionStatusColor: Color { + switch appState.connectionStatus { + case .connected: + return SpacedriveColors.Accent.success + case .connecting: + return SpacedriveColors.Accent.warning + case .disconnected, .error: + return SpacedriveColors.Accent.error + } + } + + private var connectionStatusText: String { + switch appState.connectionStatus { + case .connected: + return "Connected" + case .connecting: + return "Connecting..." + case .disconnected: + return "Disconnected" + case .error: + return "Error" + } + } +} + +/// Individual navigation item in the settings sidebar +struct SettingsNavigationItem: View { + let section: SettingsSection + let isSelected: Bool + let action: () -> Void + + @State private var isHovered = false + + var body: some View { + Button(action: action) { + HStack(spacing: 12) { + // Icon with consistent sizing + Image(systemName: section.icon) + .font(.system(size: 16, weight: .medium)) + .frame(width: 20, height: 20) + .foregroundColor(iconColor) + + // Title text + Text(section.rawValue) + .label(.medium, color: textColor) + .frame(maxWidth: .infinity, alignment: .leading) + + // Selection indicator + if isSelected { + Circle() + .fill(SpacedriveColors.Accent.primary) + .frame(width: 6, height: 6) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(backgroundColor) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(borderColor, lineWidth: borderWidth) + ) + ) + .scaleEffect(isHovered ? 1.02 : 1.0) + .animation(.easeInOut(duration: 0.2), value: isHovered) + .animation(.easeInOut(duration: 0.2), value: isSelected) + } + .buttonStyle(PlainButtonStyle()) + .padding(.horizontal, 12) + .onHover { hovering in + isHovered = hovering + } + } + + private var backgroundColor: Color { + if isSelected { + return SpacedriveColors.Accent.primary.opacity(0.12) + } else if isHovered { + return SpacedriveColors.Interactive.hover.opacity(0.8) + } else { + return Color.clear + } + } + + private var borderColor: Color { + if isSelected { + return SpacedriveColors.Accent.primary.opacity(0.3) + } else if isHovered { + return SpacedriveColors.Border.primary.opacity(0.5) + } else { + return Color.clear + } + } + + private var borderWidth: CGFloat { + if isSelected || isHovered { + return 1.0 + } else { + return 0.0 + } + } + + private var textColor: Color { + if isSelected { + return SpacedriveColors.Accent.primary + } else { + return SpacedriveColors.Text.primary + } + } + + private var iconColor: Color { + if isSelected { + return SpacedriveColors.Accent.primary + } else { + return SpacedriveColors.Text.secondary + } + } +} + +#Preview { + SettingsNavigationView(selectedSection: .constant(.general)) + .environmentObject(SharedAppState.shared) + .frame(width: 250, height: 500) +} diff --git a/apps/macos2/Spacedrive/Spacedrive/Windows/Settings/Views/SettingsContentView.swift b/apps/macos2/Spacedrive/Spacedrive/Windows/Settings/Views/SettingsContentView.swift new file mode 100644 index 000000000..c0e84619b --- /dev/null +++ b/apps/macos2/Spacedrive/Spacedrive/Windows/Settings/Views/SettingsContentView.swift @@ -0,0 +1,306 @@ +import SwiftUI + +/// Settings Content Area - Main content that changes based on selected section +struct SettingsContentView: View { + let selectedSection: SettingsSection + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + // Section Header + VStack(alignment: .leading, spacing: 6) { + HStack { + Image(systemName: selectedSection.icon) + .foregroundColor(SpacedriveColors.Accent.primary) + .font(.system(size: 22, weight: .medium)) + + VStack(alignment: .leading, spacing: 2) { + Text(selectedSection.rawValue) + .h2(.semibold) + + Text(selectedSection.description) + .bodySmall(color: SpacedriveColors.Text.secondary) + } + + Spacer() + } + .padding(.horizontal, 28) + .padding(.top, 28) + .padding(.bottom, 20) + } + + Rectangle() + .fill(SpacedriveColors.Border.secondary) + .frame(height: 1) + .padding(.horizontal, 28) + + // Content based on selected section + contentForSection(selectedSection) + .padding(28) + } + } + .background(SpacedriveColors.Background.primary) + } + + @ViewBuilder + private func contentForSection(_ section: SettingsSection) -> some View { + switch section { + case .general: + GeneralSettingsView() + case .daemon: + DaemonSettingsView() + case .appearance: + AppearanceSettingsView() + case .privacy: + PrivacySettingsView() + case .advanced: + AdvancedSettingsView() + case .about: + AboutSettingsView() + } + } +} + +// MARK: - Individual Settings Views + +struct GeneralSettingsView: View { + @EnvironmentObject var appState: SharedAppState + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + // Services status section + SettingsGroup("Services") { + ConnectivityCard() + .environmentObject(appState) + } + + // Auto-launch section + SettingsGroup("Startup") { + SettingsToggle( + "Launch at login", + description: "Automatically start Spacedrive when you log in", + isOn: .constant(false) + ) + + SettingsToggle( + "Auto-connect to daemon", + description: "Automatically connect to the daemon on startup", + isOn: .constant(appState.userPreferences.autoConnectToDaemon) + ) + } + + // Notifications section + SettingsGroup("Notifications") { + SettingsToggle( + "Show job notifications", + description: "Display notifications when jobs complete", + isOn: .constant(appState.userPreferences.showJobNotifications) + ) + } + } + } +} + +struct DaemonSettingsView: View { + @EnvironmentObject var appState: SharedAppState + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + SettingsGroup("Connection") { + HStack { + VStack(alignment: .leading) { + Text("Status: \(appState.connectionStatus.displayName)") + .body(.medium) + Text("Manage daemon connection and settings") + .bodySmall(color: SpacedriveColors.Text.secondary) + } + + Spacer() + + SDButton("Connect", style: .primary, size: .medium) { + appState.dispatch(.connectToDaemon) + } + + SDButton("Disconnect", style: .secondary, size: .medium) { + appState.dispatch(.disconnectFromDaemon) + } + } + + SettingsToggle( + "Auto-reconnect", + description: "Automatically reconnect if connection is lost", + isOn: .constant(true) + ) + } + } + } +} + +struct AppearanceSettingsView: View { + @EnvironmentObject var appState: SharedAppState + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + SettingsGroup("Theme") { + HStack { + Text("Theme:") + .body(.medium) + + Spacer() + + Picker("Theme", selection: .constant(SpacedriveTheme.dark)) { + Text("Dark").tag(SpacedriveTheme.dark) + Text("Light").tag(SpacedriveTheme.light) + } + .pickerStyle(SegmentedPickerStyle()) + .frame(width: 200) + } + + SettingsToggle( + "Compact mode", + description: "Use smaller interface elements", + isOn: .constant(appState.userPreferences.compactMode) + ) + } + } + } +} + +struct PrivacySettingsView: View { + var body: some View { + VStack(alignment: .leading, spacing: 20) { + SettingsGroup("Data Collection") { + SettingsToggle( + "Analytics", + description: "Help improve Spacedrive by sharing anonymous usage data", + isOn: .constant(false) + ) + + SettingsToggle( + "Crash reports", + description: "Automatically send crash reports to help fix bugs", + isOn: .constant(true) + ) + } + } + } +} + +struct AdvancedSettingsView: View { + var body: some View { + VStack(alignment: .leading, spacing: 20) { + SettingsGroup("Debug") { + HStack { + VStack(alignment: .leading) { + Text("Developer mode") + .body(.medium) + Text("Enable advanced debugging features") + .bodySmall(color: SpacedriveColors.Text.secondary) + } + + Spacer() + + SDButton("Enable", style: .secondary, size: .medium) { + // Enable developer mode + } + } + + SDButton("Reset all settings", style: .destructive, size: .medium) { + // Reset settings + } + } + } + } +} + +struct AboutSettingsView: View { + var body: some View { + VStack(alignment: .leading, spacing: 20) { + SettingsGroup("Version") { + VStack(alignment: .leading, spacing: 8) { + Text("Spacedrive") + .h4(.semibold) + Text("Version 0.1.0") + .body() + Text("Build 1") + .bodySmall(color: SpacedriveColors.Text.secondary) + } + } + + SettingsGroup("Links") { + VStack(spacing: 12) { + SDButton("Visit Website", style: .secondary, size: .medium, icon: "safari") { + // Open website + } + + SDButton("View on GitHub", style: .secondary, size: .medium, icon: "chevron.left.forwardslash.chevron.right") { + // Open GitHub + } + + SDButton("Report Issue", style: .secondary, size: .medium, icon: "exclamationmark.bubble") { + // Report issue + } + } + } + } + } +} + +// MARK: - Settings Components + +struct SettingsGroup: View { + let title: String + let content: Content + + init(_ title: String, @ViewBuilder content: () -> Content) { + self.title = title + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(title) + .h5(.semibold) + .foregroundColor(SpacedriveColors.Text.primary) + + SDCard(style: .bordered, padding: EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16)) { + content + } + } + } +} + +struct SettingsToggle: View { + let title: String + let description: String + @Binding var isOn: Bool + + init(_ title: String, description: String, isOn: Binding) { + self.title = title + self.description = description + _isOn = isOn + } + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .body(.medium) + + Text(description) + .bodySmall(color: SpacedriveColors.Text.secondary) + } + + Spacer() + + Toggle("", isOn: $isOn) + .toggleStyle(SwitchToggleStyle()) + } + } +} + +#Preview { + SettingsContentView(selectedSection: .general) + .environmentObject(SharedAppState.shared) + .frame(width: 500, height: 600) +} diff --git a/apps/macos2/Spacedrive/Spacedrive/Windows/Settings/Views/SettingsView.swift b/apps/macos2/Spacedrive/Spacedrive/Windows/Settings/Views/SettingsView.swift new file mode 100644 index 000000000..47be154cc --- /dev/null +++ b/apps/macos2/Spacedrive/Spacedrive/Windows/Settings/Views/SettingsView.swift @@ -0,0 +1,73 @@ +import SwiftUI + +/// Settings Window - Main settings interface with left navigation +struct SettingsView: View { + @EnvironmentObject var appState: SharedAppState + @State private var selectedSection: SettingsSection = .general + + var body: some View { + WindowContainer { + HSplitView { + // Left Navigation Sidebar + SettingsNavigationView(selectedSection: $selectedSection) + .frame(minWidth: 220, maxWidth: 280) + + // Main Content Area + SettingsContentView(selectedSection: selectedSection) + .frame(minWidth: 450, maxWidth: .infinity) + } + } + } +} + +/// Settings sections for navigation +enum SettingsSection: String, CaseIterable, Identifiable { + case general = "General" + case daemon = "Daemon" + case appearance = "Appearance" + case privacy = "Privacy" + case advanced = "Advanced" + case about = "About" + + var id: String { rawValue } + + var icon: String { + switch self { + case .general: + return "gear" + case .daemon: + return "server.rack" + case .appearance: + return "paintbrush" + case .privacy: + return "hand.raised" + case .advanced: + return "wrench.and.screwdriver" + case .about: + return "info.circle" + } + } + + var description: String { + switch self { + case .general: + return "General application settings" + case .daemon: + return "Daemon connection and configuration" + case .appearance: + return "Theme and visual preferences" + case .privacy: + return "Privacy and security settings" + case .advanced: + return "Advanced configuration options" + case .about: + return "About Spacedrive" + } + } +} + +#Preview { + SettingsView() + .environmentObject(SharedAppState.shared) + .frame(width: 800, height: 600) +}