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.
This commit is contained in:
Jamie Pine
2025-09-26 14:56:20 -07:00
parent e85e796c4f
commit db73c7ac55
42 changed files with 7358 additions and 38 deletions

92
.gitignore vendored
View File

@@ -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

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.get-task-allow</key>
<true/>
</dict>
</plist>

View File

@@ -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 = "<group>";
@@ -85,19 +91,26 @@
6A0F02E52E86800200B2DEBA /* Products */ = {
isa = PBXGroup;
children = (
6A0F02E42E86800200B2DEBA /* Spacedrive.app */,
6A0F02E42E86800200B2DEBA /* AppIcon.app */,
6A0F02F12E86800300B2DEBA /* SpacedriveTests.xctest */,
6A0F02FB2E86800300B2DEBA /* SpacedriveUITests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
6ADA37DB2E8684310017BA3A /* Frameworks */ = {
isa = PBXGroup;
children = (
);
name = Frameworks;
sourceTree = "<group>";
};
/* 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 */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -0,0 +1,182 @@
import SwiftUI
/// Spacedrive Card Component - Reusable container with consistent styling
struct SDCard<Content: View>: 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<Content: View>: 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<Content: View>: 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)
}

View File

@@ -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()
}
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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<CenterContent: View, RightContent: View>(
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<CenterContent: View, RightContent: View>(
center: CenterContent? = nil,
right: RightContent? = nil
) -> some View {
self.modifier(CustomTitleBarModifier(center: center, right: right))
}
}

View File

@@ -0,0 +1,47 @@
import AppKit
import SwiftUI
/// Shared Window Container - Provides consistent window chrome with native traffic lights for all windows
struct WindowContainer<Content: View>: 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)
}

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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<AnyCancellable>()
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

View File

@@ -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<Void, Never>?
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<T: Sendable>(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<T: Codable>(_ 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<T: Codable>(_ 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
}
}
}

View File

@@ -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)")
}
}

View File

@@ -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<AnyCancellable>()
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<T>(_ keyPath: WritableKeyPath<UserPreferences, T>, 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<Content: View>: 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 }
}
}

View File

@@ -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)")
}
}
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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
}
}
}

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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
)
}
}

View File

@@ -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"
}
}
}

View File

@@ -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<AnyCancellable>()
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()
}
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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))
}

View File

@@ -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)
}

View File

@@ -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<Content: View>: 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<Bool>) {
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)
}

View File

@@ -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)
}