mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-04-30 11:23:07 -04:00
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:
92
.gitignore
vendored
92
.gitignore
vendored
@@ -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
|
||||
|
||||
10
apps/macos2/Spacedrive/Spacedrive.entitlements
Normal file
10
apps/macos2/Spacedrive/Spacedrive.entitlements
Normal 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>
|
||||
@@ -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 */;
|
||||
}
|
||||
|
||||
7
apps/macos2/Spacedrive/Spacedrive.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
apps/macos2/Spacedrive/Spacedrive.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
236
apps/macos2/Spacedrive/Spacedrive/Components/SDButton.swift
Normal file
236
apps/macos2/Spacedrive/Spacedrive/Components/SDButton.swift
Normal 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)
|
||||
}
|
||||
182
apps/macos2/Spacedrive/Spacedrive/Components/SDCard.swift
Normal file
182
apps/macos2/Spacedrive/Spacedrive/Components/SDCard.swift
Normal 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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
508
apps/macos2/Spacedrive/Spacedrive/MenuBar/MenuBarManager.swift
Normal file
508
apps/macos2/Spacedrive/Spacedrive/MenuBar/MenuBarManager.swift
Normal 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
|
||||
567
apps/macos2/Spacedrive/Spacedrive/Services/DaemonConnector.swift
Normal file
567
apps/macos2/Spacedrive/Spacedrive/Services/DaemonConnector.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
426
apps/macos2/Spacedrive/Spacedrive/State/SharedAppState.swift
Normal file
426
apps/macos2/Spacedrive/Spacedrive/State/SharedAppState.swift
Normal 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 }
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user