replace sidebar with separate document browser window
This commit is contained in:
parent
db1a0aaefa
commit
1ccea45a6f
|
|
@ -47,6 +47,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
setupMenuBar()
|
||||
observeDocumentTitle()
|
||||
|
||||
DocumentBrowserController.shared = DocumentBrowserController(appState: appState)
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(settingsDidChange),
|
||||
name: .settingsChanged, object: nil
|
||||
|
|
@ -192,7 +194,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
private func buildViewMenu() -> NSMenuItem {
|
||||
let item = NSMenuItem()
|
||||
let menu = NSMenu(title: "View")
|
||||
let toggleItem = NSMenuItem(title: "Toggle Sidebar", action: #selector(toggleSidebar), keyEquivalent: "b")
|
||||
let toggleItem = NSMenuItem(title: "Document Browser", action: #selector(toggleBrowser), keyEquivalent: "b")
|
||||
toggleItem.keyEquivalentModifierMask = .control
|
||||
toggleItem.target = self
|
||||
menu.addItem(toggleItem)
|
||||
|
|
@ -402,16 +404,24 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
window.contentView?.needsDisplay = true
|
||||
}
|
||||
|
||||
@objc private func toggleSidebar() {
|
||||
NotificationCenter.default.post(name: .toggleSidebar, object: nil)
|
||||
@objc private func toggleBrowser() {
|
||||
DocumentBrowserController.shared?.toggle()
|
||||
}
|
||||
|
||||
@objc private func zoomIn() {
|
||||
if let browser = DocumentBrowserController.shared, browser.window.isKeyWindow {
|
||||
browser.browserState.scaleUp()
|
||||
return
|
||||
}
|
||||
ConfigManager.shared.zoomLevel += 1
|
||||
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
||||
}
|
||||
|
||||
@objc private func zoomOut() {
|
||||
if let browser = DocumentBrowserController.shared, browser.window.isKeyWindow {
|
||||
browser.browserState.scaleDown()
|
||||
return
|
||||
}
|
||||
let current = ConfigManager.shared.zoomLevel
|
||||
if 11 + current > 8 {
|
||||
ConfigManager.shared.zoomLevel -= 1
|
||||
|
|
|
|||
|
|
@ -2,24 +2,14 @@ import SwiftUI
|
|||
|
||||
struct ContentView: View {
|
||||
@ObservedObject var state: AppState
|
||||
@State private var sidebarVisible: Bool = false
|
||||
@State private var themeVersion: Int = 0
|
||||
|
||||
var body: some View {
|
||||
let _ = themeVersion
|
||||
HSplitView {
|
||||
if sidebarVisible {
|
||||
SidebarView(state: state)
|
||||
.frame(minWidth: 180, idealWidth: 250, maxWidth: 350)
|
||||
}
|
||||
EditorView(state: state)
|
||||
.frame(minWidth: 400)
|
||||
}
|
||||
.frame(minWidth: 700, minHeight: 400)
|
||||
.background(Color(ns: Theme.current.base))
|
||||
.onReceive(NotificationCenter.default.publisher(for: .toggleSidebar)) { _ in
|
||||
withAnimation { sidebarVisible.toggle() }
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .settingsChanged)) { _ in
|
||||
themeVersion += 1
|
||||
}
|
||||
|
|
@ -27,7 +17,6 @@ struct ContentView: View {
|
|||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let toggleSidebar = Notification.Name("toggleSidebar")
|
||||
static let focusEditor = Notification.Name("focusEditor")
|
||||
static let focusTitle = Notification.Name("focusTitle")
|
||||
static let formatDocument = Notification.Name("formatDocument")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,231 @@
|
|||
import Cocoa
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
class DocumentBrowserController {
|
||||
static var shared: DocumentBrowserController?
|
||||
|
||||
let window: NSWindow
|
||||
let browserState: BrowserState
|
||||
private let hostingView: NSHostingView<DocumentBrowserView>
|
||||
|
||||
init(appState: AppState) {
|
||||
browserState = BrowserState(appState: appState)
|
||||
|
||||
let view = DocumentBrowserView(state: browserState)
|
||||
hostingView = NSHostingView(rootView: view)
|
||||
|
||||
window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 800, height: 600),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.title = "Documents"
|
||||
window.backgroundColor = Theme.current.base
|
||||
window.contentView = hostingView
|
||||
window.setFrameAutosaveName("SwiftlyBrowser")
|
||||
window.center()
|
||||
window.isReleasedWhenClosed = false
|
||||
}
|
||||
|
||||
func toggle() {
|
||||
if window.isVisible {
|
||||
window.orderOut(nil)
|
||||
} else {
|
||||
browserState.refresh()
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class BrowserState: ObservableObject {
|
||||
@Published var notes: [NoteInfo] = []
|
||||
@Published var cardScale: CGFloat = 1.0
|
||||
@Published var selectedID: UUID?
|
||||
|
||||
let appState: AppState
|
||||
private let bridge = RustBridge.shared
|
||||
private var cancellable: AnyCancellable?
|
||||
|
||||
init(appState: AppState) {
|
||||
self.appState = appState
|
||||
refresh()
|
||||
|
||||
cancellable = appState.$noteList
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] list in
|
||||
self?.notes = list
|
||||
}
|
||||
}
|
||||
|
||||
func refresh() {
|
||||
appState.refreshNoteList()
|
||||
notes = appState.noteList
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func openNote(_ id: UUID) {
|
||||
appState.openNote(id)
|
||||
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
|
||||
}
|
||||
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)
|
||||
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)
|
||||
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
|
||||
}
|
||||
}
|
||||
NSWorkspace.shared.open(dirURL)
|
||||
}
|
||||
|
||||
func scaleUp() {
|
||||
cardScale = min(cardScale + 0.1, 3.0)
|
||||
}
|
||||
|
||||
func scaleDown() {
|
||||
cardScale = max(cardScale - 0.1, 0.4)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
.padding(16 * state.cardScale)
|
||||
}
|
||||
.background(Color(ns: Theme.current.base))
|
||||
.frame(minWidth: 400, minHeight: 300)
|
||||
}
|
||||
}
|
||||
|
||||
struct DocumentCard: View {
|
||||
let note: NoteInfo
|
||||
@ObservedObject var state: BrowserState
|
||||
@State private var isRenaming = false
|
||||
@State private var renameText = ""
|
||||
|
||||
private var isSelected: Bool {
|
||||
state.selectedID == note.id
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
.padding(10 * state.cardScale)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8 * state.cardScale)
|
||||
.fill(Color(ns: isSelected ? Theme.current.surface1 : Theme.current.surface0))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8 * state.cardScale)
|
||||
.stroke(isSelected ? Color(ns: Theme.current.blue) : Color.clear, lineWidth: 2)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(count: 2) {
|
||||
state.openNote(note.id)
|
||||
}
|
||||
.onTapGesture(count: 1) {
|
||||
state.selectedID = note.id
|
||||
}
|
||||
.contextMenu {
|
||||
Button("Open") { state.openNote(note.id) }
|
||||
Button("Rename") {
|
||||
renameText = note.title
|
||||
isRenaming = true
|
||||
}
|
||||
Button("Duplicate") { state.duplicateNote(note.id) }
|
||||
Divider()
|
||||
Button("Move to Trash") { state.trashNote(note.id) }
|
||||
Divider()
|
||||
Button("Reveal in Finder") { state.revealInFinder(note.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,183 +1 @@
|
|||
import SwiftUI
|
||||
|
||||
struct SidebarView: View {
|
||||
@ObservedObject var state: AppState
|
||||
@State private var lastClickedID: UUID?
|
||||
@State private var previewNote: NoteInfo?
|
||||
@FocusState private var sidebarFocused: Bool
|
||||
|
||||
private let dateFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateStyle = .short
|
||||
f.timeStyle = .short
|
||||
return f
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text("Notes")
|
||||
.font(.headline)
|
||||
.foregroundColor(Color(ns: Theme.current.text))
|
||||
Spacer()
|
||||
Button(action: { state.newNote() }) {
|
||||
Image(systemName: "plus")
|
||||
.foregroundColor(Color(ns: Theme.current.text))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("New Note")
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
Divider()
|
||||
|
||||
if state.noteList.isEmpty {
|
||||
VStack {
|
||||
Spacer()
|
||||
Text("No notes yet")
|
||||
.foregroundColor(Color(ns: Theme.current.overlay1))
|
||||
.font(Font(Theme.sidebarFont as CTFont))
|
||||
Spacer()
|
||||
}
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(Array(state.noteList.enumerated()), id: \.element.id) { index, note in
|
||||
NoteRow(
|
||||
note: note,
|
||||
isSelected: state.selectedNoteIDs.contains(note.id),
|
||||
isActive: note.id == state.currentNoteID,
|
||||
dateFormatter: dateFormatter
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(count: 1) {
|
||||
handleClick(note: note, index: index)
|
||||
}
|
||||
.contextMenu {
|
||||
if state.selectedNoteIDs.count > 1 && state.selectedNoteIDs.contains(note.id) {
|
||||
Button("Delete \(state.selectedNoteIDs.count) Notes") {
|
||||
state.deleteNotes(state.selectedNoteIDs)
|
||||
lastClickedID = nil
|
||||
}
|
||||
} else {
|
||||
Button("Delete") { state.deleteNote(note.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.focusable()
|
||||
.focused($sidebarFocused)
|
||||
.onDeleteCommand {
|
||||
guard !state.selectedNoteIDs.isEmpty else { return }
|
||||
state.deleteNotes(state.selectedNoteIDs)
|
||||
lastClickedID = nil
|
||||
}
|
||||
.onKeyPress(.space) {
|
||||
guard let id = state.selectedNoteIDs.first,
|
||||
state.selectedNoteIDs.count == 1,
|
||||
let note = state.noteList.first(where: { $0.id == id }) else {
|
||||
return .ignored
|
||||
}
|
||||
if previewNote?.id == note.id {
|
||||
previewNote = nil
|
||||
} else {
|
||||
previewNote = note
|
||||
}
|
||||
return .handled
|
||||
}
|
||||
.popover(item: $previewNote, arrowEdge: .trailing) { note in
|
||||
NotePreviewView(note: note, state: state)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color(ns: Theme.current.base))
|
||||
.onAppear { state.refreshNoteList() }
|
||||
}
|
||||
|
||||
private func handleClick(note: NoteInfo, index: Int) {
|
||||
sidebarFocused = true
|
||||
let flags = NSEvent.modifierFlags
|
||||
if flags.contains(.command) {
|
||||
state.selectNote(note.id, extend: true)
|
||||
lastClickedID = note.id
|
||||
} else if flags.contains(.shift), let lastID = lastClickedID {
|
||||
if let lastIndex = state.noteList.firstIndex(where: { $0.id == lastID }) {
|
||||
let range = min(lastIndex, index)...max(lastIndex, index)
|
||||
var ids = Set<UUID>()
|
||||
for i in range {
|
||||
ids.insert(state.noteList[i].id)
|
||||
}
|
||||
state.selectedNoteIDs = ids
|
||||
}
|
||||
} else {
|
||||
state.selectNote(note.id)
|
||||
state.openNote(note.id)
|
||||
lastClickedID = note.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NotePreviewView: View {
|
||||
let note: NoteInfo
|
||||
@ObservedObject var state: AppState
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(note.title)
|
||||
.font(.headline)
|
||||
.foregroundColor(Color(ns: Theme.current.text))
|
||||
Divider()
|
||||
ScrollView {
|
||||
Text(previewText)
|
||||
.font(.body)
|
||||
.foregroundColor(Color(ns: Theme.current.subtext0))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(width: 320, height: 240)
|
||||
.background(Color(ns: Theme.current.base))
|
||||
}
|
||||
|
||||
private var previewText: String {
|
||||
let bridge = RustBridge.shared
|
||||
if bridge.cacheLoad(note.id) {
|
||||
let text = bridge.getText(note.id)
|
||||
return String(text.prefix(2000))
|
||||
}
|
||||
return "(unable to load preview)"
|
||||
}
|
||||
}
|
||||
|
||||
struct NoteRow: View {
|
||||
let note: NoteInfo
|
||||
let isSelected: Bool
|
||||
var isActive: Bool = false
|
||||
let dateFormatter: DateFormatter
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(note.title)
|
||||
.font(Font(isActive
|
||||
? NSFontManager.shared.convert(Theme.sidebarFont, toHaveTrait: .boldFontMask) as CTFont
|
||||
: Theme.sidebarFont as CTFont))
|
||||
.foregroundColor(Color(ns: Theme.current.text))
|
||||
.lineLimit(1)
|
||||
Text(dateFormatter.string(from: note.lastModified))
|
||||
.font(Font(Theme.sidebarDateFont as CTFont))
|
||||
.foregroundColor(Color(ns: Theme.current.subtext0))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, 12)
|
||||
.background(
|
||||
isActive
|
||||
? Color(ns: Theme.current.surface1)
|
||||
: isSelected
|
||||
? Color(ns: Theme.current.surface0)
|
||||
: Color.clear
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue