diff --git a/Neon Vision Editor.xcodeproj/project.pbxproj b/Neon Vision Editor.xcodeproj/project.pbxproj index df3832c..9c586ad 100644 --- a/Neon Vision Editor.xcodeproj/project.pbxproj +++ b/Neon Vision Editor.xcodeproj/project.pbxproj @@ -358,7 +358,7 @@ CODE_SIGNING_ALLOWED = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 322; + CURRENT_PROJECT_VERSION = 323; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = CS727NF72U; ENABLE_APP_SANDBOX = YES; @@ -439,7 +439,7 @@ CODE_SIGNING_ALLOWED = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 322; + CURRENT_PROJECT_VERSION = 323; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = CS727NF72U; ENABLE_APP_SANDBOX = YES; diff --git a/Neon Vision Editor/UI/ContentView+Actions.swift b/Neon Vision Editor/UI/ContentView+Actions.swift index 1be78bc..bead572 100644 --- a/Neon Vision Editor/UI/ContentView+Actions.swift +++ b/Neon Vision Editor/UI/ContentView+Actions.swift @@ -225,7 +225,13 @@ extension ContentView { } func requestCloseTab(_ tab: TabData) { - if tab.isDirty && confirmCloseDirtyTab { + #if os(iOS) + let shouldConfirmClose = tab.isDirty + #else + let shouldConfirmClose = tab.isDirty && confirmCloseDirtyTab + #endif + + if shouldConfirmClose { pendingCloseTabID = tab.id showUnsavedCloseDialog = true } else { diff --git a/Neon Vision Editor/UI/ContentView.swift b/Neon Vision Editor/UI/ContentView.swift index a9a09be..5f25e79 100644 --- a/Neon Vision Editor/UI/ContentView.swift +++ b/Neon Vision Editor/UI/ContentView.swift @@ -112,6 +112,20 @@ struct ContentView: View { let createdAt: Date } +#if os(iOS) + private struct IOSSavedDraftTab: Codable { + let name: String + let content: String + let language: String + let fileURLString: String? + } + + private struct IOSSavedDraftSnapshot: Codable { + let tabs: [IOSSavedDraftTab] + let selectedIndex: Int? + } +#endif + // Environment-provided view model and theme/error bindings @EnvironmentObject var viewModel: EditorViewModel @EnvironmentObject private var supportPurchaseManager: SupportPurchaseManager @@ -1668,7 +1682,16 @@ struct ContentView: View { } .onReceive(viewModel.$tabs) { _ in persistSessionIfReady() +#if os(iOS) + persistUnsavedDraftSnapshotIfNeeded() +#endif } +#if os(iOS) + .onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { _ in + persistSessionIfReady() + persistUnsavedDraftSnapshotIfNeeded() + } +#endif .modifier(ModalPresentationModifier(contentView: self)) .onAppear { if !didRunInitialWindowLayoutSetup { @@ -1925,6 +1948,14 @@ struct ContentView: View { private func applyStartupBehaviorIfNeeded() { guard !didApplyStartupBehavior else { return } +#if os(iOS) + if restoreUnsavedDraftSnapshotIfAvailable() { + didApplyStartupBehavior = true + persistSessionIfReady() + return + } +#endif + if viewModel.tabs.contains(where: { $0.fileURL != nil }) { didApplyStartupBehavior = true persistSessionIfReady() @@ -2010,8 +2041,78 @@ struct ContentView: View { } #if os(iOS) + private var unsavedDraftSnapshotKey: String { "IOSUnsavedDraftSnapshotV1" } private var lastSessionBookmarksKey: String { "LastSessionFileBookmarks" } private var lastSessionSelectedBookmarkKey: String { "LastSessionSelectedFileBookmark" } + private var maxPersistedDraftTabs: Int { 20 } + private var maxPersistedDraftUTF16Length: Int { 2_000_000 } + + private func persistUnsavedDraftSnapshotIfNeeded() { + let dirtyTabs = viewModel.tabs.filter(\.isDirty) + guard !dirtyTabs.isEmpty else { + UserDefaults.standard.removeObject(forKey: unsavedDraftSnapshotKey) + return + } + + var savedTabs: [IOSSavedDraftTab] = [] + savedTabs.reserveCapacity(min(dirtyTabs.count, maxPersistedDraftTabs)) + for tab in dirtyTabs.prefix(maxPersistedDraftTabs) { + let content = tab.content + let nsContent = content as NSString + let clampedContent: String + if nsContent.length > maxPersistedDraftUTF16Length { + clampedContent = nsContent.substring(to: maxPersistedDraftUTF16Length) + } else { + clampedContent = content + } + savedTabs.append( + IOSSavedDraftTab( + name: tab.name, + content: clampedContent, + language: tab.language, + fileURLString: tab.fileURL?.absoluteString + ) + ) + } + + let selectedIndex: Int? = { + guard let selectedID = viewModel.selectedTabID else { return nil } + return dirtyTabs.firstIndex(where: { $0.id == selectedID }) + }() + + let snapshot = IOSSavedDraftSnapshot(tabs: savedTabs, selectedIndex: selectedIndex) + guard let encoded = try? JSONEncoder().encode(snapshot) else { return } + UserDefaults.standard.set(encoded, forKey: unsavedDraftSnapshotKey) + } + + private func restoreUnsavedDraftSnapshotIfAvailable() -> Bool { + guard let data = UserDefaults.standard.data(forKey: unsavedDraftSnapshotKey), + let snapshot = try? JSONDecoder().decode(IOSSavedDraftSnapshot.self, from: data), + !snapshot.tabs.isEmpty else { + return false + } + + let restoredTabs = snapshot.tabs.map { saved in + TabData( + name: saved.name, + content: saved.content, + language: saved.language, + fileURL: saved.fileURLString.flatMap(URL.init(string:)), + languageLocked: true, + isDirty: true, + lastSavedFingerprint: nil + ) + } + viewModel.tabs = restoredTabs + + if let selectedIndex = snapshot.selectedIndex, + restoredTabs.indices.contains(selectedIndex) { + viewModel.selectedTabID = restoredTabs[selectedIndex].id + } else { + viewModel.selectedTabID = restoredTabs.first?.id + } + return true + } private func persistLastSessionSecurityScopedBookmarks(fileURLs: [URL], selectedURL: URL?) { let bookmarkData = fileURLs.compactMap { makeSecurityScopedBookmarkData(for: $0) }