import SwiftUI import AppKit private struct NoteCollection: Identifiable, Hashable { enum Kind: Hashable { case notes case archive case recentlyDeleted } let id: UUID let name: String let kind: Kind } struct Note: Identifiable, Equatable, Hashable, Codable { let id: UUID var title: String var body: String var updatedAt: Date var isArchived: Bool var isDeleted: Bool var deletedAt: Date? } struct NotesAppRootView: View { fileprivate enum EditorFocusField: Hashable { case title case body } @State private var searchText: String = "" private static let defaultCollectionID: UUID = UUID(uuidString: "2E6A2D4E-0B3D-4E8A-A5B7-8D9E0C9E2A01")! private static let archiveCollectionID: UUID = UUID(uuidString: "2E6A2D4E-0B3D-4E8A-A5B7-8D9E0C9E2A03")! private static let recentlyDeletedCollectionID: UUID = UUID(uuidString: "2E6A2D4E-0B3D-4E8A-A5B7-8D9E0C9E2A04")! @State private var selectedCollectionID: UUID? = NotesAppRootView.defaultCollectionID @State private var selectedNoteID: UUID? = nil @State private var editorFocus: EditorFocusField? = nil @State private var showPermanentDeleteConfirmation = false @StateObject private var store = NotesStore() private var collections: [NoteCollection] = [ NoteCollection(id: NotesAppRootView.defaultCollectionID, name: "Notes", kind: .notes), NoteCollection(id: NotesAppRootView.archiveCollectionID, name: "Archive", kind: .archive), NoteCollection(id: NotesAppRootView.recentlyDeletedCollectionID, name: "Recently Deleted", kind: .recentlyDeleted) ] private var selectedCollection: NoteCollection? { collections.first { $0.id == selectedCollectionID } } private var filteredNotes: [Note] { guard let selectedCollection else { return [] } let scoped: [Note] switch selectedCollection.kind { case .notes: scoped = store.notes.filter { !$0.isDeleted && !$0.isArchived } case .archive: scoped = store.notes.filter { !$0.isDeleted && $0.isArchived } case .recentlyDeleted: scoped = store.notes.filter { $0.isDeleted } } let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if trimmed.isEmpty { return scoped } return scoped.filter { $0.title.lowercased().contains(trimmed) || $0.body.lowercased().contains(trimmed) } } private func addNote() { let now = Date() let newNote = Note( id: UUID(), title: "New Note", body: "", updatedAt: now, isArchived: false, isDeleted: false, deletedAt: nil ) selectedCollectionID = NotesAppRootView.defaultCollectionID store.notes.insert(newNote, at: 0) selectedNoteID = newNote.id editorFocus = .title } private var canDeleteSelectedNote: Bool { guard let selectedNoteID else { return false } return store.notes.contains { $0.id == selectedNoteID } } private var canMoveToRecentlyDeleted: Bool { guard let selectedCollection else { return false } guard let selectedNoteID else { return false } guard let note = store.notes.first(where: { $0.id == selectedNoteID }) else { return false } if note.isDeleted { return false } return selectedCollection.kind == .notes || selectedCollection.kind == .archive } private func moveSelectedNoteToRecentlyDeleted() { guard let selectedNoteID else { return } guard let index = store.notes.firstIndex(where: { $0.id == selectedNoteID }) else { return } store.notes[index].isDeleted = true store.notes[index].deletedAt = Date() // Stay on the current sidebar section (e.g. Notes); clear selection since the note left this list. self.selectedNoteID = nil editorFocus = nil } private var canPermanentlyDeleteSelectedNote: Bool { guard let selectedCollection else { return false } guard let selectedNoteID else { return false } guard let note = store.notes.first(where: { $0.id == selectedNoteID }) else { return false } return selectedCollection.kind == .recentlyDeleted && note.isDeleted } private func permanentlyDeleteSelectedNote() { guard let selectedNoteID else { return } store.notes.removeAll { $0.id == selectedNoteID } self.selectedNoteID = nil editorFocus = nil } private var canRestoreSelectedNote: Bool { guard let selectedCollection else { return false } guard let selectedNoteID else { return false } guard let note = store.notes.first(where: { $0.id == selectedNoteID }) else { return false } return selectedCollection.kind == .recentlyDeleted && note.isDeleted } private func restoreSelectedNote() { guard let selectedNoteID else { return } guard let index = store.notes.firstIndex(where: { $0.id == selectedNoteID }) else { return } store.notes[index].isDeleted = false store.notes[index].deletedAt = nil if store.notes[index].isArchived { selectedCollectionID = NotesAppRootView.archiveCollectionID } else { selectedCollectionID = NotesAppRootView.defaultCollectionID } self.selectedNoteID = store.notes[index].id } private var canArchiveSelectedNote: Bool { guard let selectedNoteID else { return false } guard let note = store.notes.first(where: { $0.id == selectedNoteID }) else { return false } guard let selectedCollection else { return false } if note.isDeleted { return false } return selectedCollection.kind == .notes && !note.isArchived } private func archiveSelectedNote() { guard let selectedNoteID else { return } guard let index = store.notes.firstIndex(where: { $0.id == selectedNoteID }) else { return } store.notes[index].isArchived = true // Stay on Notes; clear selection since archived notes no longer appear in this list. self.selectedNoteID = nil editorFocus = nil } private var canUnarchiveSelectedNote: Bool { guard let selectedNoteID else { return false } guard let note = store.notes.first(where: { $0.id == selectedNoteID }) else { return false } guard let selectedCollection else { return false } if note.isDeleted { return false } return selectedCollection.kind == .archive && note.isArchived } private func unarchiveSelectedNote() { guard let selectedNoteID else { return } guard let index = store.notes.firstIndex(where: { $0.id == selectedNoteID }) else { return } store.notes[index].isArchived = false selectedCollectionID = NotesAppRootView.defaultCollectionID self.selectedNoteID = store.notes[index].id editorFocus = nil } var body: some View { NavigationSplitView { SidebarView( collections: collections, notes: store.notes, selectedCollectionID: $selectedCollectionID ) } content: { VStack(spacing: 0) { HStack(spacing: 10) { SearchBar(text: $searchText) Button(action: addNote) { Image(systemName: "square.and.pencil") .font(.system(size: 14, weight: .semibold)) } .buttonStyle(.borderless) .keyboardShortcut("n", modifiers: [.command]) .help("New Note") Button(action: moveSelectedNoteToRecentlyDeleted) { Image(systemName: "trash") .font(.system(size: 14, weight: .semibold)) } .buttonStyle(.borderless) .keyboardShortcut(.delete, modifiers: [.command]) .help("Delete Note") .disabled(!canMoveToRecentlyDeleted) Button(action: restoreSelectedNote) { Image(systemName: "arrow.uturn.left") .font(.system(size: 14, weight: .semibold)) } .buttonStyle(.borderless) .keyboardShortcut("z", modifiers: [.command, .shift]) .help("Restore Note") .disabled(!canRestoreSelectedNote) Button(action: { showPermanentDeleteConfirmation = true }) { Image(systemName: "trash.slash") .font(.system(size: 14, weight: .semibold)) } .buttonStyle(.borderless) .keyboardShortcut(.delete, modifiers: [.command, .option]) .help("Delete Permanently") .disabled(!canPermanentlyDeleteSelectedNote) Button(action: archiveSelectedNote) { Image(systemName: "archivebox") .font(.system(size: 14, weight: .semibold)) } .buttonStyle(.borderless) .keyboardShortcut("a", modifiers: [.command, .shift]) .help("Archive Note") .disabled(!canArchiveSelectedNote) Button(action: unarchiveSelectedNote) { Image(systemName: "tray.and.arrow.up") .font(.system(size: 14, weight: .semibold)) } .buttonStyle(.borderless) .keyboardShortcut("u", modifiers: [.command, .shift]) .help("Unarchive Note") .disabled(!canUnarchiveSelectedNote) .padding(.trailing, 12) } Group { if filteredNotes.isEmpty { EmptyListView(text: "No Notes") } else { NotesListView( notes: filteredNotes, selectedNoteID: $selectedNoteID ) } } .frame(maxWidth: .infinity, maxHeight: .infinity) } .background(Color(nsColor: .textBackgroundColor)) } detail: { NoteEditorView( notes: $store.notes, selectedNoteID: selectedNoteID, focusField: $editorFocus ) .background(Color(nsColor: .textBackgroundColor)) } .preferredColorScheme(.dark) .onAppear { store.purgeExpiredRecentlyDeletedIfNeeded() } .alert("Delete permanently?", isPresented: $showPermanentDeleteConfirmation) { Button("Cancel", role: .cancel) {} Button("Delete Permanently", role: .destructive) { permanentlyDeleteSelectedNote() } } message: { Text("This note will be permanently removed from this device. Notes in Recently Deleted are also removed automatically after 30 days. You cannot undo this action.") } } } private struct SidebarView: View { let collections: [NoteCollection] let notes: [Note] @Binding var selectedCollectionID: UUID? private func notesCount(for collection: NoteCollection) -> Int { switch collection.kind { case .notes: return notes.filter { !$0.isDeleted && !$0.isArchived }.count case .archive: return notes.filter { !$0.isDeleted && $0.isArchived }.count case .recentlyDeleted: return notes.filter { $0.isDeleted }.count } } var body: some View { List(selection: $selectedCollectionID) { Section(header: Text("Keep Notebook")) { ForEach(collections) { collection in HStack(spacing: 8) { Text(collection.name) .foregroundStyle(.primary) Spacer(minLength: 8) Text("\(notesCount(for: collection))") .foregroundStyle(.secondary) .font(.system(size: 12, weight: .semibold)) } .tag(collection.id) } } } .listStyle(.sidebar) .frame(minWidth: 210) } } private struct SearchBar: View { @Binding var text: String var body: some View { HStack(spacing: 8) { Image(systemName: "magnifyingglass") .foregroundStyle(.secondary) TextField("Search", text: $text) .textFieldStyle(.plain) .foregroundStyle(.primary) } .padding(10) .background(Color(nsColor: .windowBackgroundColor)) .overlay( RoundedRectangle(cornerRadius: 10) .stroke(Color(nsColor: .separatorColor).opacity(0.35), lineWidth: 1) ) .padding(12) } } private struct EmptyListView: View { let text: String var body: some View { VStack { Spacer() Text(text) .foregroundStyle(.secondary) .font(.system(size: 16, weight: .semibold)) Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) } } private struct NotesListView: View { let notes: [Note] @Binding var selectedNoteID: UUID? var body: some View { List(selection: $selectedNoteID) { ForEach(notes) { note in HStack(spacing: 8) { Text(note.title) .lineLimit(1) Spacer(minLength: 8) } .tag(note.id) } } .listStyle(.inset) } } private struct NoteEditorView: View { @Binding var notes: [Note] let selectedNoteID: UUID? @Binding var focusField: NotesAppRootView.EditorFocusField? @FocusState private var focusedField: NotesAppRootView.EditorFocusField? var body: some View { VStack(spacing: 10) { if let selectedNoteID, let index = notes.firstIndex(where: { $0.id == selectedNoteID }) { TextField("Title", text: $notes[index].title) .textFieldStyle(.plain) .font(.system(size: 22, weight: .semibold)) .padding(.horizontal, 14) .focused($focusedField, equals: .title) Divider() .padding(.horizontal, 14) TextEditor(text: $notes[index].body) .font(.system(size: 14)) .foregroundStyle(.primary) .padding(14) .background(Color(nsColor: .textBackgroundColor)) .focused($focusedField, equals: .body) } else { // Keep the editor empty to match the screenshot's "nothing selected" state. Spacer() } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .onChange(of: focusField) { _, newValue in guard let newValue else { return } focusedField = newValue focusField = nil } } }