rework document browser: filesystem-backed grid with folder navigation

This commit is contained in:
jess 2026-04-06 18:45:12 -07:00
parent 063063895a
commit 752f5a1595
1 changed files with 353 additions and 107 deletions

View File

@ -1,6 +1,32 @@
import Cocoa import Cocoa
import SwiftUI import SwiftUI
import Combine 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 { class DocumentBrowserController {
static var shared: DocumentBrowserController? static var shared: DocumentBrowserController?
@ -39,97 +65,179 @@ class DocumentBrowserController {
} }
} }
// MARK: - State
class BrowserState: ObservableObject { class BrowserState: ObservableObject {
@Published var notes: [NoteInfo] = [] @Published var items: [BrowserItem] = []
@Published var cardScale: CGFloat = 1.0 @Published var cardScale: CGFloat = 1.0
@Published var selectedID: UUID? @Published var selectedURL: URL?
@Published var currentPath: URL
let appState: AppState let appState: AppState
private let bridge = RustBridge.shared private let fm = FileManager.default
private var cancellable: AnyCancellable? 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) { init(appState: AppState) {
self.appState = appState self.appState = appState
self.currentPath = URL(fileURLWithPath: ConfigManager.shared.autoSaveDirectory)
refresh() refresh()
cancellable = appState.$noteList
.receive(on: RunLoop.main)
.sink { [weak self] list in
self?.notes = list
}
} }
func refresh() { func refresh() {
appState.refreshNoteList() items = scanDirectory(currentPath)
notes = appState.noteList
} }
func previewText(for id: UUID) -> String { func navigate(to url: URL) {
if bridge.cacheLoad(id) { currentPath = url
let text = bridge.getText(id) 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") let lines = text.components(separatedBy: "\n")
return lines.prefix(20).joined(separator: "\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) { // MARK: - Actions
appState.openNote(id)
func openFile(_ item: BrowserItem) {
guard item.kind == .file else { return }
appState.loadNoteFromFile(item.url)
DocumentBrowserController.shared?.window.orderOut(nil) DocumentBrowserController.shared?.window.orderOut(nil)
} }
func renameNote(_ id: UUID, to newTitle: String) { func renameItem(_ item: BrowserItem, to newName: String) {
guard bridge.cacheLoad(id) else { return } let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines)
let text = bridge.getText(id) guard !trimmed.isEmpty else { return }
let lines = text.components(separatedBy: "\n") let ext = item.kind == .file ? "." + item.url.pathExtension : ""
var rest = Array(lines.dropFirst()) let dest = item.url.deletingLastPathComponent().appendingPathComponent(trimmed + ext)
if rest.isEmpty && text.isEmpty { rest = [] } guard !fm.fileExists(atPath: dest.path) else { return }
let newText = ([newTitle] + rest).joined(separator: "\n") try? fm.moveItem(at: item.url, to: dest)
bridge.setText(id, text: newText)
let _ = bridge.cacheSave(id)
if id == appState.currentNoteID {
appState.documentText = newText
}
refresh() refresh()
} }
func duplicateNote(_ id: UUID) { func duplicateItem(_ item: BrowserItem) {
guard bridge.cacheLoad(id) else { return } guard item.kind == .file else { return }
let text = bridge.getText(id) let dir = item.url.deletingLastPathComponent()
let newID = bridge.newDocument() let base = item.url.deletingPathExtension().lastPathComponent
bridge.setText(newID, text: text) let ext = item.url.pathExtension
let _ = bridge.cacheSave(newID) 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() refresh()
} }
func trashNote(_ id: UUID) { func trashItem(_ item: BrowserItem) {
let dir = ConfigManager.shared.autoSaveDirectory try? fm.trashItem(at: item.url, resultingItemURL: nil)
let dirURL = URL(fileURLWithPath: dir) if selectedURL == item.url { selectedURL = nil }
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() refresh()
} }
func revealInFinder(_ id: UUID) { func revealInFinder(_ item: BrowserItem) {
let dir = ConfigManager.shared.autoSaveDirectory NSWorkspace.shared.activateFileViewerSelecting([item.url])
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 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() { func scaleUp() {
@ -141,63 +249,113 @@ class BrowserState: ObservableObject {
} }
} }
// MARK: - Browser View
struct DocumentBrowserView: View { struct DocumentBrowserView: View {
@ObservedObject var state: BrowserState @ObservedObject var state: BrowserState
var body: some View { var body: some View {
VStack(spacing: 0) {
BreadcrumbBar(state: state)
Divider().background(Color(ns: Theme.current.surface1))
ScrollView { ScrollView {
if state.items.isEmpty {
emptyState
} else {
LazyVGrid( 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 spacing: 16 * state.cardScale
) { ) {
ForEach(state.notes) { note in ForEach(state.items) { item in
DocumentCard(note: note, state: state) BrowserCardView(item: item, state: state)
.onDrag {
NSItemProvider(object: item.url as NSURL)
}
} }
} }
.padding(16 * state.cardScale) .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)) .background(Color(ns: Theme.current.base))
.frame(minWidth: 400, minHeight: 300) .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 { // MARK: - Breadcrumb Bar
let note: NoteInfo
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 @ObservedObject var state: BrowserState
@State private var isRenaming = false @State private var isRenaming = false
@State private var renameText = "" @State private var renameText = ""
@State private var isDropTarget = false
private var isSelected: Bool { private var isSelected: Bool {
state.selectedID == note.id state.selectedURL == item.url
} }
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 6 * state.cardScale) { VStack(alignment: .leading, spacing: 6 * state.cardScale) {
Text(state.previewText(for: note.id)) previewArea
.font(.system(size: 10 * state.cardScale, design: .monospaced)) titleArea
.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) .padding(10 * state.cardScale)
.background( .background(
@ -206,26 +364,114 @@ struct DocumentCard: View {
) )
.overlay( .overlay(
RoundedRectangle(cornerRadius: 8 * state.cardScale) 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()) .contentShape(Rectangle())
.onTapGesture(count: 2) { .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) { .onTapGesture(count: 1) {
state.selectedID = note.id state.selectedURL = item.url
} }
.contextMenu { .contextMenu { contextMenuItems }
Button("Open") { state.openNote(note.id) } .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") { Button("Rename") {
renameText = note.title renameText = item.name
isRenaming = true isRenaming = true
} }
Button("Duplicate") { state.duplicateNote(note.id) } Button("Duplicate") { state.duplicateItem(item) }
Divider() Divider()
Button("Move to Trash") { state.trashNote(note.id) } Button("Move to Trash") { state.trashItem(item) }
Divider() 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) }
} }
} }
} }