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
32 changes: 27 additions & 5 deletions MacImageManager/MacImageManager/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,16 @@ import UniformTypeIdentifiers
struct ContentView: View {
// Use EnvironmentObject to access the shared model
@EnvironmentObject private var browserModel: BrowserModel
@State private var selectedFile: FileItem?
@FocusState private var activePane: ActivePane?

enum ActivePane {
case browser, viewer
}

// View selection based on media type
@ViewBuilder
private var mediaViewer: some View {
if let file = selectedFile {
if let file = browserModel.selectedFile {
switch file.mediaType {
case .staticImage, .unknown:
PaneImageViewer(selectedImage: file.url)
Expand Down Expand Up @@ -58,11 +62,13 @@ struct ContentView: View {
var body: some View {
HSplitView {
// Left pane - File browser
PaneFileBrowserView(selectedImage: $selectedFile)
PaneFileBrowserView(selectedImage: $browserModel.selectedFile)
.frame(minWidth: 250, maxWidth: 400)

.focused($activePane, equals: .browser)

// Right pane - Media viewer
mediaViewer
.focused($activePane, equals: .viewer)
.fileImporter(
isPresented: $browserModel.showingFileImporter,
allowedContentTypes: [.folder],
Expand Down Expand Up @@ -94,7 +100,23 @@ struct ContentView: View {
}
.onChange(of: browserModel.currentDirectory) { _, _ in
// Clear the selected image when navigating to a different directory
selectedFile = nil
browserModel.selectedFile = nil
}
.onKeyPress(.space) {
if activePane == .viewer && browserModel.selectedFileIsVideo {
browserModel.toggleVideoPlayback()
return .handled
}
return .ignored
}
.onKeyPress(phases: .down) { keyPress in
if keyPress.characters == "r" && keyPress.modifiers.contains(.command) {
if browserModel.canRenameSelectedFile {
browserModel.startRenamingSelectedFile()
return .handled
}
}
return .ignored
}
}
}
Expand Down
79 changes: 75 additions & 4 deletions MacImageManager/MacImageManager/MacImageManagerApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,92 @@ struct MacImageManagerApp: App {
WindowGroup {
ContentView()
.frame(minWidth: 1200, minHeight: 800)
// 💡 Add the shared model to the environment so child views can access it
// Add the shared model to the environment so child views can access it
.environmentObject(browserModel)
.onAppear {
NSWindow.allowsAutomaticWindowTabbing = false
}
}
.windowResizability(.contentSize)
.commands {
// Add keyboard shortcuts
// Hide the default Edit menu
CommandGroup(replacing: .textEditing) {}

// Hide the default View menu
CommandGroup(replacing: .toolbar) {}

// Replace the new item commands
CommandGroup(replacing: .newItem) {}


// Remove the (⌘W) "Close" item from `File`
CommandGroup(replacing: .saveItem) {}

// File operations
CommandGroup(after: .newItem) {
Button("Open Folder...") {
browserModel.showingFileImporter = true
}
.keyboardShortcut("o", modifiers: .command)

Divider()

Button("Go Up One Level") {
browserModel.navigateUp()
}
.keyboardShortcut(.upArrow, modifiers: .command)
.disabled(!browserModel.canNavigateUp)

Button("Rename File") {
browserModel.startRenamingSelectedFile()
}
.keyboardShortcut("r", modifiers: .command)
.disabled(!browserModel.canRenameSelectedFile)

Button("Delete File") {
browserModel.deleteSelectedFile()
}
.keyboardShortcut(.delete)
.disabled(!browserModel.hasSelectedFile)

Divider()

Button("Show in Finder") {
browserModel.showSelectedFileInFinder()
}
.keyboardShortcut("r", modifiers: [.command, .shift])
.disabled(!browserModel.hasSelectedFile)
}

// Playback menu for video controls (inserted explicitly after our File operations)
CommandMenu("Playback") {
Button("Play/Pause") {
browserModel.toggleVideoPlayback()
}
.keyboardShortcut(.space, modifiers: [])
.disabled(!browserModel.selectedFileIsVideo)

Divider()

Button("Jump Backward 10s") {
browserModel.jumpVideoBackward()
}
.keyboardShortcut(.leftArrow, modifiers: .command)
.disabled(!browserModel.selectedFileIsVideo)

Button("Jump Forward 10s") {
browserModel.jumpVideoForward()
}
.keyboardShortcut(.rightArrow, modifiers: .command)
.disabled(!browserModel.selectedFileIsVideo)

Divider()

Button("Restart Video") {
browserModel.restartVideo()
}
.keyboardShortcut("r", modifiers: [.command, .option])
.disabled(!browserModel.selectedFileIsVideo)
}

}
}
}
200 changes: 198 additions & 2 deletions MacImageManager/MacImageManager/Models/BrowserModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,21 @@ import Foundation
import SwiftUI
import UniformTypeIdentifiers
import Combine
import AVKit

class BrowserModel: ObservableObject {
@Published var items: [FileItem] = []
@Published var currentDirectory: URL
@Published var canNavigateUp: Bool = false
@Published var showingFileImporter: Bool = false
@Published var selectedFile: FileItem?
@Published var isRenamingFile = false
@Published var renamingText = ""
@Published var currentVideoPlayer: AVPlayer?

enum VideoAction {
case play, pause, toggle, jumpForward, jumpBackward, restart
}

// Cache to speed up metadata recomputation in large directories
private var fileItemCache: [URL: FileItem] = [:]
Expand Down Expand Up @@ -42,6 +51,8 @@ class BrowserModel: ObservableObject {
updateNavigationState()
}

let videoActionPublisher = PassthroughSubject<VideoAction, Never>()

var currentDirectoryName: String {
currentDirectory.lastPathComponent
}
Expand Down Expand Up @@ -72,8 +83,6 @@ class BrowserModel: ObservableObject {

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()

Expand Down Expand Up @@ -198,4 +207,191 @@ class BrowserModel: ObservableObject {

return imageItems.last
}

// MARK: - Menu Actions

/// Computed property to check if a file is selected for menu state
var hasSelectedFile: Bool {
selectedFile != nil
}

/// Computed property to check if selected file is renameable
var canRenameSelectedFile: Bool {
guard let file = selectedFile else { return false }
return !file.isDirectory // For now, only allow renaming files, not directories
}

/// Start renaming the currently selected file
func startRenamingSelectedFile() {
guard let file = selectedFile else { return }
renamingText = file.name
isRenamingFile = true
}

/// Complete the rename operation
func completeRename() {
guard let file = selectedFile, !renamingText.isEmpty else {
cancelRename()
return
}

// Validate the filename
guard isValidFilename(renamingText) else {
cancelRename()
return
}

// Check if the name hasn't actually changed
guard renamingText != file.name else {
cancelRename()
return
}

let newURL = file.url.deletingLastPathComponent().appendingPathComponent(renamingText)

do {
try FileManager.default.moveItem(at: file.url, to: newURL)

// Update the file item in our list
if let index = items.firstIndex(where: { $0.url == file.url }) {
Task {
let updatedItem = await FileItem(
url: newURL,
name: renamingText,
isDirectory: file.isDirectory,
fileSize: file.fileSize,
modificationDate: file.modificationDate,
uti: file.uti
)
await MainActor.run {
items[index] = updatedItem
selectedFile = updatedItem

// Update cache
fileItemCache.removeValue(forKey: file.url)
fileItemCache[newURL] = updatedItem

// Cancel rename mode after UI is updated
cancelRename()
}
}
} else {
// If item not found in list, still cancel rename mode
cancelRename()
}

print("Successfully renamed \(file.name) to \(renamingText)")
} catch {
print("Failed to rename file: \(error.localizedDescription)")
cancelRename()
}
}

/// Cancel the rename operation
func cancelRename() {
isRenamingFile = false
renamingText = ""
}

/// Delete the currently selected file
func deleteSelectedFile() {
guard let file = selectedFile else { return }

do {
try FileManager.default.trashItem(at: file.url, resultingItemURL: nil)

// Remove from our list
items.removeAll { $0.url == file.url }
fileItemCache.removeValue(forKey: file.url)
selectedFile = nil

print("Successfully moved \(file.name) to trash")
} catch {
print("Failed to delete file: \(error.localizedDescription)")
}
}

/// Show selected file in Finder
func showSelectedFileInFinder() {
guard let file = selectedFile else { return }
NSWorkspace.shared.selectFile(file.url.path, inFileViewerRootedAtPath: "")
}

// MARK: - Video Control Actions

/// Toggle video playback (play/pause)
func toggleVideoPlayback() {
videoActionPublisher.send(.toggle)
}

/// Play video
func playVideo() {
videoActionPublisher.send(.play)
}

/// Pause video
func pauseVideo() {
videoActionPublisher.send(.pause)
}

/// Jump forward 10 seconds
func jumpVideoForward() {
videoActionPublisher.send(.jumpForward)
}

/// Jump backward 10 seconds
func jumpVideoBackward() {
videoActionPublisher.send(.jumpBackward)
}

/// Restart video from beginning
func restartVideo() {
videoActionPublisher.send(.restart)
}

/// Set the current video player reference
func setVideoPlayer(_ player: AVPlayer?) {
currentVideoPlayer = player
}

/// Check if we currently have a video playing
var hasVideoPlayer: Bool {
currentVideoPlayer != nil
}

/// Check if current selection is a video file
var selectedFileIsVideo: Bool {
selectedFile?.mediaType == .video
}

/// Validate filename for macOS compatibility
private func isValidFilename(_ filename: String) -> Bool {
let trimmedName = filename.trimmingCharacters(in: .whitespacesAndNewlines)

// Check for empty or whitespace-only names
guard !trimmedName.isEmpty else { return false }

// Check if it's just an extension (starts with .)
guard !trimmedName.hasPrefix(".") else { return false }

// Check filename length (macOS limit is 255 bytes, but we'll use a conservative limit)
guard trimmedName.count <= 255 else { return false }

// Check for invalid characters on macOS
// macOS is more permissive than Windows, but these are still problematic
let invalidCharacters = CharacterSet(charactersIn: ":\0")
guard trimmedName.rangeOfCharacter(from: invalidCharacters) == nil else { return false }

// Check for names that are just dots
guard trimmedName != "." && trimmedName != ".." else { return false }

// Check for control characters (0x00-0x1F and 0x7F)
for char in trimmedName.unicodeScalars {
if char.value <= 0x1F || char.value == 0x7F {
return false
}
}

return true
}
}
Loading