Skip to content

Commit 9417ff0

Browse files
authored
Merge pull request #1 from gitbrent/new-viewers
Add new viewers
2 parents 2d51020 + 6d538d0 commit 9417ff0

File tree

9 files changed

+940
-141
lines changed

9 files changed

+940
-141
lines changed

MacImageManager/MacImageManager/ContentView.swift

Lines changed: 79 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -9,49 +9,93 @@ import SwiftUI
99
import UniformTypeIdentifiers
1010

1111
struct ContentView: View {
12-
// 1. Use EnvironmentObject to access the shared model
12+
// Use EnvironmentObject to access the shared model
1313
@EnvironmentObject private var browserModel: BrowserModel
14-
@State private var selectedImage: FileItem?
14+
@State private var selectedFile: FileItem?
15+
16+
// View selection based on media type
17+
@ViewBuilder
18+
private var mediaViewer: some View {
19+
if let file = selectedFile {
20+
switch file.mediaType {
21+
case .staticImage, .unknown:
22+
PaneImageViewer(selectedImage: file.url)
23+
.frame(minWidth: 250)
24+
case .animatedGif:
25+
PaneGifViewer(gifUrl: file.url)
26+
.frame(minWidth: 250, maxHeight: .infinity)
27+
case .video:
28+
PaneVideoViewer(videoUrl: file.url)
29+
.frame(minWidth: 250, maxHeight: .infinity)
30+
case .directory:
31+
// Show placeholder for unsupported types
32+
VStack {
33+
Image(systemName: "questionmark.square")
34+
.font(.system(size: 64))
35+
.foregroundColor(.secondary)
36+
Text("Unsupported file type")
37+
.font(.title2)
38+
.foregroundColor(.secondary)
39+
}
40+
.frame(maxWidth: .infinity, maxHeight: .infinity)
41+
.background(Color(NSColor.controlBackgroundColor))
42+
}
43+
} else {
44+
// Empty state when no file is selected
45+
VStack {
46+
Image(systemName: "photo.on.rectangle")
47+
.font(.system(size: 64))
48+
.foregroundColor(.secondary)
49+
Text("(select a file)")
50+
.font(.title2)
51+
.foregroundColor(.secondary)
52+
}
53+
.frame(maxWidth: .infinity, maxHeight: .infinity)
54+
.background(Color(NSColor.controlBackgroundColor))
55+
}
56+
}
1557

1658
var body: some View {
1759
HSplitView {
1860
// Left pane - File browser
19-
PaneFileBrowserView(selectedImage: $selectedImage)
61+
PaneFileBrowserView(selectedImage: $selectedFile)
2062
.frame(minWidth: 250, maxWidth: 400)
2163

22-
// Right pane - Image viewer
23-
PaneImageViewer(selectedImage: selectedImage?.url)
24-
.frame(minWidth: 250)
25-
}
26-
.fileImporter(
27-
isPresented: $browserModel.showingFileImporter,
28-
allowedContentTypes: [.folder],
29-
allowsMultipleSelection: false
30-
) { result in
31-
do {
32-
let urls = try result.get()
33-
if urls.count > 0 {
34-
// Create a temporary FileItem for the imported folder
35-
let folderUrl = urls[0]
36-
let folderItem = FileItem(
37-
url: folderUrl,
38-
name: folderUrl.lastPathComponent,
39-
iconName: "folder.fill",
40-
isDirectory: true,
41-
fileSize: 0,
42-
modificationDate: Date(),
43-
uti: .folder,
44-
isAnimatedGif: false,
45-
isVideo: false
46-
)
47-
browserModel.navigateInto(item: folderItem)
64+
// Right pane - Media viewer
65+
mediaViewer
66+
.fileImporter(
67+
isPresented: $browserModel.showingFileImporter,
68+
allowedContentTypes: [.folder],
69+
allowsMultipleSelection: false
70+
) { result in
71+
switch result {
72+
case .success(let urls):
73+
if let folderUrl = urls.first {
74+
Task {
75+
let folderItem = await FileItem(
76+
url: folderUrl,
77+
name: folderUrl.lastPathComponent,
78+
isDirectory: true,
79+
fileSize: 0,
80+
modificationDate: Date(),
81+
uti: .folder
82+
)
83+
browserModel.navigateInto(item: folderItem)
84+
}
85+
}
86+
case .failure(let error):
87+
print("Failed to import folder: \(error.localizedDescription)")
88+
}
89+
}
90+
.onAppear {
91+
Task {
92+
await browserModel.loadInitialDirectory()
93+
}
94+
}
95+
.onChange(of: browserModel.currentDirectory) { _, _ in
96+
// Clear the selected image when navigating to a different directory
97+
selectedFile = nil
4898
}
49-
} catch {
50-
print("Failed to import folder: \(error.localizedDescription)")
51-
}
52-
}
53-
.onAppear {
54-
browserModel.loadInitialDirectory()
5599
}
56100
}
57101
}

MacImageManager/MacImageManager/Models/BrowserModel+Preview.swift

Lines changed: 85 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,23 +12,94 @@ import UniformTypeIdentifiers
1212
extension BrowserModel {
1313
static var preview: BrowserModel {
1414
let model = BrowserModel()
15-
let now = Date()
16-
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: now)!
17-
let lastWeek = Calendar.current.date(byAdding: .day, value: -7, to: now)!
1815

19-
model.items = [
20-
// Folders
21-
FileItem(url: URL(fileURLWithPath: "/tmp/Archive"), name: "Archive", iconName: "folder.fill", isDirectory: true, fileSize: 0, modificationDate: lastWeek, uti: .folder, isAnimatedGif: false, isVideo: false),
22-
FileItem(url: URL(fileURLWithPath: "/tmp/Photos"), name: "Photos", iconName: "folder.fill", isDirectory: true, fileSize: 0, modificationDate: now, uti: .folder, isAnimatedGif: false, isVideo: false),
16+
// Run async initialization in a synchronous context for previews
17+
Task { @MainActor in
18+
let now = Date()
19+
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: now)!
20+
let lastWeek = Calendar.current.date(byAdding: .day, value: -7, to: now)!
21+
22+
// Create items asynchronously
23+
async let archiveItem = FileItem(
24+
url: URL(fileURLWithPath: "/tmp/Archive"),
25+
name: "Archive",
26+
isDirectory: true,
27+
fileSize: 0,
28+
modificationDate: lastWeek,
29+
uti: .folder
30+
)
31+
async let photosItem = FileItem(
32+
url: URL(fileURLWithPath: "/tmp/Photos"),
33+
name: "Photos",
34+
isDirectory: true,
35+
fileSize: 0,
36+
modificationDate: now,
37+
uti: .folder
38+
)
2339

2440
// Images with different formats
25-
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),
26-
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),
27-
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),
28-
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),
29-
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),
30-
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),
31-
]
41+
async let animationItem = FileItem(
42+
url: URL(fileURLWithPath: "/tmp/animation.gif"),
43+
name: "animation.gif",
44+
isDirectory: false,
45+
fileSize: 500_000,
46+
modificationDate: lastWeek,
47+
uti: UTType.gif
48+
)
49+
50+
async let vacationItem = FileItem(
51+
url: URL(fileURLWithPath: "/tmp/vacation.jpg"),
52+
name: "vacation.jpg",
53+
isDirectory: false,
54+
fileSize: 2_500_000,
55+
modificationDate: now,
56+
uti: .jpeg
57+
)
58+
59+
async let lockItem = FileItem(
60+
url: URL(fileURLWithPath: "/tmp/lock.svg"),
61+
name: "lock.svg",
62+
isDirectory: false,
63+
fileSize: 200_000,
64+
modificationDate: yesterday,
65+
uti: .svg)
66+
67+
async let screenshotItem = FileItem(
68+
url: URL(fileURLWithPath: "/tmp/screenshot.png"),
69+
name: "screenshot.png",
70+
isDirectory: false,
71+
fileSize: 1_200_000,
72+
modificationDate: yesterday,
73+
uti: .png)
74+
75+
async let profileItem = FileItem(
76+
url: URL(fileURLWithPath: "/tmp/profile.heic"),
77+
name: "profile.heic",
78+
isDirectory: false,
79+
fileSize: 3_000_000,
80+
modificationDate: now,
81+
uti: .heic)
82+
83+
async let ziplineItem = FileItem(
84+
url: URL(fileURLWithPath: "/tmp/zipline.mp4"),
85+
name: "zipline.mp4",
86+
isDirectory: false,
87+
fileSize: 55_100_000,
88+
modificationDate: now,
89+
uti: .mpeg4Movie)
90+
91+
// Wait for all items to be created and add them to the model
92+
model.items = await [
93+
archiveItem,
94+
photosItem,
95+
animationItem,
96+
vacationItem,
97+
lockItem,
98+
screenshotItem,
99+
profileItem,
100+
ziplineItem
101+
]
102+
}
32103

33104
return model
34105
}

MacImageManager/MacImageManager/Models/BrowserModel.swift

Lines changed: 20 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,8 @@ class BrowserModel: ObservableObject {
1616
@Published var canNavigateUp: Bool = false
1717
@Published var showingFileImporter: Bool = false
1818

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

2322
private let fileManager = FileManager.default
2423

@@ -47,15 +46,15 @@ class BrowserModel: ObservableObject {
4746
currentDirectory.lastPathComponent
4847
}
4948

50-
var imageCount: Int {
51-
items.filter { isImageFile($0) }.count
49+
var supportedFileCount: Int {
50+
items.filter { $0.mediaType != .unknown && !$0.isDirectory }.count
5251
}
5352

54-
func loadInitialDirectory() {
55-
loadCurrentDirectory()
53+
@MainActor func loadInitialDirectory() async {
54+
await loadCurrentDirectory()
5655
}
5756

58-
func loadCurrentDirectory() {
57+
@MainActor func loadCurrentDirectory() async {
5958
do {
6059
let contents = try fileManager.contentsOfDirectory(
6160
at: currentDirectory,
@@ -67,23 +66,18 @@ class BrowserModel: ObservableObject {
6766

6867
for url in contents {
6968
let resourceValues = try url.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey, .isReadableKey, .contentTypeKey])
70-
69+
7170
// GUARD: Check if the file is readable.
7271
guard resourceValues.isReadable ?? false else { continue }
73-
72+
7473
let uti = resourceValues.contentType
7574
let isDir = resourceValues.isDirectory ?? false
7675
let isAnimatedGif = uti?.conforms(to: UTType.gif) ?? false
7776
let isVideo = uti?.conforms(to: UTType.movie) ?? false
7877
let fileSize = resourceValues.fileSize ?? 0
7978
let modDate = resourceValues.contentModificationDate ?? Date()
8079

81-
let iconName: String
82-
if isDir {
83-
iconName = "folder.fill"
84-
} else {
85-
iconName = self.iconName(for: uti)
86-
}
80+
// Directory check moved to FileItem initialization
8781

8882
// Reuse cached FileItem when unchanged to avoid recomputing
8983
if let cached = fileItemCache[url],
@@ -93,16 +87,13 @@ class BrowserModel: ObservableObject {
9387
cached.uti == uti {
9488
fileItems.append(cached)
9589
} else {
96-
let fileItem = FileItem(
90+
let fileItem = await FileItem(
9791
url: url,
9892
name: url.lastPathComponent,
99-
iconName: iconName,
10093
isDirectory: isDir,
10194
fileSize: fileSize,
10295
modificationDate: modDate,
103-
uti: uti,
104-
isAnimatedGif: isAnimatedGif,
105-
isVideo: isVideo
96+
uti: uti
10697
)
10798
fileItems.append(fileItem)
10899
fileItemCache[url] = fileItem
@@ -131,7 +122,9 @@ class BrowserModel: ObservableObject {
131122
// Don't go above the user's home directory for safety
132123
if parentDirectory.path.count >= fileManager.homeDirectoryForCurrentUser.path.count {
133124
currentDirectory = parentDirectory
134-
loadCurrentDirectory()
125+
Task {
126+
await loadCurrentDirectory()
127+
}
135128
}
136129
}
137130

@@ -154,58 +147,21 @@ class BrowserModel: ObservableObject {
154147
_ = try fileManager.contentsOfDirectory(at: item.url, includingPropertiesForKeys: nil, options: [])
155148
print("Navigating into: \(item.url.path)")
156149
currentDirectory = item.url
157-
loadCurrentDirectory()
150+
Task {
151+
await loadCurrentDirectory()
152+
}
158153
} catch {
159154
print("Cannot access directory \(item.url.path): \(error)")
160155
}
161156
}
162157

163158
func isImageFile(_ item: FileItem) -> Bool {
164-
guard !item.isDirectory else { return false }
165-
166-
// Prefer the UTI from metadata; if missing, derive from the filename extension
167-
let type = item.uti ?? UTType(filenameExtension: item.url.pathExtension.lowercased())
168-
169-
if let type {
170-
return type.conforms(to: .rawImage) || type.conforms(to: .image)
171-
}
172-
173-
return false
159+
return item.mediaType == .staticImage
174160
}
175161

176-
func openDirectory(_ item: FileItem) {
162+
@MainActor func openDirectory(_ item: FileItem) async {
177163
self.currentDirectory = item.url
178-
loadCurrentDirectory()
179-
}
180-
181-
// Returns an SF Symbol name for a given UTI, with simple caching
182-
private func iconName(for uti: UTType?) -> String {
183-
guard let uti = uti else { return "photo" }
184-
let key = uti.identifier
185-
if let cached = iconNameCache[key] {
186-
return cached
187-
}
188-
189-
let name: String
190-
if uti == .livePhoto {
191-
name = "livephoto"
192-
} else if uti.conforms(to: .gif) {
193-
name = "rectangle.stack.badge.play"
194-
} else if uti == .svg {
195-
name = "square.on.square.squareshape.controlhandles"
196-
} else if uti.conforms(to: .rawImage) {
197-
name = "camera.aperture"
198-
} else if uti == .heic || uti == .heif {
199-
name = "photo"
200-
} else if uti.conforms(to: UTType.rawImage) || uti.conforms(to: UTType.image) {
201-
name = "photo"
202-
} else if uti.conforms(to: UTType.movie) {
203-
name = "film"
204-
} else {
205-
name = "questionmark.square.dashed"
206-
}
207-
iconNameCache[key] = name
208-
return name
164+
await loadCurrentDirectory()
209165
}
210166

211167
private func updateNavigationState() {
@@ -243,4 +199,3 @@ class BrowserModel: ObservableObject {
243199
return imageItems.last
244200
}
245201
}
246-

0 commit comments

Comments
 (0)