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 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) }
}
}
}