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 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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue