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
4 changes: 1 addition & 3 deletions .sparkle/notes/next.md
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
- New:
- Changed:
- Fixed:
- New: File ▸ Move to Trash moves the playing file to the Trash and returns to the welcome window. Hold Option for Delete Immediately…, which deletes it outright after asking
100 changes: 100 additions & 0 deletions Front Row Tests/Files/FileRemoverTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
//
// FileRemoverTests.swift
// Front Row Tests
//

import Foundation
import Testing

@testable import Front_Row

/// Removing the playing file acts only on the file that was named, and cleans up after itself only
/// once the file is actually gone.
///
/// Deletes rather than trashes wherever a removal succeeds, so a test run leaves nothing in the
/// user's Trash.
@MainActor
struct FileRemoverTests {

private let presentationModel = PresentationModel()
private let recents: RecentDocumentsStore

init() {
let suite = "FileRemoverTests-\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defaults.removePersistentDomain(forName: suite)
recents = RecentDocumentsStore(
defaults: defaults, bookmarkProvider: FakeBookmarkProvider(),
mountedVolumes: FakeMountedVolumesProvider())
}

private func makeFile() throws -> URL {
let file = URL.temporaryDirectory.appending(path: "\(UUID().uuidString).mov")
try Data().write(to: file)
return file
}

private func remover(playing file: URL?) -> FileRemover {
FileRemover(
playingFile: { file }, recentDocuments: recents, presentationModel: presentationModel)
}

@Test
func removesThePlayingFileAndItsRecentsEntry() throws {
let file = try makeFile()
recents.noteRecentDocument(file)

#expect(remover(playing: file).remove(file, .delete))

#expect(!FileManager.default.fileExists(atPath: file.path(percentEncoded: false)))
#expect(!recents.recentURLs.contains(file), "The entry outlived its file")
#expect(presentationModel.fileRemovalAlert == nil)
}

/// The confirmation names one file, and something else can be opened while it's up. Removing
/// whatever is playing by then would delete a file nobody was asked about.
@Test
func refusesAFileThatIsNoLongerPlaying() throws {
let named = try makeFile()
let nowPlaying = try makeFile()
defer {
try? FileManager.default.removeItem(at: named)
try? FileManager.default.removeItem(at: nowPlaying)
}

#expect(!remover(playing: nowPlaying).remove(named, .delete))

#expect(FileManager.default.fileExists(atPath: named.path(percentEncoded: false)))
#expect(FileManager.default.fileExists(atPath: nowPlaying.path(percentEncoded: false)))
#expect(presentationModel.fileRemovalAlert == nil)
}

/// A stream, or nothing at all, has no file behind it to remove.
@Test
func refusesWhenNoLocalFileIsPlaying() throws {
let file = try makeFile()
defer { try? FileManager.default.removeItem(at: file) }

#expect(!remover(playing: nil).remove(file, .delete))

#expect(FileManager.default.fileExists(atPath: file.path(percentEncoded: false)))
}

/// A file that couldn't be removed is still there, so its recents entry stays too.
@Test
func aFailureRaisesAnAlertAndKeepsTheRecentsEntry() throws {
let file = try makeFile()
recents.noteRecentDocument(file)
try FileManager.default.removeItem(at: file)

#expect(!remover(playing: file).remove(file, .trash))

guard case .failure(let failure) = presentationModel.fileRemovalAlert else {
Issue.record("No failure was raised")
return
}
#expect(failure.url == file)
#expect(failure.removal == .trash)
#expect(recents.recentURLs.contains(file), "A failed removal dropped the entry")
}
}
16 changes: 16 additions & 0 deletions Front Row Tests/Models/PresentationModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,22 @@ struct PresentationModelTests {
#expect(model.raise(problem(film)), "The slot stayed shut after the conversion ended")
}

/// Confirming a deletion shares the slot with the other questions, and gives it back.
@Test
func aFileRemovalAlertHoldsTheSlotUntilDismissed() {
let model = PresentationModel()
#expect(model.raise(.confirmDeletion(film)))

#expect(!model.raise(problem(film)), "A conversion talked over a deletion")
#expect(!model.raise(unopenable(film)), "A recent-file alert talked over a deletion")
#expect(!model.raise(.confirmDeletion(film)), "A second deletion replaced the first")

model.dismissFileRemovalAlert()
#expect(!model.isAskingAboutAFile)
#expect(model.raise(problem(film)))
#expect(!model.raise(.confirmDeletion(film)), "A deletion talked over a conversion")
}

/// Playback commands stay disabled while any question is up, which is the one thing outside
/// this file that reads the slot.
@Test
Expand Down
61 changes: 61 additions & 0 deletions Front Row UI Tests/ControlAndMenuStateUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ final class ControlAndMenuStateUITests: FrontRowUITestCase {
menus.states(of: "File", for: ["Show in Finder"])["Show in Finder"], false,
"File ▸ Show in Finder"
)
assertEqual(
menus.states(of: "File", for: ["Move to Trash"])["Move to Trash"], false,
"File ▸ Move to Trash"
)
assertEqual(
menus.states(of: "Window", for: ["Natural Size"])["Natural Size"], false,
"Window ▸ Natural Size"
Expand All @@ -54,6 +58,63 @@ final class ControlAndMenuStateUITests: FrontRowUITestCase {
)
}

func testMoveToTrashTrashesTheFileAndReturnsToTheWelcomeWindow() async throws {
let movie = try await MediaFixtures.makeMovie(
size: CGSize(width: 640, height: 360), named: "trash", in: fixtures)
try openInFinder(movie)
let player = try playerWindow(for: movie)
waitForSizeToSettle(player)

menus.click("Move to Trash", in: "File")

XCTAssertTrue(
app.windows["welcome"].waitForExistence(timeout: 10),
"The welcome window did not come back"
)
XCTAssertTrue(
player.waitForNonExistence(timeout: 10), "The player window is still open")
XCTAssertFalse(
FileManager.default.fileExists(atPath: movie.path(percentEncoded: false)),
"The file is still where it was"
)
XCTAssertEqual(app.state, .runningForeground, "The app quit")
}

func testDeleteImmediatelyAsksFirstAndReturnsToTheWelcomeWindow() async throws {
let movie = try await MediaFixtures.makeMovie(
size: CGSize(width: 640, height: 360), named: "delete", in: fixtures)
try openInFinder(movie)
let player = try playerWindow(for: movie)
waitForSizeToSettle(player)
let path = movie.path(percentEncoded: false)

let confirmation = player.sheets.firstMatch
XCUIElement.perform(withKeyModifiers: .option) {
menus.click("Delete Immediately...", in: "File")
}
XCTAssertTrue(confirmation.waitForExistence(timeout: 10), "No confirmation was asked for")
confirmation.buttons["Cancel"].click()
XCTAssertTrue(
confirmation.waitForNonExistence(timeout: 10), "The confirmation stayed up")
XCTAssertTrue(FileManager.default.fileExists(atPath: path), "Cancel deleted the file")
XCTAssertTrue(player.exists, "Cancel closed the player window")

XCUIElement.perform(withKeyModifiers: .option) {
menus.click("Delete Immediately...", in: "File")
}
XCTAssertTrue(confirmation.waitForExistence(timeout: 10), "No confirmation was asked for")
confirmation.buttons["Delete"].click()

XCTAssertTrue(
app.windows["welcome"].waitForExistence(timeout: 10),
"The welcome window did not come back"
)
XCTAssertTrue(
player.waitForNonExistence(timeout: 10), "The player window is still open")
XCTAssertFalse(FileManager.default.fileExists(atPath: path), "The file is still there")
XCTAssertEqual(app.state, .runningForeground, "The app quit")
}

/// The item is titled for what it will do, so it reads "Pause" only while something is playing.
func testPlayPauseItemFollowsWhetherAnythingIsPlaying() async throws {
try await openFixture(named: "clip")
Expand Down
1 change: 1 addition & 0 deletions Front Row/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ struct ContentView: View {
}
.unopenableRecentFileAlert(in: .player)
.remuxAlert(in: .player)
.fileRemovalAlert()
.onAppear {
chrome.mouseMoved()
}
Expand Down
115 changes: 115 additions & 0 deletions Front Row/Files/FileRemovalAlert.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
//
// FileRemovalAlert.swift
// Front Row
//
// Created by Joshua Park on 9/26/26.
//

import SwiftUI

/// A file that couldn't be moved to the Trash or deleted, and what went wrong.
struct FileRemovalFailure {
var url: URL
var removal: FileRemoval
var reason: String
}

/// What removing the playing file is asking the user. Only the player window presents it, since
/// only a file playing there can be removed.
enum FileRemovalAlert {
case confirmDeletion(URL)
case failure(FileRemovalFailure)
}

extension View {
/// Presents the file removal alert. Applied to the player window only.
func fileRemovalAlert() -> some View {
modifier(FileRemovalAlertModifier())
}
}

private struct FileRemovalAlertModifier: ViewModifier {
@Environment(PresentationModel.self) private var presentationModel: PresentationModel
@Environment(\.openWindow) private var openWindow
@Environment(\.dismissWindow) private var dismissWindow

private var isPresented: Binding<Bool> {
Binding(
get: { presentationModel.fileRemovalAlert != nil },
set: { isPresented in
if !isPresented { presentationModel.dismissFileRemovalAlert() }
}
)
}

func body(content: Content) -> some View {
content.alert(
FileRemovalAlertTitle.text(for: presentationModel.fileRemovalAlert),
isPresented: isPresented,
presenting: presentationModel.fileRemovalAlert
) { alert in
switch alert {
case .confirmDeletion(let url):
Button(role: .destructive) {
// After the confirmation has gone: a failure raised from here would find
// the slot still taken.
Task {
FileRemover().remove(
url, .delete, openWindow: openWindow, dismissWindow: dismissWindow)
}
} label: {
Text("Delete", comment: "Alert button that confirms Delete Immediately")
}
Button(role: .cancel) {
} label: {
Text("Cancel", comment: "Alert button that cancels Delete Immediately")
}
case .failure:
Button {
} label: {
Text(
"OK",
comment: "Dismisses the alert shown when a file couldn’t be removed"
)
}
}
} message: { alert in
switch alert {
case .confirmDeletion:
Text(
"This item will be deleted immediately. You can’t undo this action.",
comment: "Message of the alert confirming Delete Immediately"
)
case .failure(let failure):
Text(failure.reason)
}
}
.onDisappear { presentationModel.dismissFileRemovalAlert() }
}
}

/// An alert title has to be a `Text`, so this is a function rather than a view.
private enum FileRemovalAlertTitle {
static func text(for alert: FileRemovalAlert?) -> Text {
switch alert {
case .confirmDeletion(let url):
Text(
"Are you sure you want to delete “\(url.lastPathComponent)”?",
comment:
"Title of the alert confirming Delete Immediately; the argument is a file name"
)
case .failure(let failure) where failure.removal == .trash:
Text(
"Couldn’t Move “\(failure.url.lastPathComponent)” to the Trash",
comment: "Title of the alert shown when a file couldn’t be moved to the Trash"
)
case .failure(let failure):
Text(
"Couldn’t Delete “\(failure.url.lastPathComponent)”",
comment: "Title of the alert shown when a file couldn’t be deleted"
)
case nil:
Text(verbatim: "")
}
}
}
Loading