diff --git a/src/DocumentBrowserWindow.swift b/src/DocumentBrowserWindow.swift index 650ae4d..246a89b 100644 --- a/src/DocumentBrowserWindow.swift +++ b/src/DocumentBrowserWindow.swift @@ -1,6 +1,32 @@ import Cocoa import SwiftUI import Combine +import UniformTypeIdentifiers + +// MARK: - Model + +enum BrowserItemKind { + case file + case folder +} + +struct BrowserItem: Identifiable, Hashable { + let id: String + let url: URL + let name: String + let kind: BrowserItemKind + let modified: Date + var preview: String + + static func == (lhs: BrowserItem, rhs: BrowserItem) -> Bool { + lhs.url == rhs.url + } + func hash(into hasher: inout Hasher) { + hasher.combine(url) + } +} + +// MARK: - Controller class DocumentBrowserController { static var shared: DocumentBrowserController? @@ -39,97 +65,179 @@ class DocumentBrowserController { } } +// MARK: - State + class BrowserState: ObservableObject { - @Published var notes: [NoteInfo] = [] + @Published var items: [BrowserItem] = [] @Published var cardScale: CGFloat = 1.0 - @Published var selectedID: UUID? + @Published var selectedURL: URL? + @Published var currentPath: URL let appState: AppState - private let bridge = RustBridge.shared - private var cancellable: AnyCancellable? + private let fm = FileManager.default + private static let supportedExtensions: Set = ["md", "txt", "markdown", "mdown"] + + var rootPath: URL { + URL(fileURLWithPath: ConfigManager.shared.autoSaveDirectory) + } + + var pathSegments: [(name: String, url: URL)] { + var segments: [(String, URL)] = [] + var path = currentPath.standardizedFileURL + let root = rootPath.standardizedFileURL + + while path != root && path.path.hasPrefix(root.path) { + segments.insert((path.lastPathComponent, path), at: 0) + path = path.deletingLastPathComponent().standardizedFileURL + } + segments.insert(("Documents", root), at: 0) + return segments + } init(appState: AppState) { self.appState = appState + self.currentPath = URL(fileURLWithPath: ConfigManager.shared.autoSaveDirectory) refresh() - - cancellable = appState.$noteList - .receive(on: RunLoop.main) - .sink { [weak self] list in - self?.notes = list - } } func refresh() { - appState.refreshNoteList() - notes = appState.noteList + items = scanDirectory(currentPath) } - 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") + func navigate(to url: URL) { + currentPath = url + selectedURL = nil + refresh() + } + + private func scanDirectory(_ dir: URL) -> [BrowserItem] { + guard let contents = try? fm.contentsOfDirectory( + at: dir, + includingPropertiesForKeys: [.contentModificationDateKey, .isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { return [] } + + var folders: [BrowserItem] = [] + var files: [BrowserItem] = [] + + for url in contents { + guard let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .contentModificationDateKey]) else { continue } + let mtime = values.contentModificationDate ?? .distantPast + + if values.isDirectory == true { + folders.append(BrowserItem( + id: url.path, + url: url, + name: url.lastPathComponent, + kind: .folder, + modified: mtime, + preview: folderSummary(url) + )) + } else { + let ext = url.pathExtension.lowercased() + guard Self.supportedExtensions.contains(ext) else { continue } + files.append(BrowserItem( + id: url.path, + url: url, + name: url.deletingPathExtension().lastPathComponent, + kind: .file, + modified: mtime, + preview: filePreview(url) + )) + } } - return "" + + folders.sort { $0.modified > $1.modified } + files.sort { $0.modified > $1.modified } + return folders + files } - func openNote(_ id: UUID) { - appState.openNote(id) + private func filePreview(_ url: URL) -> String { + guard let data = try? Data(contentsOf: url, options: .mappedIfSafe), + let text = String(data: data, encoding: .utf8) else { return "" } + let lines = text.components(separatedBy: "\n") + return lines.prefix(20).joined(separator: "\n") + } + + private func folderSummary(_ url: URL) -> String { + let contents = (try? fm.contentsOfDirectory( + at: url, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + )) ?? [] + let fileCount = contents.filter { + Self.supportedExtensions.contains($0.pathExtension.lowercased()) + }.count + let folderCount = contents.filter { + (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true + }.count + var parts: [String] = [] + if fileCount > 0 { parts.append("\(fileCount) file\(fileCount == 1 ? "" : "s")") } + if folderCount > 0 { parts.append("\(folderCount) folder\(folderCount == 1 ? "" : "s")") } + return parts.isEmpty ? "Empty" : parts.joined(separator: ", ") + } + + // MARK: - Actions + + func openFile(_ item: BrowserItem) { + guard item.kind == .file else { return } + appState.loadNoteFromFile(item.url) 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 - } + func renameItem(_ item: BrowserItem, to newName: String) { + let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + let ext = item.kind == .file ? "." + item.url.pathExtension : "" + let dest = item.url.deletingLastPathComponent().appendingPathComponent(trimmed + ext) + guard !fm.fileExists(atPath: dest.path) else { return } + try? fm.moveItem(at: item.url, to: dest) 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) + func duplicateItem(_ item: BrowserItem) { + guard item.kind == .file else { return } + let dir = item.url.deletingLastPathComponent() + let base = item.url.deletingPathExtension().lastPathComponent + let ext = item.url.pathExtension + var n = 1 + var dest: URL + repeat { + dest = dir.appendingPathComponent("\(base) \(n).\(ext)") + n += 1 + } while fm.fileExists(atPath: dest.path) + try? fm.copyItem(at: item.url, to: dest) 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) + func trashItem(_ item: BrowserItem) { + try? fm.trashItem(at: item.url, resultingItemURL: nil) + if selectedURL == item.url { selectedURL = nil } 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 - } + func revealInFinder(_ item: BrowserItem) { + NSWorkspace.shared.activateFileViewerSelecting([item.url]) + } + + func createFolder() { + var name = "New Folder" + var n = 1 + while fm.fileExists(atPath: currentPath.appendingPathComponent(name).path) { + n += 1 + name = "New Folder \(n)" } - NSWorkspace.shared.open(dirURL) + let url = currentPath.appendingPathComponent(name) + try? fm.createDirectory(at: url, withIntermediateDirectories: false) + refresh() + } + + func moveItem(_ item: BrowserItem, into folder: BrowserItem) { + guard folder.kind == .folder else { return } + let dest = folder.url.appendingPathComponent(item.url.lastPathComponent) + guard !fm.fileExists(atPath: dest.path) else { return } + try? fm.moveItem(at: item.url, to: dest) + refresh() } func scaleUp() { @@ -141,63 +249,113 @@ class BrowserState: ObservableObject { } } +// MARK: - Browser View + 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) + VStack(spacing: 0) { + BreadcrumbBar(state: state) + Divider().background(Color(ns: Theme.current.surface1)) + + ScrollView { + if state.items.isEmpty { + emptyState + } else { + LazyVGrid( + columns: [GridItem(.adaptive( + minimum: 200 * state.cardScale, + maximum: 400 * state.cardScale + ))], + spacing: 16 * state.cardScale + ) { + ForEach(state.items) { item in + BrowserCardView(item: item, state: state) + .onDrag { + NSItemProvider(object: item.url as NSURL) + } + } + } + .padding(16 * state.cardScale) + } + } + .background(Color(ns: Theme.current.base)) + .contextMenu { + Button("New Folder") { state.createFolder() } + Divider() + Button("Reveal in Finder") { + NSWorkspace.shared.open(state.currentPath) } } - .padding(16 * state.cardScale) } .background(Color(ns: Theme.current.base)) .frame(minWidth: 400, minHeight: 300) } + + private var emptyState: some View { + VStack(spacing: 8) { + Text("No documents") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(Color(ns: Theme.current.subtext0)) + Text("Create a new note or add files to this folder") + .font(.system(size: 12)) + .foregroundColor(Color(ns: Theme.current.overlay0)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.top, 100) + } } -struct DocumentCard: View { - let note: NoteInfo +// MARK: - Breadcrumb Bar + +struct BreadcrumbBar: View { + @ObservedObject var state: BrowserState + + var body: some View { + HStack(spacing: 4) { + ForEach(Array(state.pathSegments.enumerated()), id: \.offset) { index, segment in + if index > 0 { + Image(systemName: "chevron.right") + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(Color(ns: Theme.current.overlay0)) + } + Button(action: { state.navigate(to: segment.url) }) { + Text(segment.name) + .font(.system(size: 12, weight: isLast(index) ? .semibold : .regular)) + .foregroundColor(Color(ns: isLast(index) ? Theme.current.text : Theme.current.subtext0)) + } + .buttonStyle(.plain) + } + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color(ns: Theme.current.mantle)) + } + + private func isLast(_ index: Int) -> Bool { + index == state.pathSegments.count - 1 + } +} + +// MARK: - Card View + +struct BrowserCardView: View { + let item: BrowserItem @ObservedObject var state: BrowserState @State private var isRenaming = false @State private var renameText = "" + @State private var isDropTarget = false private var isSelected: Bool { - state.selectedID == note.id + state.selectedURL == item.url } 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) - } + previewArea + titleArea } .padding(10 * state.cardScale) .background( @@ -206,26 +364,114 @@ struct DocumentCard: View { ) .overlay( RoundedRectangle(cornerRadius: 8 * state.cardScale) - .stroke(isSelected ? Color(ns: Theme.current.blue) : Color.clear, lineWidth: 2) + .stroke( + isDropTarget ? Color(ns: Theme.current.green) : + isSelected ? Color(ns: Theme.current.blue) : Color.clear, + lineWidth: 2 + ) ) .contentShape(Rectangle()) .onTapGesture(count: 2) { - state.openNote(note.id) + switch item.kind { + case .folder: state.navigate(to: item.url) + case .file: state.openFile(item) + } } .onTapGesture(count: 1) { - state.selectedID = note.id + state.selectedURL = item.url } - .contextMenu { - Button("Open") { state.openNote(note.id) } + .contextMenu { contextMenuItems } + .onDrop(of: [.fileURL], isTargeted: item.kind == .folder ? $isDropTarget : .constant(false)) { providers in + guard item.kind == .folder else { return false } + for provider in providers { + provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { data, _ in + guard let urlData = data as? Data, + let sourceURL = URL(dataRepresentation: urlData, relativeTo: nil) else { return } + DispatchQueue.main.async { + let source = BrowserItem( + id: sourceURL.path, url: sourceURL, + name: sourceURL.lastPathComponent, + kind: .file, modified: .now, preview: "" + ) + state.moveItem(source, into: item) + } + } + } + return true + } + } + + @ViewBuilder + private var previewArea: some View { + if item.kind == .folder { + HStack(spacing: 8 * state.cardScale) { + Image(systemName: "folder.fill") + .font(.system(size: 28 * state.cardScale)) + .foregroundColor(Color(ns: Theme.current.blue)) + Text(item.preview) + .font(.system(size: 10 * state.cardScale)) + .foregroundColor(Color(ns: Theme.current.subtext0)) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8 * state.cardScale) + .background(Color(ns: Theme.current.mantle)) + .cornerRadius(4 * state.cardScale) + } else { + Text(item.preview) + .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) + } + } + + @ViewBuilder + private var titleArea: some View { + if isRenaming { + TextField("Name", text: $renameText, onCommit: { + state.renameItem(item, 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(item.name) + .font(.system(size: 12 * state.cardScale, weight: .semibold)) + .foregroundColor(Color(ns: Theme.current.text)) + .lineLimit(2) + .padding(.horizontal, 4) + } + } + + @ViewBuilder + private var contextMenuItems: some View { + switch item.kind { + case .file: + Button("Open") { state.openFile(item) } Button("Rename") { - renameText = note.title + renameText = item.name isRenaming = true } - Button("Duplicate") { state.duplicateNote(note.id) } + Button("Duplicate") { state.duplicateItem(item) } Divider() - Button("Move to Trash") { state.trashNote(note.id) } + Button("Move to Trash") { state.trashItem(item) } Divider() - Button("Reveal in Finder") { state.revealInFinder(note.id) } + Button("Reveal in Finder") { state.revealInFinder(item) } + case .folder: + Button("Open") { state.navigate(to: item.url) } + Button("Rename") { + renameText = item.name + isRenaming = true + } + Divider() + Button("Move to Trash") { state.trashItem(item) } + Divider() + Button("Reveal in Finder") { state.revealInFinder(item) } } } }