1ac917959d28

add multi-source local folders with idempotent migration (v0.6.0)

time
2026-07-16 22:10 UTC · 2 months ago
machine
koh2
agent
minimax-m3
session
resolve remaining Haven findings
parent
c3734780b18a
1 file Sources/Haven/UI/AppState.swift+81 −0
Sources/Haven/UI/AppState.swift+81 −0
@@ -74,6 +74,9 @@7474   @Published var saveFolderPath: String {7575     didSet { UserDefaults.standard.set(saveFolderPath, forKey: "saveFolderPath") }7676   }77+  @Published var sourceFolderPaths: [String] {78+    didSet { UserDefaults.standard.set(sourceFolderPaths, forKey: "sourceFolderPaths") }79+  }7780   @Published var launchAtLogin: Bool {7881     didSet { autostartManager.isEnabled = launchAtLogin }7982   }@@ -89,6 +92,7 @@8992   private let wallpaperManager: WallpaperManaging9093   private let autostartManager: AutostartManaging9194   private let chooseFolder: @MainActor (URL, @escaping (URL?) -> Void) -> Void95+  private let chooseSourceFolders: @MainActor ([URL], @escaping ([URL]) -> Void) -> Void9296   private let terminateApplication: @MainActor () -> Void9397   private let removeTempFile: @MainActor (URL) throws -> Void9498   private var cancellables = Set<AnyCancellable>()@@ -103,6 +107,7 @@103107     wallpaperManager: WallpaperManaging? = nil,104108     autostartManager: AutostartManaging? = nil,105109     chooseFolder: (@MainActor (URL, @escaping (URL?) -> Void) -> Void)? = nil,110+    chooseSourceFolders: (@MainActor ([URL], @escaping ([URL]) -> Void) -> Void)? = nil,106111     terminateApplication: (@MainActor () -> Void)? = nil,107112     removeTempFile: @escaping @MainActor (URL) throws -> Void = {108113       try FileManager.default.removeItem(at: $0)@@ -119,6 +124,7 @@119124     self.wallpaperManager = wallpaperManager120125     self.autostartManager = autostartManager121126     self.chooseFolder = chooseFolder ?? AppState.presentFolderPicker127+    self.chooseSourceFolders = chooseSourceFolders ?? AppState.chooseDefaultSourceFolders122128     self.terminateApplication = terminateApplication ?? { NSApp.terminate(nil) }123129     self.removeTempFile = removeTempFile124130     self.launchAtLogin = autostartManager.isEnabled@@ -130,6 +136,20 @@130136       let pictures = picturesDirectoryProvider() ?? FileManager.default.temporaryDirectory131137       self.saveFolderPath = pictures.appendingPathComponent("Haven").path132138     }139+    // Migrate the legacy single-folder setting into the new source list140+    // exactly once, so existing users keep seeing their gallery. Idempotent141+    // because we only seed when the new key is absent.142+    if let storedSources = UserDefaults.standard.array(forKey: "sourceFolderPaths")143+      as? [String]144+    {145+      self.sourceFolderPaths = storedSources146+    } else if let legacyPath = UserDefaults.standard.string(forKey: "saveFolderPath") {147+      let seeded = [legacyPath]148+      self.sourceFolderPaths = seeded149+      UserDefaults.standard.set(seeded, forKey: "sourceFolderPaths")150+    } else {151+      self.sourceFolderPaths = []152+    }133153 134154     guard observeChanges else { return }135155 @@ -189,6 +209,13 @@189209         self?.updateLocalWallpapersMonitoring()190210       }191211       .store(in: &cancellables)212+213+    $sourceFolderPaths214+      .dropFirst()215+      .sink { [weak self] _ in216+        self?.updateLocalWallpapersMonitoring()217+      }218+      .store(in: &cancellables)192219   }193220 194221   private func updateLocalWallpapersMonitoring() {@@ -393,6 +420,60 @@393420   ) {394421     panel.begin { response in completion(response == .OK ? panel.url : nil) }395422   }423+424+  /// Show the multi-select source folder picker and append the chosen425+  /// directories to `sourceFolderPaths`. Cancelling the picker is a no-op.426+  func addSourceFolders() {427+    MenuController.shared?.beginModalPresentation()428+    NSApp.activate(ignoringOtherApps: true)429+430+    chooseSourceFolders([]) { urls in431+      let merged = (self.sourceFolderPaths + urls.map(\.path))432+        .reduce(into: [String]()) { acc, path in433+          if !acc.contains(path) { acc.append(path) }434+        }435+      self.sourceFolderPaths = merged436+      MenuController.shared?.endModalPresentation()437+    }438+  }439+440+  /// Remove a previously added source folder. Idempotent: removing an441+  /// unknown path is a no-op.442+  func removeSourceFolder(_ path: String) {443+    sourceFolderPaths.removeAll { $0 == path }444+  }445+446+  static func makeSourceFoldersPanel() -> NSOpenPanel {447+    let panel = NSOpenPanel()448+    panel.canChooseFiles = false449+    panel.canChooseDirectories = true450+    panel.canCreateDirectories = true451+    panel.allowsMultipleSelection = true452+    panel.prompt = "Add"453+    return panel454+  }455+456+  static func presentSourceFoldersPicker(457+    panel: NSOpenPanel, completion: @escaping ([URL]) -> Void458+  ) {459+    panel.begin { response in460+      completion(response == .OK ? panel.urls : [])461+    }462+  }463+464+  static func presentSourceFoldersPicker(465+    _ directory: URL, completion: @escaping ([URL]) -> Void466+  ) {467+    let panel = makeSourceFoldersPanel()468+    panel.directoryURL = directory469+    presentSourceFoldersPicker(panel: panel, completion: completion)470+  }471+472+  static let chooseDefaultSourceFolders: @MainActor ([URL], @escaping ([URL]) -> Void) -> Void =473+    { directories, completion in474+      let start = directories.last ?? FileManager.default.homeDirectoryForCurrentUser475+      presentSourceFoldersPicker(start, completion: completion)476+    }396477 397478   func quit() {398479     terminateApplication()

view file