Skip to content

Commit 1a45962

Browse files
committed
feat: rename, duplicate, and search recordings in the library
Project management was open/reveal/delete only. Added: - Rename… (context menu → alert): moves the folder and rewrites the descriptor, preserving the original creation date. A project open in a Studio window keeps pointing at the old path until reopened. - Duplicate: copies the whole folder as "Name copy" (collision-safe). - A search field in the header filtering tiles by name. Both library operations are unit-tested against a temp directory.
1 parent 0f38f79 commit 1a45962

3 files changed

Lines changed: 114 additions & 1 deletion

File tree

CineScreen/MainWindow/ProjectsView.swift

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ struct ProjectsView: View {
99
@State private var hoveredProject: Project.ID?
1010
/// Set when the user picks Delete — the confirmation dialog acts on it.
1111
@State private var projectPendingDeletion: Project?
12+
/// Set when the user picks Rename… — the rename alert acts on it.
13+
@State private var projectPendingRename: Project?
14+
@State private var renameText = ""
15+
@State private var searchText = ""
1216

1317
var body: some View {
1418
ZStack(alignment: .top) {
@@ -62,6 +66,32 @@ struct ProjectsView: View {
6266
} message: {
6367
Text("The recording and its edits can be restored from the Trash.")
6468
}
69+
.alert(
70+
"Rename Recording",
71+
isPresented: Binding(
72+
get: { projectPendingRename != nil },
73+
set: { if !$0 { projectPendingRename = nil } }
74+
)
75+
) {
76+
TextField("Name", text: $renameText)
77+
Button("Rename") {
78+
if let project = projectPendingRename {
79+
do {
80+
_ = try ProjectsLibrary.rename(project, to: renameText)
81+
state.refreshProjects()
82+
} catch {
83+
state.statusMessage = "Rename failed: \(error.localizedDescription)"
84+
}
85+
}
86+
projectPendingRename = nil
87+
}
88+
Button("Cancel", role: .cancel) { projectPendingRename = nil }
89+
}
90+
}
91+
92+
private var filteredProjects: [Project] {
93+
guard !searchText.isEmpty else { return state.projects }
94+
return state.projects.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
6595
}
6696

6797
// MARK: - Backdrop
@@ -86,6 +116,10 @@ struct ProjectsView: View {
86116
.truncationMode(.head)
87117
}
88118
Spacer(minLength: 8)
119+
TextField("Search", text: $searchText)
120+
.textFieldStyle(.roundedBorder)
121+
.controlSize(.small)
122+
.frame(width: 180)
89123
newRecordingButton
90124
}
91125
.padding(.horizontal, 28)
@@ -124,7 +158,7 @@ struct ProjectsView: View {
124158
columns: [GridItem(.adaptive(minimum: 260), spacing: 20)],
125159
spacing: 20
126160
) {
127-
ForEach(state.projects) { project in
161+
ForEach(filteredProjects) { project in
128162
ProjectTile(
129163
project: project,
130164
isHovered: hoveredProject == project.id
@@ -136,6 +170,16 @@ struct ProjectsView: View {
136170
ProjectsLibrary.reveal(project)
137171
} onDelete: {
138172
projectPendingDeletion = project
173+
} onRename: {
174+
renameText = project.name
175+
projectPendingRename = project
176+
} onDuplicate: {
177+
do {
178+
_ = try ProjectsLibrary.duplicate(project)
179+
state.refreshProjects()
180+
} catch {
181+
state.statusMessage = "Duplicate failed: \(error.localizedDescription)"
182+
}
139183
}
140184
.onHover { hovering in
141185
hoveredProject = hovering ? project.id : nil
@@ -244,6 +288,8 @@ private struct ProjectTile: View {
244288
var onOpen: () -> Void
245289
var onReveal: () -> Void
246290
var onDelete: () -> Void
291+
var onRename: () -> Void
292+
var onDuplicate: () -> Void
247293

248294
@State private var thumbnail: NSImage?
249295

@@ -269,6 +315,8 @@ private struct ProjectTile: View {
269315
.contextMenu {
270316
Button("Open") { onOpen() }
271317
.disabled(!project.isComplete)
318+
Button("Rename…") { onRename() }
319+
Button("Duplicate") { onDuplicate() }
272320
Button("Reveal in Finder") { onReveal() }
273321
Divider()
274322
Button("Delete", role: .destructive) { onDelete() }

CineScreen/Projects/ProjectsLibrary.swift

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,43 @@ final class ProjectsLibrary {
122122
try encoder.encode(descriptor).write(to: url, options: .atomic)
123123
}
124124

125+
/// Renames the project folder and updates its descriptor, preserving the
126+
/// original creation date. Throws if a folder with the new name already
127+
/// exists. Note: renaming a project that's open in a Studio window leaves
128+
/// that window pointing at the old path until it's reopened.
129+
static func rename(_ project: Project, to newName: String) throws -> Project {
130+
let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines)
131+
guard !trimmed.isEmpty, trimmed != project.name else { return project }
132+
let dest = project.folderURL.deletingLastPathComponent()
133+
.appendingPathComponent(trimmed, isDirectory: true)
134+
try FileManager.default.moveItem(at: project.folderURL, to: dest)
135+
try writeDescriptor(
136+
ProjectDescriptor(name: trimmed, createdAtMs: project.createdAt.timeIntervalSince1970 * 1000),
137+
to: dest
138+
)
139+
return load(from: dest)
140+
}
141+
142+
/// Copies the whole project folder as "Name copy" (then "copy 2", …).
143+
static func duplicate(_ project: Project) throws -> Project {
144+
let fm = FileManager.default
145+
let parent = project.folderURL.deletingLastPathComponent()
146+
var name = "\(project.name) copy"
147+
var dest = parent.appendingPathComponent(name, isDirectory: true)
148+
var counter = 2
149+
while fm.fileExists(atPath: dest.path) {
150+
name = "\(project.name) copy \(counter)"
151+
dest = parent.appendingPathComponent(name, isDirectory: true)
152+
counter += 1
153+
}
154+
try fm.copyItem(at: project.folderURL, to: dest)
155+
try writeDescriptor(
156+
ProjectDescriptor(name: name, createdAtMs: Date().timeIntervalSince1970 * 1000),
157+
to: dest
158+
)
159+
return load(from: dest)
160+
}
161+
125162
/// Moves the project folder to the Trash — recoverable, unlike a hard
126163
/// delete. Deliberately no `removeItem` fallback: on volumes without a
127164
/// Trash this throws, and the caller surfaces the error, rather than

Tests/CineScreenTests/ProjectsLibraryTests.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,34 @@ final class ProjectsLibraryTests: XCTestCase {
3838
XCTAssertEqual(listed.first?.name, "Real")
3939
}
4040

41+
@MainActor
42+
func testRenameMovesFolderAndPreservesCreationDate() throws {
43+
let original = try ProjectsLibrary.createNew(in: root, name: "Before")
44+
let renamed = try ProjectsLibrary.rename(original, to: "After")
45+
XCTAssertEqual(renamed.name, "After")
46+
XCTAssertFalse(FileManager.default.fileExists(atPath: original.folderURL.path))
47+
XCTAssertEqual(
48+
renamed.createdAt.timeIntervalSince1970,
49+
original.createdAt.timeIntervalSince1970,
50+
accuracy: 0.01
51+
)
52+
}
53+
54+
@MainActor
55+
func testDuplicateCopiesArtifactsAndAvoidsCollisions() throws {
56+
let original = try ProjectsLibrary.createNew(in: root, name: "Take")
57+
FileManager.default.createFile(
58+
atPath: original.fallbackVideoURL.path,
59+
contents: Data([1, 2, 3])
60+
)
61+
let copy = try ProjectsLibrary.duplicate(original)
62+
let copy2 = try ProjectsLibrary.duplicate(original)
63+
XCTAssertNotEqual(copy.folderURL, original.folderURL)
64+
XCTAssertNotEqual(copy2.folderURL, copy.folderURL)
65+
XCTAssertTrue(FileManager.default.fileExists(atPath: copy.fallbackVideoURL.path))
66+
XCTAssertEqual(ProjectsLibrary.projects(in: root).count, 3)
67+
}
68+
4169
/// Pre-descriptor recordings (video only, no project.json) stay visible.
4270
@MainActor
4371
func testListingKeepsFoldersWithVideoButNoDescriptor() throws {

0 commit comments

Comments
 (0)