diff --git a/Maccy.xcodeproj/project.pbxproj b/Maccy.xcodeproj/project.pbxproj index ce97806e4..99a124eb0 100644 --- a/Maccy.xcodeproj/project.pbxproj +++ b/Maccy.xcodeproj/project.pbxproj @@ -145,6 +145,11 @@ DAFE2DDA268A521B00990986 /* String+Shortened.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFE2DD9268A521A00990986 /* String+Shortened.swift */; }; DAFE2DE9268A9B1B00990986 /* HistoryItemTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFE2DE8268A9B1B00990986 /* HistoryItemTests.swift */; }; DAFEF0B8249D7DEE006029E8 /* KeyboardShortcuts.Name+Shortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFEF0B7249D7DEE006029E8 /* KeyboardShortcuts.Name+Shortcuts.swift */; }; + AA00000100000001AAAAAAAA /* Workspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000200000001AAAAAAAA /* Workspace.swift */; }; + AA00000100000002AAAAAAAA /* WorkspaceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000200000002AAAAAAAA /* WorkspaceManager.swift */; }; + AA00000100000003AAAAAAAA /* WorkspaceTabsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000200000003AAAAAAAA /* WorkspaceTabsView.swift */; }; + AA00000100000004AAAAAAAA /* WorkspaceSettingsPane.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000200000004AAAAAAAA /* WorkspaceSettingsPane.swift */; }; + AA00000100000005AAAAAAAA /* WorkspaceSettings.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA00000300000005AAAAAAAA /* WorkspaceSettings.strings */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -555,6 +560,11 @@ DAFE2DD9268A521A00990986 /* String+Shortened.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Shortened.swift"; sourceTree = ""; }; DAFE2DE8268A9B1B00990986 /* HistoryItemTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryItemTests.swift; sourceTree = ""; }; DAFEF0B7249D7DEE006029E8 /* KeyboardShortcuts.Name+Shortcuts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeyboardShortcuts.Name+Shortcuts.swift"; sourceTree = ""; }; + AA00000200000001AAAAAAAA /* Workspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Workspace.swift; sourceTree = ""; }; + AA00000200000002AAAAAAAA /* WorkspaceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceManager.swift; sourceTree = ""; }; + AA00000200000003AAAAAAAA /* WorkspaceTabsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTabsView.swift; sourceTree = ""; }; + AA00000200000004AAAAAAAA /* WorkspaceSettingsPane.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSettingsPane.swift; sourceTree = ""; }; + AA00000200000005AAAAAAAA /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/WorkspaceSettings.strings; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -634,6 +644,7 @@ children = ( DA05B5132C234DCF006980FE /* HistoryItem.swift */, DA05B5122C234DCF006980FE /* HistoryItemContent.swift */, + AA00000200000001AAAAAAAA /* Workspace.swift */, ); path = Models; sourceTree = ""; @@ -662,6 +673,8 @@ DA689FAE2C1CD3B00009B887 /* StorageSettings.strings */, DAA365CD2C4BF9DD00A394F8 /* PinsSettingsPane.swift */, DAA365CF2C4C147400A394F8 /* PinsSettings.strings */, + AA00000200000004AAAAAAAA /* WorkspaceSettingsPane.swift */, + AA00000300000005AAAAAAAA /* WorkspaceSettings.strings */, ); path = Settings; sourceTree = ""; @@ -699,6 +712,7 @@ 2F9B42F02F1A4999006548E5 /* ToolbarView.swift */, DAA072D02C4089D3006DDFD2 /* VisualEffectView.swift */, DA555F112CF155A2009608BD /* WrappingTextView.swift */, + AA00000200000003AAAAAAAA /* WorkspaceTabsView.swift */, ); path = Views; sourceTree = ""; @@ -740,6 +754,7 @@ DA3BCB8D2C3E015000B01BC1 /* ModifierFlags.swift */, DAE8F5D32C43262B00851CA9 /* Popup.swift */, 2F578C3E2EFFE8720088B759 /* SlideoutController.swift */, + AA00000200000002AAAAAAAA /* WorkspaceManager.swift */, ); path = Observables; sourceTree = ""; @@ -1039,6 +1054,7 @@ DA5E62802C39E53F00F4C710 /* PreviewItemView.strings in Resources */, DA009932256411F90030E697 /* README.md in Resources */, DA9C3C4A2C20D4B40056795D /* IgnoreSettings.strings in Resources */, + AA00000100000005AAAAAAAA /* WorkspaceSettings.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1176,6 +1192,10 @@ DA7A753E26A52F0F00DC16EF /* NSImage+Names.swift in Sources */, 2F0EC7852E774083003E2EA9 /* PasteStackView.swift in Sources */, DA20FA782B082E1A00056DD5 /* NSSound+Named.swift in Sources */, + AA00000100000001AAAAAAAA /* Workspace.swift in Sources */, + AA00000100000002AAAAAAAA /* WorkspaceManager.swift in Sources */, + AA00000100000003AAAAAAAA /* WorkspaceTabsView.swift in Sources */, + AA00000100000004AAAAAAAA /* WorkspaceSettingsPane.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1499,6 +1519,14 @@ name = GeneralSettings.strings; sourceTree = ""; }; + AA00000300000005AAAAAAAA /* WorkspaceSettings.strings */ = { + isa = PBXVariantGroup; + children = ( + AA00000200000005AAAAAAAA /* en */, + ); + name = WorkspaceSettings.strings; + sourceTree = ""; + }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ @@ -1957,4 +1985,4 @@ /* End XCSwiftPackageProductDependency section */ }; rootObject = DAEE383B1E3DBEB100DD2966 /* Project object */; -} +} \ No newline at end of file diff --git a/Maccy/AppDelegate.swift b/Maccy/AppDelegate.swift index 44f0b2895..79114f122 100644 --- a/Maccy/AppDelegate.swift +++ b/Maccy/AppDelegate.swift @@ -37,6 +37,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Bridge FloatingPanel via AppDelegate. AppState.shared.appDelegate = self + // Ensure default workspace exists and migrate orphaned items before clipboard starts. + Storage.shared.ensureDefaultWorkspace() + WorkspaceManager.shared.load() + Clipboard.shared.onNewCopy { History.shared.add($0) } Clipboard.shared.start() @@ -109,7 +113,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { func applicationWillTerminate(_ notification: Notification) { if Defaults[.clearOnQuit] { - AppState.shared.history.clear() + AppState.shared.history.clearEverything() } } diff --git a/Maccy/Extensions/Defaults.Keys+Names.swift b/Maccy/Extensions/Defaults.Keys+Names.swift index 4a08a4bc2..89c05ac87 100644 --- a/Maccy/Extensions/Defaults.Keys+Names.swift +++ b/Maccy/Extensions/Defaults.Keys+Names.swift @@ -11,6 +11,7 @@ struct StorageType { } extension Defaults.Keys { + static let activeWorkspaceId = Key("activeWorkspaceId") static let clearOnQuit = Key("clearOnQuit", default: false) static let clearSystemClipboard = Key("clearSystemClipboard", default: false) static let clipboardCheckInterval = Key("clipboardCheckInterval", default: 0.5) diff --git a/Maccy/Extensions/NSImage+Names.swift b/Maccy/Extensions/NSImage+Names.swift index 1adfa55ed..c5e8dd2ea 100644 --- a/Maccy/Extensions/NSImage+Names.swift +++ b/Maccy/Extensions/NSImage+Names.swift @@ -7,6 +7,7 @@ extension NSImage { static let pincircle = NSImage(systemSymbolName: "pin.circle", accessibilityDescription: "pin.cirlce") static let nosign = NSImage(systemSymbolName: "nosign", accessibilityDescription: "nosign") static let gearshape2 = NSImage(systemSymbolName: "gearshape.2", accessibilityDescription: "gearshape2") + static let squareStack = NSImage(systemSymbolName: "square.stack.3d.up", accessibilityDescription: "workspaces") } extension NSImage.Name { diff --git a/Maccy/Extensions/Settings.PaneIdentifier+Panes.swift b/Maccy/Extensions/Settings.PaneIdentifier+Panes.swift index dfa9c6731..c10e29372 100644 --- a/Maccy/Extensions/Settings.PaneIdentifier+Panes.swift +++ b/Maccy/Extensions/Settings.PaneIdentifier+Panes.swift @@ -7,4 +7,5 @@ extension Settings.PaneIdentifier { static let ignore = Self("ignore") static let pins = Self("pins") static let storage = Self("storage") + static let workspaces = Self("workspaces") } diff --git a/Maccy/Models/HistoryItem.swift b/Maccy/Models/HistoryItem.swift index 08e7b726a..485bd68de 100644 --- a/Maccy/Models/HistoryItem.swift +++ b/Maccy/Models/HistoryItem.swift @@ -69,6 +69,9 @@ class HistoryItem { @Relationship(deleteRule: .cascade, inverse: \HistoryItemContent.item) var contents: [HistoryItemContent] = [] + @Relationship(inverse: \Workspace.items) + var workspace: Workspace? + init(contents: [HistoryItemContent] = []) { self.firstCopiedAt = firstCopiedAt self.lastCopiedAt = lastCopiedAt diff --git a/Maccy/Models/Workspace.swift b/Maccy/Models/Workspace.swift new file mode 100644 index 000000000..8eaf26a4f --- /dev/null +++ b/Maccy/Models/Workspace.swift @@ -0,0 +1,22 @@ +import Foundation +import SwiftData + +@Model +class Workspace { + static let defaultName = "Default" + + var id: UUID + var name: String = Workspace.defaultName + var createdAt: Date + var sortOrder: Int = 0 + + @Relationship(deleteRule: .nullify) + var items: [HistoryItem] = [] + + init(name: String, sortOrder: Int = 0) { + self.id = UUID() + self.name = name + self.sortOrder = sortOrder + self.createdAt = Date.now + } +} diff --git a/Maccy/Observables/AppState.swift b/Maccy/Observables/AppState.swift index f290418df..15adb0210 100644 --- a/Maccy/Observables/AppState.swift +++ b/Maccy/Observables/AppState.swift @@ -9,6 +9,7 @@ class AppState: Sendable { static let shared = AppState(history: History.shared, footer: Footer()) let multiSelectionEnabled = false + var isPopoverEditing = false var appDelegate: AppDelegate? var popup: Popup @@ -16,6 +17,7 @@ class AppState: Sendable { var footer: Footer var navigator: NavigationManager var preview: SlideoutController + var workspaceManager: WorkspaceManager var searchVisible: Bool { if !Defaults[.showSearch] { return false } @@ -40,6 +42,7 @@ class AppState: Sendable { self.footer = footer popup = Popup() navigator = NavigationManager(history: history, footer: footer) + workspaceManager = WorkspaceManager.shared preview = SlideoutController( onContentResize: { contentWidth in Defaults[.windowSize].width = contentWidth @@ -124,6 +127,14 @@ class AppState: Sendable { ) { StorageSettingsPane() }, + Settings.Pane( + identifier: Settings.PaneIdentifier.workspaces, + title: NSLocalizedString("Title", tableName: "WorkspaceSettings", comment: ""), + toolbarIcon: NSImage.squareStack! + ) { + WorkspaceSettingsPane() + .environment(self) + }, Settings.Pane( identifier: Settings.PaneIdentifier.appearance, title: NSLocalizedString("Title", tableName: "AppearanceSettings", comment: ""), diff --git a/Maccy/Observables/History.swift b/Maccy/Observables/History.swift index e5804ad19..57bfacd0c 100644 --- a/Maccy/Observables/History.swift +++ b/Maccy/Observables/History.swift @@ -103,7 +103,12 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length @MainActor func load() async throws { - let descriptor = FetchDescriptor() + guard let workspaceId = WorkspaceManager.shared.activeWorkspace?.id else { return } + + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.workspace?.id == workspaceId } + ) + let results = try Storage.shared.context.fetch(descriptor) all = sorter.sort(results).map { HistoryItemDecorator($0) } items = all @@ -136,6 +141,11 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length @discardableResult @MainActor func add(_ item: HistoryItem) -> HistoryItemDecorator { + // Assign the item to the currently active workspace. + if item.workspace == nil { + item.workspace = WorkspaceManager.shared.activeWorkspace + } + if #available(macOS 15.0, *) { try? History.shared.insertIntoStorage(item) } else { @@ -217,20 +227,16 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length cleanup(item) } } + + let unpinnedToDelete = all.filter(\.isUnpinned) + for item in unpinnedToDelete { + Storage.shared.context.delete(item.item) + } + all.removeAll(where: \.isUnpinned) sessionLog.removeValues { $0.pin == nil } items = all - try? Storage.shared.context.transaction { - try? Storage.shared.context.delete( - model: HistoryItem.self, - where: #Predicate { $0.pin == nil } - ) - try? Storage.shared.context.delete( - model: HistoryItemContent.self, - where: #Predicate { $0.item?.pin == nil } - ) - } Storage.shared.context.processPendingChanges() try? Storage.shared.context.save() } @@ -248,11 +254,15 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length all.forEach { item in cleanup(item) } + + for item in all { + Storage.shared.context.delete(item.item) + } + all.removeAll() sessionLog.removeAll() items = all - try? Storage.shared.context.delete(model: HistoryItem.self) Storage.shared.context.processPendingChanges() try? Storage.shared.context.save() } @@ -264,6 +274,33 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length } } + /// Clears history items in the active workspace. Preserves other workspaces' data. + /// Fetches directly from storage since `all` may not be loaded at quit time. + @MainActor + func clearEverything() { + guard let workspaceId = WorkspaceManager.shared.activeWorkspace?.id else { return } + + withLogging("Clearing active workspace history on quit") { + all.forEach { cleanup($0) } + all.removeAll() + sessionLog.removeAll() + items = all + + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.workspace?.id == workspaceId } + ) + if let toDelete = try? Storage.shared.context.fetch(descriptor) { + for item in toDelete { + Storage.shared.context.delete(item) + } + } + Storage.shared.context.processPendingChanges() + try? Storage.shared.context.save() + } + + Clipboard.shared.clear() + } + @MainActor func delete(_ item: HistoryItemDecorator?) { guard let item else { return } @@ -445,7 +482,12 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length @MainActor private func findSimilarItem(_ item: HistoryItem) -> HistoryItem? { - let descriptor = FetchDescriptor() + guard let workspaceId = WorkspaceManager.shared.activeWorkspace?.id else { return nil } + + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.workspace?.id == workspaceId } + ) + if let all = try? Storage.shared.context.fetch(descriptor) { let duplicates = all.filter({ $0 == item || $0.supersedes(item) }) if duplicates.count > 1 { @@ -455,7 +497,7 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length } } - return item + return nil } private func isModified(_ item: HistoryItem) -> HistoryItem? { diff --git a/Maccy/Observables/Popup.swift b/Maccy/Observables/Popup.swift index 42ff3cf6f..ee3e7aa67 100644 --- a/Maccy/Observables/Popup.swift +++ b/Maccy/Observables/Popup.swift @@ -43,6 +43,7 @@ class Popup { var extraTopHeight: CGFloat = 0 var extraBottomHeight: CGFloat = 0 var footerHeight: CGFloat = 0 + var workspaceTabsHeight: CGFloat = 0 private var eventsMonitor: Any? @@ -97,7 +98,8 @@ class Popup { if AppState.shared.preview.state.isOpen && AppState.shared.navigator.leadSelection != nil { minimumHeight += Self.minimumPreviewHeight } - minimumHeight = max(headerHeight + Self.verticalPadding, minimumHeight) + let chromeHeight = headerHeight + workspaceTabsHeight + footerHeight + Self.verticalPadding + minimumHeight = max(chromeHeight, minimumHeight) height = max(height, minimumHeight) height = min(height, Defaults[.windowSize].height) @@ -105,7 +107,7 @@ class Popup { } func resize(height: CGFloat) { - self.height = height + headerHeight + extraTopHeight + extraBottomHeight + footerHeight + self.height = height + headerHeight + workspaceTabsHeight + extraTopHeight + extraBottomHeight + footerHeight AppState.shared.appDelegate?.panel.verticallyResize(to: preferredHeight(for: self.height)) needsResize = false } diff --git a/Maccy/Observables/WorkspaceManager.swift b/Maccy/Observables/WorkspaceManager.swift new file mode 100644 index 000000000..90d46419c --- /dev/null +++ b/Maccy/Observables/WorkspaceManager.swift @@ -0,0 +1,97 @@ +import Defaults +import Foundation +import Observation +import SwiftData + +@Observable +class WorkspaceManager { + static let maxWorkspaces = 10 + static let shared = WorkspaceManager() + + var workspaces: [Workspace] = [] + + var activeWorkspace: Workspace? { + didSet { + if let workspace = activeWorkspace { + Defaults[.activeWorkspaceId] = workspace.id.uuidString + } + } + } + + @MainActor + func load() { + let descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.sortOrder), SortDescriptor(\.createdAt)] + ) + workspaces = (try? Storage.shared.context.fetch(descriptor)) ?? [] + + // Restore active workspace from persisted UUID, or fall back to the first one. + if let savedId = Defaults[.activeWorkspaceId], + let uuid = UUID(uuidString: savedId) { + activeWorkspace = workspaces.first(where: { $0.id == uuid }) ?? workspaces.first + } else { + activeWorkspace = workspaces.first + } + } + + @discardableResult + @MainActor + func create(name: String) -> Workspace? { + // Prevent duplicate workspace names. + let trimmedName = name.trimmingCharacters(in: .whitespaces) + guard !trimmedName.isEmpty else { return nil } + guard workspaces.count < Self.maxWorkspaces else { return nil } + guard !workspaces.contains(where: { $0.name.lowercased() == trimmedName.lowercased() }) else { return nil } + + let maxOrder = workspaces.map(\.sortOrder).max() ?? -1 + let workspace = Workspace(name: trimmedName, sortOrder: maxOrder + 1) + Storage.shared.context.insert(workspace) + Storage.shared.context.processPendingChanges() + try? Storage.shared.context.save() + + workspaces.append(workspace) + return workspace + } + + @MainActor + func rename(_ workspace: Workspace, to newName: String) { + let trimmedName = newName.trimmingCharacters(in: .whitespaces) + guard !trimmedName.isEmpty else { return } + // Prevent renaming to an existing name. + guard !workspaces.contains(where: { $0 != workspace && $0.name.lowercased() == trimmedName.lowercased() }) else { + return + } + + workspace.name = trimmedName + Storage.shared.context.processPendingChanges() + try? Storage.shared.context.save() + } + + @MainActor + func delete(_ workspace: Workspace) { + // Prevent deleting the last workspace. + guard workspaces.count > 1 else { return } + + let wasActive = (workspace == activeWorkspace) + + // Move items from the deleted workspace to the first remaining workspace. + let fallback = workspaces.first(where: { $0 != workspace }) + for item in workspace.items { + item.workspace = fallback + } + + Storage.shared.context.delete(workspace) + Storage.shared.context.processPendingChanges() + try? Storage.shared.context.save() + + workspaces.removeAll(where: { $0 == workspace }) + + if wasActive { + activeWorkspace = workspaces.first + } + } + + func switchTo(_ workspace: Workspace) { + activeWorkspace = workspace + } +} diff --git a/Maccy/Settings/WorkspaceSettingsPane.swift b/Maccy/Settings/WorkspaceSettingsPane.swift new file mode 100644 index 000000000..8c3300f1e --- /dev/null +++ b/Maccy/Settings/WorkspaceSettingsPane.swift @@ -0,0 +1,116 @@ +import SwiftUI +import Settings + +struct WorkspaceSettingsPane: View { + @Environment(AppState.self) private var appState + + @State private var newWorkspaceName = "" + @State private var selectedWorkspace: Workspace? + @State private var editingName = "" + @State private var isEditing = false + + private var workspaceManager: WorkspaceManager { + appState.workspaceManager + } + + var body: some View { + Settings.Container(contentWidth: 450) { + Settings.Section( + label: { Text("Workspaces", tableName: "WorkspaceSettings") } + ) { + VStack(alignment: .leading, spacing: 8) { + List(workspaceManager.workspaces, id: \.id, selection: $selectedWorkspace) { workspace in + HStack { + if isEditing && selectedWorkspace == workspace { + TextField("Name", text: $editingName) + .onSubmit { + let name = editingName.trimmingCharacters(in: .whitespaces) + if !name.isEmpty { + workspaceManager.rename(workspace, to: name) + } + isEditing = false + } + .textFieldStyle(.roundedBorder) + } else { + Text(workspace.name) + .frame(maxWidth: .infinity, alignment: .leading) + + if workspace == workspaceManager.activeWorkspace { + Text("Active") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(Color.accentColor.opacity(0.15)) + ) + } + } + } + .tag(workspace) + .contentShape(Rectangle()) + } + .listStyle(.bordered) + .frame(height: 200) + + HStack(spacing: 4) { + TextField("New workspace name", text: $newWorkspaceName) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 200) + .onSubmit { + addWorkspace() + } + + Button(action: addWorkspace) { + Image(systemName: "plus") + } + .disabled( + newWorkspaceName.trimmingCharacters(in: .whitespaces).isEmpty + || workspaceManager.workspaces.count >= WorkspaceManager.maxWorkspaces + ) + + Button(action: { + if let workspace = selectedWorkspace { + editingName = workspace.name + isEditing = true + } + }) { + Image(systemName: "pencil") + } + .disabled(selectedWorkspace == nil) + + Button(action: { + if let workspace = selectedWorkspace { + workspaceManager.delete(workspace) + selectedWorkspace = nil + } + }) { + Image(systemName: "minus") + } + .disabled(selectedWorkspace == nil || workspaceManager.workspaces.count <= 1) + } + + Text("WorkspaceDescription", tableName: "WorkspaceSettings") + .controlSize(.small) + .foregroundStyle(.gray) + } + } + } + } + + private func addWorkspace() { + let name = newWorkspaceName.trimmingCharacters(in: .whitespaces) + guard !name.isEmpty else { return } + if let workspace = workspaceManager.create(name: name) { + selectedWorkspace = workspace + } + newWorkspaceName = "" + } +} + +#Preview { + WorkspaceSettingsPane() + .environment(AppState.shared) + .environment(\.locale, .init(identifier: "en")) +} diff --git a/Maccy/Settings/en.lproj/WorkspaceSettings.strings b/Maccy/Settings/en.lproj/WorkspaceSettings.strings new file mode 100644 index 000000000..be09e8181 --- /dev/null +++ b/Maccy/Settings/en.lproj/WorkspaceSettings.strings @@ -0,0 +1,3 @@ +"Title" = "Workspaces"; +"Workspaces" = "Workspaces:"; +"WorkspaceDescription" = "Create separate workspaces to organize your clipboard history. Items copied while a workspace is active will be stored in that workspace."; diff --git a/Maccy/Storage.swift b/Maccy/Storage.swift index 775cf45e0..44c4963bd 100644 --- a/Maccy/Storage.swift +++ b/Maccy/Storage.swift @@ -1,3 +1,4 @@ +import Defaults import Foundation import SwiftData @@ -27,9 +28,46 @@ class Storage { #endif do { - container = try ModelContainer(for: HistoryItem.self, configurations: config) + container = try ModelContainer( + for: HistoryItem.self, Workspace.self, + configurations: config + ) } catch let error { fatalError("Cannot load database: \(error.localizedDescription).") } } + + /// Ensures a "Default" workspace exists and migrates any orphaned history items into it. + func ensureDefaultWorkspace() { + let defaultName = Workspace.defaultName + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.name == defaultName } + ) + + let defaultWorkspace: Workspace + if let existing = try? context.fetch(descriptor).first { + defaultWorkspace = existing + } else { + defaultWorkspace = Workspace(name: Workspace.defaultName) + context.insert(defaultWorkspace) + } + + // Migrate orphaned items (workspace == nil) to the default workspace. + let orphanDescriptor = FetchDescriptor( + predicate: #Predicate { $0.workspace == nil } + ) + if let orphans = try? context.fetch(orphanDescriptor) { + for item in orphans { + item.workspace = defaultWorkspace + } + } + + context.processPendingChanges() + try? context.save() + + // Persist the active workspace ID if not already set. + if Defaults[.activeWorkspaceId] == nil { + Defaults[.activeWorkspaceId] = defaultWorkspace.id.uuidString + } + } } diff --git a/Maccy/Storage.xcdatamodeld/Storage.xcdatamodel/contents b/Maccy/Storage.xcdatamodeld/Storage.xcdatamodel/contents index dbd3810b5..bd0412972 100644 --- a/Maccy/Storage.xcdatamodeld/Storage.xcdatamodel/contents +++ b/Maccy/Storage.xcdatamodeld/Storage.xcdatamodel/contents @@ -8,10 +8,23 @@ + - \ No newline at end of file + + + + + + + + + + + + + diff --git a/Maccy/Views/ContentView.swift b/Maccy/Views/ContentView.swift index 72fe0d94f..9b8a9c83d 100644 --- a/Maccy/Views/ContentView.swift +++ b/Maccy/Views/ContentView.swift @@ -24,6 +24,8 @@ struct ContentView: View { searchFocused: $searchFocused ) + WorkspaceTabsView() + VStack(alignment: .leading, spacing: 0) { HistoryListView( searchQuery: $appState.history.searchQuery, diff --git a/Maccy/Views/KeyHandlingView.swift b/Maccy/Views/KeyHandlingView.swift index d5d226405..924f99f8a 100644 --- a/Maccy/Views/KeyHandlingView.swift +++ b/Maccy/Views/KeyHandlingView.swift @@ -148,6 +148,9 @@ struct KeyHandlingView: View { appState.togglePin() return .handled case .selectCurrentItem: + if appState.isPopoverEditing { + return .ignored + } appState.select() return .handled case .close: diff --git a/Maccy/Views/WorkspaceTabsView.swift b/Maccy/Views/WorkspaceTabsView.swift new file mode 100644 index 000000000..0f7e11001 --- /dev/null +++ b/Maccy/Views/WorkspaceTabsView.swift @@ -0,0 +1,160 @@ +import SwiftUI + +struct WorkspaceTabsView: View { + @Environment(AppState.self) private var appState + + @State private var renamingWorkspace: Workspace? + @State private var renameText = "" + @State private var showRenamePopover = false + + private var workspaceManager: WorkspaceManager { + appState.workspaceManager + } + + var body: some View { + HStack(spacing: 4) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 4) { + ForEach(workspaceManager.workspaces, id: \.id) { workspace in + workspaceTab(workspace) + } + } + } + + Button(action: addWorkspace) { + Image(systemName: "plus") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(Color.secondary) + .frame(width: 22, height: 22) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color.primary.opacity(0.08)) + ) + } + .buttonStyle(.plain) + .disabled(workspaceManager.workspaces.count >= WorkspaceManager.maxWorkspaces) + .help(Text("workspace_add_tooltip")) + } + .padding(.horizontal, Popup.horizontalPadding + 5) + .padding(.vertical, 4) + .readHeight(appState, into: \.popup.workspaceTabsHeight) + .accessibilityHidden(true) + .onChange(of: showRenamePopover) { _, newValue in + appState.isPopoverEditing = newValue + } + } + + @ViewBuilder + private func workspaceTab(_ workspace: Workspace) -> some View { + let isActive = workspace == workspaceManager.activeWorkspace + + Text(workspace.name) + .font(.system(size: 11, weight: isActive ? .semibold : .regular)) + .foregroundStyle(isActive ? Color(nsColor: .selectedMenuItemTextColor) : Color.primary) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(isActive ? Color.accentColor : Color.primary.opacity(0.08)) + ) + .onTapGesture(count: 2) { + startRename(workspace) + } + .onTapGesture(count: 1) { + workspaceManager.switchTo(workspace) + Task { + try? await appState.history.load() + } + } + .popover(isPresented: Binding( + get: { renamingWorkspace == workspace && showRenamePopover }, + set: { if !$0 { endRename() } } + )) { + renamePopover() + } + .contextMenu { + workspaceContextMenu(for: workspace) + } + } + + @ViewBuilder + private func renamePopover() -> some View { + VStack(spacing: 8) { + Text("workspace_rename_title") + .font(.headline) + TextField("workspace_rename_name", text: $renameText) + .textFieldStyle(.roundedBorder) + .frame(width: 180) + .onSubmit { + commitRename() + } + HStack { + Button("workspace_rename_cancel") { + endRename() + } + Button("workspace_rename_confirm") { + commitRename() + } + .keyboardShortcut(.defaultAction) + .disabled(renameText.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + .padding() + } + + @ViewBuilder + private func workspaceContextMenu(for workspace: Workspace) -> some View { + Button("workspace_rename") { + startRename(workspace) + } + Button("workspace_delete", role: .destructive) { + let wasActive = (workspace == workspaceManager.activeWorkspace) + workspaceManager.delete(workspace) + if wasActive { + Task { + try? await appState.history.load() + } + } + } + .disabled(workspaceManager.workspaces.count <= 1) + } + + private func startRename(_ workspace: Workspace) { + renameText = workspace.name + renamingWorkspace = workspace + showRenamePopover = true + } + + private func endRename() { + showRenamePopover = false + renamingWorkspace = nil + } + + private func addWorkspace() { + let baseName = "Workspace" + var counter = 1 + var name = "\(baseName) \(counter)" + let existingNames = Set(workspaceManager.workspaces.map { $0.name.lowercased() }) + while existingNames.contains(name.lowercased()) { + counter += 1 + name = "\(baseName) \(counter)" + } + + if let workspace = workspaceManager.create(name: name) { + workspaceManager.switchTo(workspace) + Task { + try? await appState.history.load() + } + } + } + + private func commitRename() { + if let workspace = renamingWorkspace { + let name = renameText.trimmingCharacters(in: .whitespaces) + if !name.isEmpty { + workspaceManager.rename(workspace, to: name) + } + } + endRename() + } +} diff --git a/Maccy/en.lproj/Localizable.strings b/Maccy/en.lproj/Localizable.strings index 16c6f85f7..da6b1865c 100644 --- a/Maccy/en.lproj/Localizable.strings +++ b/Maccy/en.lproj/Localizable.strings @@ -28,3 +28,10 @@ "system_settings_pane" = "Privacy & Security settings"; "system_preferences_name" = "System Preferences"; "system_preferences_pane" = "Security & Privacy preferences"; +"workspace_add_tooltip" = "Add workspace"; +"workspace_rename_title" = "Rename Workspace"; +"workspace_rename_name" = "Name"; +"workspace_rename_cancel" = "Cancel"; +"workspace_rename_confirm" = "Rename"; +"workspace_rename" = "Rename"; +"workspace_delete" = "Delete"; diff --git a/MaccyTests/ClipboardTests.swift b/MaccyTests/ClipboardTests.swift index be4a5592a..5dcad93b5 100644 --- a/MaccyTests/ClipboardTests.swift +++ b/MaccyTests/ClipboardTests.swift @@ -26,8 +26,11 @@ class ClipboardTests: XCTestCase { let savedIgnoredApps = Defaults[.ignoredApps] let savedIgnoredPasteboardTypes = Defaults[.ignoredPasteboardTypes] + @MainActor override func setUp() { super.setUp() + Storage.shared.ensureDefaultWorkspace() + WorkspaceManager.shared.load() Defaults[.ignoreAllAppsExceptListed] = false Defaults[.ignoreEvents] = false } diff --git a/MaccyTests/HistoryDecoratorTests.swift b/MaccyTests/HistoryDecoratorTests.swift index 6aef658ed..f39511c5a 100644 --- a/MaccyTests/HistoryDecoratorTests.swift +++ b/MaccyTests/HistoryDecoratorTests.swift @@ -22,6 +22,8 @@ class HistoryItemDecoratorTests: XCTestCase { override func setUp() { super.setUp() + Storage.shared.ensureDefaultWorkspace() + WorkspaceManager.shared.load() Defaults[.highlightMatch] = .bold Defaults[.imageMaxHeight] = 40 } diff --git a/MaccyTests/HistoryItemTests.swift b/MaccyTests/HistoryItemTests.swift index 781e07be7..50083801b 100644 --- a/MaccyTests/HistoryItemTests.swift +++ b/MaccyTests/HistoryItemTests.swift @@ -5,6 +5,12 @@ import Defaults // swiftlint:disable force_try @MainActor class HistoryItemTests: XCTestCase { + override func setUp() { + super.setUp() + Storage.shared.ensureDefaultWorkspace() + WorkspaceManager.shared.load() + } + func testTitleForString() { let title = "foo" let item = historyItem(title) diff --git a/MaccyTests/HistoryTests.swift b/MaccyTests/HistoryTests.swift index a6d4be983..b3fbfe241 100644 --- a/MaccyTests/HistoryTests.swift +++ b/MaccyTests/HistoryTests.swift @@ -10,6 +10,8 @@ class HistoryTests: XCTestCase { override func setUp() { super.setUp() + Storage.shared.ensureDefaultWorkspace() + WorkspaceManager.shared.load() history.clearAll() Defaults[.size] = 10 Defaults[.sortBy] = .firstCopiedAt diff --git a/MaccyTests/SearchTests.swift b/MaccyTests/SearchTests.swift index e1bc53270..c0685ce8f 100644 --- a/MaccyTests/SearchTests.swift +++ b/MaccyTests/SearchTests.swift @@ -2,10 +2,17 @@ import XCTest import Defaults @testable import Maccy -class SearchTests: XCTestCase { +class SearchTests: XCTestCase { // swiftlint:disable:this type_body_length let savedSearchMode = Defaults[.searchMode] var items: [Search.Searchable]! + @MainActor + override func setUp() { + super.setUp() + Storage.shared.ensureDefaultWorkspace() + WorkspaceManager.shared.load() + } + override func tearDown() { super.tearDown() Defaults[.searchMode] = savedSearchMode diff --git a/MaccyTests/SorterTests.swift b/MaccyTests/SorterTests.swift index 6fb9619fc..491c29008 100644 --- a/MaccyTests/SorterTests.swift +++ b/MaccyTests/SorterTests.swift @@ -13,6 +13,8 @@ class SorterTests: XCTestCase { @MainActor override func setUp() { super.setUp() + Storage.shared.ensureDefaultWorkspace() + WorkspaceManager.shared.load() item1 = historyItem(value: "foo", firstCopiedAt: -300, lastCopiedAt: -100, numberOfCopies: 3) item2 = historyItem(value: "bar", firstCopiedAt: -400, lastCopiedAt: -300, numberOfCopies: 2) item3 = historyItem(value: "bar", firstCopiedAt: -200, lastCopiedAt: -200, numberOfCopies: 1)