rework document browser: filesystem-backed grid with folder navigation
This commit is contained in:
parent
063063895a
commit
752f5a1595
|
|
@ -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<String> = ["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)
|
||||
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)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
folders.sort { $0.modified > $1.modified }
|
||||
files.sort { $0.modified > $1.modified }
|
||||
return folders + files
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
return ""
|
||||
|
||||
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: ", ")
|
||||
}
|
||||
|
||||
func openNote(_ id: UUID) {
|
||||
appState.openNote(id)
|
||||
// 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 {
|
||||
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))],
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
.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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue