Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 79 additions & 35 deletions MacImageManager/MacImageManager/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,49 +9,93 @@ import SwiftUI
import UniformTypeIdentifiers

struct ContentView: View {
// 1. Use EnvironmentObject to access the shared model
// Use EnvironmentObject to access the shared model
@EnvironmentObject private var browserModel: BrowserModel
@State private var selectedImage: FileItem?
@State private var selectedFile: FileItem?

// View selection based on media type
@ViewBuilder
private var mediaViewer: some View {
if let file = selectedFile {
switch file.mediaType {
case .staticImage, .unknown:
PaneImageViewer(selectedImage: file.url)
.frame(minWidth: 250)
case .animatedGif:
PaneGifViewer(gifUrl: file.url)
.frame(minWidth: 250, maxHeight: .infinity)
case .video:
PaneVideoViewer(videoUrl: file.url)
.frame(minWidth: 250, maxHeight: .infinity)
case .directory:
// Show placeholder for unsupported types
VStack {
Image(systemName: "questionmark.square")
.font(.system(size: 64))
.foregroundColor(.secondary)
Text("Unsupported file type")
.font(.title2)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(NSColor.controlBackgroundColor))
}
} else {
// Empty state when no file is selected
VStack {
Image(systemName: "photo.on.rectangle")
.font(.system(size: 64))
.foregroundColor(.secondary)
Text("(select a file)")
.font(.title2)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(NSColor.controlBackgroundColor))
}
}

var body: some View {
HSplitView {
// Left pane - File browser
PaneFileBrowserView(selectedImage: $selectedImage)
PaneFileBrowserView(selectedImage: $selectedFile)
.frame(minWidth: 250, maxWidth: 400)

// Right pane - Image viewer
PaneImageViewer(selectedImage: selectedImage?.url)
.frame(minWidth: 250)
}
.fileImporter(
isPresented: $browserModel.showingFileImporter,
allowedContentTypes: [.folder],
allowsMultipleSelection: false
) { result in
do {
let urls = try result.get()
if urls.count > 0 {
// Create a temporary FileItem for the imported folder
let folderUrl = urls[0]
let folderItem = FileItem(
url: folderUrl,
name: folderUrl.lastPathComponent,
iconName: "folder.fill",
isDirectory: true,
fileSize: 0,
modificationDate: Date(),
uti: .folder,
isAnimatedGif: false,
isVideo: false
)
browserModel.navigateInto(item: folderItem)
// Right pane - Media viewer
mediaViewer
.fileImporter(
isPresented: $browserModel.showingFileImporter,
allowedContentTypes: [.folder],
allowsMultipleSelection: false
) { result in
switch result {
case .success(let urls):
if let folderUrl = urls.first {
Task {
let folderItem = await FileItem(
url: folderUrl,
name: folderUrl.lastPathComponent,
isDirectory: true,
fileSize: 0,
modificationDate: Date(),
uti: .folder
)
browserModel.navigateInto(item: folderItem)
}
}
case .failure(let error):
print("Failed to import folder: \(error.localizedDescription)")
}
}
.onAppear {
Task {
await browserModel.loadInitialDirectory()
}
}
.onChange(of: browserModel.currentDirectory) { _, _ in
// Clear the selected image when navigating to a different directory
selectedFile = nil
}
} catch {
print("Failed to import folder: \(error.localizedDescription)")
}
}
.onAppear {
browserModel.loadInitialDirectory()
}
}
}
Expand Down
99 changes: 85 additions & 14 deletions MacImageManager/MacImageManager/Models/BrowserModel+Preview.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,94 @@ import UniformTypeIdentifiers
extension BrowserModel {
static var preview: BrowserModel {
let model = BrowserModel()
let now = Date()
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: now)!
let lastWeek = Calendar.current.date(byAdding: .day, value: -7, to: now)!

model.items = [
// Folders
FileItem(url: URL(fileURLWithPath: "/tmp/Archive"), name: "Archive", iconName: "folder.fill", isDirectory: true, fileSize: 0, modificationDate: lastWeek, uti: .folder, isAnimatedGif: false, isVideo: false),
FileItem(url: URL(fileURLWithPath: "/tmp/Photos"), name: "Photos", iconName: "folder.fill", isDirectory: true, fileSize: 0, modificationDate: now, uti: .folder, isAnimatedGif: false, isVideo: false),
// Run async initialization in a synchronous context for previews
Task { @MainActor in
let now = Date()
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: now)!
let lastWeek = Calendar.current.date(byAdding: .day, value: -7, to: now)!

// Create items asynchronously
async let archiveItem = FileItem(
url: URL(fileURLWithPath: "/tmp/Archive"),
name: "Archive",
isDirectory: true,
fileSize: 0,
modificationDate: lastWeek,
uti: .folder
)
async let photosItem = FileItem(
url: URL(fileURLWithPath: "/tmp/Photos"),
name: "Photos",
isDirectory: true,
fileSize: 0,
modificationDate: now,
uti: .folder
)

// Images with different formats
FileItem(url: URL(fileURLWithPath: "/tmp/animation.gif"), name: "animation.gif", iconName: "photo", isDirectory: false, fileSize: 500_000, modificationDate: lastWeek, uti: .gif, isAnimatedGif: true, isVideo: false),
FileItem(url: URL(fileURLWithPath: "/tmp/vacation.jpg"), name: "vacation.jpg", iconName: "photo", isDirectory: false, fileSize: 2_500_000, modificationDate: now, uti: .jpeg, isAnimatedGif: false, isVideo: false),
FileItem(url: URL(fileURLWithPath: "/tmp/lock.svg"), name: "lock.svg", iconName: "photo", isDirectory: false, fileSize: 200_000, modificationDate: yesterday, uti: .svg, isAnimatedGif: false, isVideo: false),
FileItem(url: URL(fileURLWithPath: "/tmp/screenshot.png"), name: "screenshot.png", iconName: "photo", isDirectory: false, fileSize: 1_200_000, modificationDate: yesterday, uti: .png, isAnimatedGif: false, isVideo: false),
FileItem(url: URL(fileURLWithPath: "/tmp/profile.heic"), name: "profile.heic", iconName: "photo", isDirectory: false, fileSize: 3_000_000, modificationDate: now, uti: .heic, isAnimatedGif: false, isVideo: false),
FileItem(url: URL(fileURLWithPath: "/tmp/zipline.mp4"), name: "zipline.mp4", iconName: "film", isDirectory: false, fileSize: 55_100_000, modificationDate: now, uti: .mpeg4Movie, isAnimatedGif: false, isVideo: true),
]
async let animationItem = FileItem(
url: URL(fileURLWithPath: "/tmp/animation.gif"),
name: "animation.gif",
isDirectory: false,
fileSize: 500_000,
modificationDate: lastWeek,
uti: UTType.gif
)

async let vacationItem = FileItem(
url: URL(fileURLWithPath: "/tmp/vacation.jpg"),
name: "vacation.jpg",
isDirectory: false,
fileSize: 2_500_000,
modificationDate: now,
uti: .jpeg
)

async let lockItem = FileItem(
url: URL(fileURLWithPath: "/tmp/lock.svg"),
name: "lock.svg",
isDirectory: false,
fileSize: 200_000,
modificationDate: yesterday,
uti: .svg)

async let screenshotItem = FileItem(
url: URL(fileURLWithPath: "/tmp/screenshot.png"),
name: "screenshot.png",
isDirectory: false,
fileSize: 1_200_000,
modificationDate: yesterday,
uti: .png)

async let profileItem = FileItem(
url: URL(fileURLWithPath: "/tmp/profile.heic"),
name: "profile.heic",
isDirectory: false,
fileSize: 3_000_000,
modificationDate: now,
uti: .heic)

async let ziplineItem = FileItem(
url: URL(fileURLWithPath: "/tmp/zipline.mp4"),
name: "zipline.mp4",
isDirectory: false,
fileSize: 55_100_000,
modificationDate: now,
uti: .mpeg4Movie)

// Wait for all items to be created and add them to the model
model.items = await [
archiveItem,
photosItem,
animationItem,
vacationItem,
lockItem,
screenshotItem,
profileItem,
ziplineItem
]
}

return model
}
Expand Down
85 changes: 20 additions & 65 deletions MacImageManager/MacImageManager/Models/BrowserModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,8 @@ class BrowserModel: ObservableObject {
@Published var canNavigateUp: Bool = false
@Published var showingFileImporter: Bool = false

// Caches to speed up metadata/icon recomputation in large directories
// Cache to speed up metadata recomputation in large directories
private var fileItemCache: [URL: FileItem] = [:]
private var iconNameCache: [String: String] = [:] // key: UTI identifier

private let fileManager = FileManager.default

Expand Down Expand Up @@ -47,15 +46,15 @@ class BrowserModel: ObservableObject {
currentDirectory.lastPathComponent
}

var imageCount: Int {
items.filter { isImageFile($0) }.count
var supportedFileCount: Int {
items.filter { $0.mediaType != .unknown && !$0.isDirectory }.count
}

func loadInitialDirectory() {
loadCurrentDirectory()
@MainActor func loadInitialDirectory() async {
await loadCurrentDirectory()
}

func loadCurrentDirectory() {
@MainActor func loadCurrentDirectory() async {
do {
let contents = try fileManager.contentsOfDirectory(
at: currentDirectory,
Expand All @@ -67,23 +66,18 @@ class BrowserModel: ObservableObject {

for url in contents {
let resourceValues = try url.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey, .isReadableKey, .contentTypeKey])

// GUARD: Check if the file is readable.
guard resourceValues.isReadable ?? false else { continue }

let uti = resourceValues.contentType
let isDir = resourceValues.isDirectory ?? false
let isAnimatedGif = uti?.conforms(to: UTType.gif) ?? false
let isVideo = uti?.conforms(to: UTType.movie) ?? false
let fileSize = resourceValues.fileSize ?? 0
let modDate = resourceValues.contentModificationDate ?? Date()

let iconName: String
if isDir {
iconName = "folder.fill"
} else {
iconName = self.iconName(for: uti)
}
// Directory check moved to FileItem initialization

// Reuse cached FileItem when unchanged to avoid recomputing
if let cached = fileItemCache[url],
Expand All @@ -93,16 +87,13 @@ class BrowserModel: ObservableObject {
cached.uti == uti {
fileItems.append(cached)
} else {
let fileItem = FileItem(
let fileItem = await FileItem(
url: url,
name: url.lastPathComponent,
iconName: iconName,
isDirectory: isDir,
fileSize: fileSize,
modificationDate: modDate,
uti: uti,
isAnimatedGif: isAnimatedGif,
isVideo: isVideo
uti: uti
)
fileItems.append(fileItem)
fileItemCache[url] = fileItem
Expand Down Expand Up @@ -131,7 +122,9 @@ class BrowserModel: ObservableObject {
// Don't go above the user's home directory for safety
if parentDirectory.path.count >= fileManager.homeDirectoryForCurrentUser.path.count {
currentDirectory = parentDirectory
loadCurrentDirectory()
Task {
await loadCurrentDirectory()
}
}
}

Expand All @@ -154,58 +147,21 @@ class BrowserModel: ObservableObject {
_ = try fileManager.contentsOfDirectory(at: item.url, includingPropertiesForKeys: nil, options: [])
print("Navigating into: \(item.url.path)")
currentDirectory = item.url
loadCurrentDirectory()
Task {
await loadCurrentDirectory()
}
} catch {
print("Cannot access directory \(item.url.path): \(error)")
}
}

func isImageFile(_ item: FileItem) -> Bool {
guard !item.isDirectory else { return false }

// Prefer the UTI from metadata; if missing, derive from the filename extension
let type = item.uti ?? UTType(filenameExtension: item.url.pathExtension.lowercased())

if let type {
return type.conforms(to: .rawImage) || type.conforms(to: .image)
}

return false
return item.mediaType == .staticImage
}

func openDirectory(_ item: FileItem) {
@MainActor func openDirectory(_ item: FileItem) async {
self.currentDirectory = item.url
loadCurrentDirectory()
}

// Returns an SF Symbol name for a given UTI, with simple caching
private func iconName(for uti: UTType?) -> String {
guard let uti = uti else { return "photo" }
let key = uti.identifier
if let cached = iconNameCache[key] {
return cached
}

let name: String
if uti == .livePhoto {
name = "livephoto"
} else if uti.conforms(to: .gif) {
name = "rectangle.stack.badge.play"
} else if uti == .svg {
name = "square.on.square.squareshape.controlhandles"
} else if uti.conforms(to: .rawImage) {
name = "camera.aperture"
} else if uti == .heic || uti == .heif {
name = "photo"
} else if uti.conforms(to: UTType.rawImage) || uti.conforms(to: UTType.image) {
name = "photo"
} else if uti.conforms(to: UTType.movie) {
name = "film"
} else {
name = "questionmark.square.dashed"
}
iconNameCache[key] = name
return name
await loadCurrentDirectory()
}

private func updateNavigationState() {
Expand Down Expand Up @@ -243,4 +199,3 @@ class BrowserModel: ObservableObject {
return imageItems.last
}
}

Loading