diff --git a/src/AppDelegate.swift b/src/AppDelegate.swift index 5e380f4..b81ae1c 100644 --- a/src/AppDelegate.swift +++ b/src/AppDelegate.swift @@ -47,6 +47,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { setupMenuBar() observeDocumentTitle() + DocumentBrowserController.shared = DocumentBrowserController(appState: appState) + NotificationCenter.default.addObserver( self, selector: #selector(settingsDidChange), name: .settingsChanged, object: nil @@ -192,7 +194,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { private func buildViewMenu() -> NSMenuItem { let item = NSMenuItem() let menu = NSMenu(title: "View") - let toggleItem = NSMenuItem(title: "Toggle Sidebar", action: #selector(toggleSidebar), keyEquivalent: "b") + let toggleItem = NSMenuItem(title: "Document Browser", action: #selector(toggleBrowser), keyEquivalent: "b") toggleItem.keyEquivalentModifierMask = .control toggleItem.target = self menu.addItem(toggleItem) @@ -402,16 +404,24 @@ class AppDelegate: NSObject, NSApplicationDelegate { window.contentView?.needsDisplay = true } - @objc private func toggleSidebar() { - NotificationCenter.default.post(name: .toggleSidebar, object: nil) + @objc private func toggleBrowser() { + DocumentBrowserController.shared?.toggle() } @objc private func zoomIn() { + if let browser = DocumentBrowserController.shared, browser.window.isKeyWindow { + browser.browserState.scaleUp() + return + } ConfigManager.shared.zoomLevel += 1 NotificationCenter.default.post(name: .settingsChanged, object: nil) } @objc private func zoomOut() { + if let browser = DocumentBrowserController.shared, browser.window.isKeyWindow { + browser.browserState.scaleDown() + return + } let current = ConfigManager.shared.zoomLevel if 11 + current > 8 { ConfigManager.shared.zoomLevel -= 1 diff --git a/src/ContentView.swift b/src/ContentView.swift index c8e57ca..14be47a 100644 --- a/src/ContentView.swift +++ b/src/ContentView.swift @@ -2,32 +2,21 @@ import SwiftUI struct ContentView: View { @ObservedObject var state: AppState - @State private var sidebarVisible: Bool = false @State private var themeVersion: Int = 0 var body: some View { let _ = themeVersion - HSplitView { - if sidebarVisible { - SidebarView(state: state) - .frame(minWidth: 180, idealWidth: 250, maxWidth: 350) + EditorView(state: state) + .frame(minWidth: 400) + .frame(minWidth: 700, minHeight: 400) + .background(Color(ns: Theme.current.base)) + .onReceive(NotificationCenter.default.publisher(for: .settingsChanged)) { _ in + themeVersion += 1 } - EditorView(state: state) - .frame(minWidth: 400) - } - .frame(minWidth: 700, minHeight: 400) - .background(Color(ns: Theme.current.base)) - .onReceive(NotificationCenter.default.publisher(for: .toggleSidebar)) { _ in - withAnimation { sidebarVisible.toggle() } - } - .onReceive(NotificationCenter.default.publisher(for: .settingsChanged)) { _ in - themeVersion += 1 - } } } extension Notification.Name { - static let toggleSidebar = Notification.Name("toggleSidebar") static let focusEditor = Notification.Name("focusEditor") static let focusTitle = Notification.Name("focusTitle") static let formatDocument = Notification.Name("formatDocument") diff --git a/src/DocumentBrowserWindow.swift b/src/DocumentBrowserWindow.swift new file mode 100644 index 0000000..650ae4d --- /dev/null +++ b/src/DocumentBrowserWindow.swift @@ -0,0 +1,231 @@ +import Cocoa +import SwiftUI +import Combine + +class DocumentBrowserController { + static var shared: DocumentBrowserController? + + let window: NSWindow + let browserState: BrowserState + private let hostingView: NSHostingView + + init(appState: AppState) { + browserState = BrowserState(appState: appState) + + let view = DocumentBrowserView(state: browserState) + hostingView = NSHostingView(rootView: view) + + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.title = "Documents" + window.backgroundColor = Theme.current.base + window.contentView = hostingView + window.setFrameAutosaveName("SwiftlyBrowser") + window.center() + window.isReleasedWhenClosed = false + } + + func toggle() { + if window.isVisible { + window.orderOut(nil) + } else { + browserState.refresh() + window.makeKeyAndOrderFront(nil) + } + } +} + +class BrowserState: ObservableObject { + @Published var notes: [NoteInfo] = [] + @Published var cardScale: CGFloat = 1.0 + @Published var selectedID: UUID? + + let appState: AppState + private let bridge = RustBridge.shared + private var cancellable: AnyCancellable? + + init(appState: AppState) { + self.appState = appState + refresh() + + cancellable = appState.$noteList + .receive(on: RunLoop.main) + .sink { [weak self] list in + self?.notes = list + } + } + + func refresh() { + appState.refreshNoteList() + notes = appState.noteList + } + + func previewText(for id: UUID) -> String { + if bridge.cacheLoad(id) { + let text = bridge.getText(id) + let lines = text.components(separatedBy: "\n") + return lines.prefix(20).joined(separator: "\n") + } + return "" + } + + func openNote(_ id: UUID) { + appState.openNote(id) + DocumentBrowserController.shared?.window.orderOut(nil) + } + + func renameNote(_ id: UUID, to newTitle: String) { + guard bridge.cacheLoad(id) else { return } + let text = bridge.getText(id) + let lines = text.components(separatedBy: "\n") + var rest = Array(lines.dropFirst()) + if rest.isEmpty && text.isEmpty { rest = [] } + let newText = ([newTitle] + rest).joined(separator: "\n") + bridge.setText(id, text: newText) + let _ = bridge.cacheSave(id) + if id == appState.currentNoteID { + appState.documentText = newText + } + refresh() + } + + func duplicateNote(_ id: UUID) { + guard bridge.cacheLoad(id) else { return } + let text = bridge.getText(id) + let newID = bridge.newDocument() + bridge.setText(newID, text: text) + let _ = bridge.cacheSave(newID) + refresh() + } + + func trashNote(_ id: UUID) { + let dir = ConfigManager.shared.autoSaveDirectory + let dirURL = URL(fileURLWithPath: dir) + let note = notes.first { $0.id == id } + if let note = note { + let filename = note.title.isEmpty ? id.uuidString.lowercased() : note.title + let fileURL = dirURL.appendingPathComponent(filename + ".md") + if FileManager.default.fileExists(atPath: fileURL.path) { + try? FileManager.default.trashItem(at: fileURL, resultingItemURL: nil) + } + } + appState.deleteNote(id) + refresh() + } + + func revealInFinder(_ id: UUID) { + let dir = ConfigManager.shared.autoSaveDirectory + let dirURL = URL(fileURLWithPath: dir) + let note = notes.first { $0.id == id } + if let note = note { + let filename = note.title.isEmpty ? id.uuidString.lowercased() : note.title + let fileURL = dirURL.appendingPathComponent(filename + ".md") + if FileManager.default.fileExists(atPath: fileURL.path) { + NSWorkspace.shared.activateFileViewerSelecting([fileURL]) + return + } + } + NSWorkspace.shared.open(dirURL) + } + + func scaleUp() { + cardScale = min(cardScale + 0.1, 3.0) + } + + func scaleDown() { + cardScale = max(cardScale - 0.1, 0.4) + } +} + +struct DocumentBrowserView: View { + @ObservedObject var state: BrowserState + + var body: some View { + ScrollView { + LazyVGrid( + columns: [GridItem(.adaptive(minimum: 200 * state.cardScale, maximum: 400 * state.cardScale))], + spacing: 16 * state.cardScale + ) { + ForEach(state.notes) { note in + DocumentCard(note: note, state: state) + } + } + .padding(16 * state.cardScale) + } + .background(Color(ns: Theme.current.base)) + .frame(minWidth: 400, minHeight: 300) + } +} + +struct DocumentCard: View { + let note: NoteInfo + @ObservedObject var state: BrowserState + @State private var isRenaming = false + @State private var renameText = "" + + private var isSelected: Bool { + state.selectedID == note.id + } + + var body: some View { + VStack(alignment: .leading, spacing: 6 * state.cardScale) { + Text(state.previewText(for: note.id)) + .font(.system(size: 10 * state.cardScale, design: .monospaced)) + .foregroundColor(Color(ns: Theme.current.subtext0)) + .lineLimit(nil) + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(8 * state.cardScale) + .background(Color(ns: Theme.current.mantle)) + .cornerRadius(4 * state.cardScale) + + if isRenaming { + TextField("Title", text: $renameText, onCommit: { + state.renameNote(note.id, to: renameText) + isRenaming = false + }) + .textFieldStyle(.plain) + .font(.system(size: 12 * state.cardScale, weight: .semibold)) + .foregroundColor(Color(ns: Theme.current.text)) + .padding(.horizontal, 4) + } else { + Text(note.title) + .font(.system(size: 12 * state.cardScale, weight: .semibold)) + .foregroundColor(Color(ns: Theme.current.text)) + .lineLimit(2) + .padding(.horizontal, 4) + } + } + .padding(10 * state.cardScale) + .background( + RoundedRectangle(cornerRadius: 8 * state.cardScale) + .fill(Color(ns: isSelected ? Theme.current.surface1 : Theme.current.surface0)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8 * state.cardScale) + .stroke(isSelected ? Color(ns: Theme.current.blue) : Color.clear, lineWidth: 2) + ) + .contentShape(Rectangle()) + .onTapGesture(count: 2) { + state.openNote(note.id) + } + .onTapGesture(count: 1) { + state.selectedID = note.id + } + .contextMenu { + Button("Open") { state.openNote(note.id) } + Button("Rename") { + renameText = note.title + isRenaming = true + } + Button("Duplicate") { state.duplicateNote(note.id) } + Divider() + Button("Move to Trash") { state.trashNote(note.id) } + Divider() + Button("Reveal in Finder") { state.revealInFinder(note.id) } + } + } +} diff --git a/src/SidebarView.swift b/src/SidebarView.swift index 4a83d61..e6c6d46 100644 --- a/src/SidebarView.swift +++ b/src/SidebarView.swift @@ -1,183 +1 @@ import SwiftUI - -struct SidebarView: View { - @ObservedObject var state: AppState - @State private var lastClickedID: UUID? - @State private var previewNote: NoteInfo? - @FocusState private var sidebarFocused: Bool - - private let dateFormatter: DateFormatter = { - let f = DateFormatter() - f.dateStyle = .short - f.timeStyle = .short - return f - }() - - var body: some View { - VStack(spacing: 0) { - HStack { - Text("Notes") - .font(.headline) - .foregroundColor(Color(ns: Theme.current.text)) - Spacer() - Button(action: { state.newNote() }) { - Image(systemName: "plus") - .foregroundColor(Color(ns: Theme.current.text)) - } - .buttonStyle(.plain) - .help("New Note") - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - - Divider() - - if state.noteList.isEmpty { - VStack { - Spacer() - Text("No notes yet") - .foregroundColor(Color(ns: Theme.current.overlay1)) - .font(Font(Theme.sidebarFont as CTFont)) - Spacer() - } - } else { - ScrollView { - LazyVStack(spacing: 0) { - ForEach(Array(state.noteList.enumerated()), id: \.element.id) { index, note in - NoteRow( - note: note, - isSelected: state.selectedNoteIDs.contains(note.id), - isActive: note.id == state.currentNoteID, - dateFormatter: dateFormatter - ) - .contentShape(Rectangle()) - .onTapGesture(count: 1) { - handleClick(note: note, index: index) - } - .contextMenu { - if state.selectedNoteIDs.count > 1 && state.selectedNoteIDs.contains(note.id) { - Button("Delete \(state.selectedNoteIDs.count) Notes") { - state.deleteNotes(state.selectedNoteIDs) - lastClickedID = nil - } - } else { - Button("Delete") { state.deleteNote(note.id) } - } - } - } - } - } - .focusable() - .focused($sidebarFocused) - .onDeleteCommand { - guard !state.selectedNoteIDs.isEmpty else { return } - state.deleteNotes(state.selectedNoteIDs) - lastClickedID = nil - } - .onKeyPress(.space) { - guard let id = state.selectedNoteIDs.first, - state.selectedNoteIDs.count == 1, - let note = state.noteList.first(where: { $0.id == id }) else { - return .ignored - } - if previewNote?.id == note.id { - previewNote = nil - } else { - previewNote = note - } - return .handled - } - .popover(item: $previewNote, arrowEdge: .trailing) { note in - NotePreviewView(note: note, state: state) - } - } - } - .background(Color(ns: Theme.current.base)) - .onAppear { state.refreshNoteList() } - } - - private func handleClick(note: NoteInfo, index: Int) { - sidebarFocused = true - let flags = NSEvent.modifierFlags - if flags.contains(.command) { - state.selectNote(note.id, extend: true) - lastClickedID = note.id - } else if flags.contains(.shift), let lastID = lastClickedID { - if let lastIndex = state.noteList.firstIndex(where: { $0.id == lastID }) { - let range = min(lastIndex, index)...max(lastIndex, index) - var ids = Set() - for i in range { - ids.insert(state.noteList[i].id) - } - state.selectedNoteIDs = ids - } - } else { - state.selectNote(note.id) - state.openNote(note.id) - lastClickedID = note.id - } - } -} - -struct NotePreviewView: View { - let note: NoteInfo - @ObservedObject var state: AppState - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text(note.title) - .font(.headline) - .foregroundColor(Color(ns: Theme.current.text)) - Divider() - ScrollView { - Text(previewText) - .font(.body) - .foregroundColor(Color(ns: Theme.current.subtext0)) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - .padding() - .frame(width: 320, height: 240) - .background(Color(ns: Theme.current.base)) - } - - private var previewText: String { - let bridge = RustBridge.shared - if bridge.cacheLoad(note.id) { - let text = bridge.getText(note.id) - return String(text.prefix(2000)) - } - return "(unable to load preview)" - } -} - -struct NoteRow: View { - let note: NoteInfo - let isSelected: Bool - var isActive: Bool = false - let dateFormatter: DateFormatter - - var body: some View { - VStack(alignment: .leading, spacing: 2) { - Text(note.title) - .font(Font(isActive - ? NSFontManager.shared.convert(Theme.sidebarFont, toHaveTrait: .boldFontMask) as CTFont - : Theme.sidebarFont as CTFont)) - .foregroundColor(Color(ns: Theme.current.text)) - .lineLimit(1) - Text(dateFormatter.string(from: note.lastModified)) - .font(Font(Theme.sidebarDateFont as CTFont)) - .foregroundColor(Color(ns: Theme.current.subtext0)) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, 4) - .padding(.horizontal, 12) - .background( - isActive - ? Color(ns: Theme.current.surface1) - : isSelected - ? Color(ns: Theme.current.surface0) - : Color.clear - ) - } -}