replace sidebar with separate document browser window

This commit is contained in:
jess 2026-04-06 17:06:56 -07:00
parent db1a0aaefa
commit 1ccea45a6f
4 changed files with 250 additions and 202 deletions

View File

@ -47,6 +47,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
setupMenuBar() setupMenuBar()
observeDocumentTitle() observeDocumentTitle()
DocumentBrowserController.shared = DocumentBrowserController(appState: appState)
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
self, selector: #selector(settingsDidChange), self, selector: #selector(settingsDidChange),
name: .settingsChanged, object: nil name: .settingsChanged, object: nil
@ -192,7 +194,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
private func buildViewMenu() -> NSMenuItem { private func buildViewMenu() -> NSMenuItem {
let item = NSMenuItem() let item = NSMenuItem()
let menu = NSMenu(title: "View") 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.keyEquivalentModifierMask = .control
toggleItem.target = self toggleItem.target = self
menu.addItem(toggleItem) menu.addItem(toggleItem)
@ -402,16 +404,24 @@ class AppDelegate: NSObject, NSApplicationDelegate {
window.contentView?.needsDisplay = true window.contentView?.needsDisplay = true
} }
@objc private func toggleSidebar() { @objc private func toggleBrowser() {
NotificationCenter.default.post(name: .toggleSidebar, object: nil) DocumentBrowserController.shared?.toggle()
} }
@objc private func zoomIn() { @objc private func zoomIn() {
if let browser = DocumentBrowserController.shared, browser.window.isKeyWindow {
browser.browserState.scaleUp()
return
}
ConfigManager.shared.zoomLevel += 1 ConfigManager.shared.zoomLevel += 1
NotificationCenter.default.post(name: .settingsChanged, object: nil) NotificationCenter.default.post(name: .settingsChanged, object: nil)
} }
@objc private func zoomOut() { @objc private func zoomOut() {
if let browser = DocumentBrowserController.shared, browser.window.isKeyWindow {
browser.browserState.scaleDown()
return
}
let current = ConfigManager.shared.zoomLevel let current = ConfigManager.shared.zoomLevel
if 11 + current > 8 { if 11 + current > 8 {
ConfigManager.shared.zoomLevel -= 1 ConfigManager.shared.zoomLevel -= 1

View File

@ -2,32 +2,21 @@ import SwiftUI
struct ContentView: View { struct ContentView: View {
@ObservedObject var state: AppState @ObservedObject var state: AppState
@State private var sidebarVisible: Bool = false
@State private var themeVersion: Int = 0 @State private var themeVersion: Int = 0
var body: some View { var body: some View {
let _ = themeVersion let _ = themeVersion
HSplitView { EditorView(state: state)
if sidebarVisible { .frame(minWidth: 400)
SidebarView(state: state) .frame(minWidth: 700, minHeight: 400)
.frame(minWidth: 180, idealWidth: 250, maxWidth: 350) .background(Color(ns: Theme.current.base))
.onReceive(NotificationCenter.default.publisher(for: .settingsChanged)) { _ in
themeVersion += 1
} }
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
}
} }
} }
extension Notification.Name { extension Notification.Name {
static let toggleSidebar = Notification.Name("toggleSidebar")
static let focusEditor = Notification.Name("focusEditor") static let focusEditor = Notification.Name("focusEditor")
static let focusTitle = Notification.Name("focusTitle") static let focusTitle = Notification.Name("focusTitle")
static let formatDocument = Notification.Name("formatDocument") static let formatDocument = Notification.Name("formatDocument")

View File

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

View File

@ -1,183 +1 @@
import SwiftUI 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
)
}
}