Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions macOS/Synapse.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

232 changes: 232 additions & 0 deletions macOS/Synapse/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ class AppState: ObservableObject {
@Published var canGoForward: Bool = false
@Published var isCommandPalettePresented: Bool = false
@Published var isNewNotePromptRequested: Bool = false
@Published var isNewFolderPromptRequested: Bool = false
@Published var pendingTemplateURL: URL? = nil
@Published var pendingCursorPosition: Int? = nil
@Published var pendingCursorRange: NSRange? = nil
Expand Down Expand Up @@ -501,6 +502,32 @@ class AppState: ObservableObject {
return settings.pinnedItems.contains { $0.isTag && $0.name == tagName && $0.matchesVaultPath(root.path) }
}

// MARK: - Folder Appearance

/// Returns the current appearance for a folder, if one has been set.
func folderAppearance(for url: URL) -> FolderAppearance? {
let rel = relativePath(for: url)
return settings.folderAppearances.first { $0.relativePath == rel }
}

/// Saves (creates or replaces) a folder appearance.
func setFolderAppearance(_ appearance: FolderAppearance, for url: URL) {
let rel = relativePath(for: url)
var appearances = settings.folderAppearances
if let idx = appearances.firstIndex(where: { $0.relativePath == rel }) {
appearances[idx] = appearance
} else {
appearances.append(appearance)
}
settings.folderAppearances = appearances
}

/// Removes any custom appearance for a folder, reverting it to defaults.
func clearFolderAppearance(for url: URL) {
let rel = relativePath(for: url)
settings.folderAppearances.removeAll { $0.relativePath == rel }
}

@objc private func handleAppTermination() {
persistDirtyFileIfNeeded()
// State file is NOT removed on quit - it's needed for "Previously open notes" restoration
Expand Down Expand Up @@ -3330,4 +3357,209 @@ class AppState: ObservableObject {
guard let stateURL = stateFileURL else { return }
try? FileManager.default.removeItem(at: stateURL)
}

// MARK: - Flat Folder Navigator (Issue #200)

/// The current directory being displayed in the flat folder navigator.
/// Defaults to rootURL when not explicitly set.
@Published var flatNavigatorCurrentDirectory: URL?

/// Navigation path tracking for the flat folder navigator.
/// Contains the history of directories navigated through (for breadcrumb support).
private var flatNavigatorPathStack: [URL] = []

/// Tracks whether the back button is currently being hovered during a drag operation.
@Published var flatNavigatorBackButtonIsDragHovering: Bool = false

/// Timer for delayed navigation when hovering over back button during drag.
private var flatNavigatorBackButtonDragTimer: Timer?

/// Returns the navigation path as an array of URLs from root to current.
var flatNavigatorPath: [URL] {
guard let root = rootURL else { return [] }
guard let current = flatNavigatorCurrentDirectory else { return [root] }

var path: [URL] = [root]
let rootPath = root.standardizedFileURL.path
let currentPath = current.standardizedFileURL.path

// Build path from root to current
if currentPath.hasPrefix(rootPath) && current != root {
var components = currentPath.dropFirst(rootPath.count)
.split(separator: "/")
.map(String.init)

var buildingPath = root
for component in components {
buildingPath = buildingPath.appendingPathComponent(component, isDirectory: true)
path.append(buildingPath)
}
}

return path
}

/// Returns the display name of the current directory.
var flatNavigatorCurrentDirectoryName: String {
guard let current = flatNavigatorCurrentDirectory else {
return rootURL?.lastPathComponent ?? "Library"
}
return current.lastPathComponent
}

/// Returns the contents of the current directory for flat navigator display.
var flatNavigatorCurrentContents: [URL] {
guard let directory = flatNavigatorCurrentDirectory ?? rootURL else { return [] }

let fm = FileManager.default
guard let contents = try? fm.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: [.isDirectoryKey, .contentModificationDateKey],
options: []
) else { return [] }

// Filter and sort: folders first, then files, alphabetically within each group
var items: [(url: URL, isDirectory: Bool, name: String)] = []

for url in contents {
let isDir = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
let name = url.lastPathComponent

// Skip hidden files and .git
if name.hasPrefix(".") { continue }
if isDir && name == ".git" { continue }
if !isDir && name.hasPrefix(".") { continue }

// Apply settings filters
if !settings.shouldShowFile(url) && !isDir { continue }
if isDir && settings.shouldHideItem(named: name) { continue }

items.append((url, isDir, name))
}

// Sort based on the current sort criterion and direction
items.sort(by: { (a, b) -> Bool in
// Always keep directories before files regardless of sort criterion
if a.isDirectory != b.isDirectory {
return a.isDirectory // Directories first
}

// Both items are same type (both dirs or both files), apply selected sort
let comparison: ComparisonResult
switch sortCriterion {
case .name:
comparison = a.name.localizedCaseInsensitiveCompare(b.name)
case .modified:
let date1 = (try? a.url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
let date2 = (try? b.url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
comparison = date1.compare(date2)
}

// Apply ascending/descending
return sortAscending ? (comparison == .orderedAscending) : (comparison == .orderedDescending)
})

return items.map { $0.url }
}

/// Returns true if the user can navigate back (i.e., not at root).
var canNavigateBackInFlatNavigator: Bool {
guard let root = rootURL else { return false }
guard let current = flatNavigatorCurrentDirectory else { return false }
return current.standardizedFileURL != root.standardizedFileURL
}

/// Navigate into a folder in the flat navigator.
func navigateToFolder(_ folder: URL) {
guard let root = rootURL else { return }

// Validate the folder is within the vault
let rootPath = root.standardizedFileURL.path
let folderPath = folder.standardizedFileURL.path
guard folderPath.hasPrefix(rootPath) else { return }

// Verify it's actually a directory
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: folder.path, isDirectory: &isDirectory),
isDirectory.boolValue else { return }

flatNavigatorCurrentDirectory = folder
flatNavigatorPathStack.append(folder)
}

/// Navigate back up one level in the flat navigator.
func navigateBackInFlatNavigator() {
guard let root = rootURL else { return }
let current = flatNavigatorCurrentDirectory ?? root

// If at root, do nothing
if current.standardizedFileURL == root.standardizedFileURL {
// Ensure flatNavigatorCurrentDirectory is set to root
flatNavigatorCurrentDirectory = root
return
}

// Navigate to parent
let parent = current.deletingLastPathComponent()
flatNavigatorCurrentDirectory = parent

// Pop from path stack if applicable
if !flatNavigatorPathStack.isEmpty {
flatNavigatorPathStack.removeLast()
}
}

/// Navigate directly to the root directory.
func navigateToRootInFlatNavigator() {
flatNavigatorCurrentDirectory = rootURL
flatNavigatorPathStack.removeAll()
}

/// Called when drag hover starts over the back button.
func flatNavigatorBackButtonDragHoverStarted() {
flatNavigatorBackButtonIsDragHovering = true

// Schedule navigation up after a delay (same pattern as folder auto-expand)
flatNavigatorBackButtonDragTimer?.invalidate()
let timer = Timer(timeInterval: 0.6, repeats: false) { [weak self] _ in
DispatchQueue.main.async {
self?.navigateBackInFlatNavigator()
}
}
RunLoop.main.add(timer, forMode: .common)
flatNavigatorBackButtonDragTimer = timer
}

/// Called when drag hover ends over the back button.
func flatNavigatorBackButtonDragHoverEnded() {
flatNavigatorBackButtonIsDragHovering = false
flatNavigatorBackButtonDragTimer?.invalidate()
flatNavigatorBackButtonDragTimer = nil
}

/// Drop a file onto a pinned item (folder only).
func dropFile(_ fileURL: URL, ontoPinnedItem pinnedItem: PinnedItem) throws -> URL {
// Validate source file exists
guard FileManager.default.fileExists(atPath: fileURL.path) else {
throw FileBrowserError.operationFailed("Source file does not exist")
}

// Validate target is a folder
guard pinnedItem.isFolder else {
throw FileBrowserError.operationFailed("Target is not a folder")
}

guard let targetURL = pinnedItem.url else {
throw FileBrowserError.operationFailed("Target folder not found")
}

var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: targetURL.path, isDirectory: &isDirectory),
isDirectory.boolValue else {
throw FileBrowserError.operationFailed("Target is not a folder")
}

// Use existing moveFile functionality
return try moveFile(at: fileURL, toFolder: targetURL)
}
}
2 changes: 1 addition & 1 deletion macOS/Synapse/AppTheme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ extension AppTheme {
"text.secondary": "#9DA7B3",
"text.muted": "#7D8590",
"accent": "#2F81F7",
"accent.soft": "#1F6FEB",
"accent.soft": "#0D4A9E", // Darker blue for selected row background
"border": "#30363D",
"divider": "#21262D",
"row": "#161B22",
Expand Down
3 changes: 2 additions & 1 deletion macOS/Synapse/CalendarPaneView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,8 @@ private struct DayCell: View {

private var textColor: Color {
if isToday {
return SynapseTheme.accent
// Use white for today to ensure contrast against the accent badge
return Color.white
}
if !isInCurrentMonth {
return SynapseTheme.textMuted.opacity(0.5)
Expand Down
13 changes: 12 additions & 1 deletion macOS/Synapse/EditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1773,9 +1773,20 @@ extension LinkAwareTextView {
}
if let frontmatter = semanticStyles.frontmatter {
if NSIntersectionRange(frontmatter.contentRange, scopeRange).length > 0 {
// Use a static/fixed line height for frontmatter that doesn't change with user settings
let frontmatterFont = NSFont.systemFont(ofSize: 11)
let naturalLineHeight = frontmatterFont.ascender - frontmatterFont.descender + frontmatterFont.leading
let staticLineHeightMultiple: CGFloat = 1.2
let desiredLineHeight = naturalLineHeight * staticLineHeightMultiple
let extraSpacing = max(0, desiredLineHeight - naturalLineHeight)
let frontmatterParagraphStyle = NSMutableParagraphStyle()
frontmatterParagraphStyle.minimumLineHeight = naturalLineHeight
frontmatterParagraphStyle.maximumLineHeight = naturalLineHeight
frontmatterParagraphStyle.lineSpacing = extraSpacing
storage.addAttributes([
.font: NSFont.systemFont(ofSize: 11),
.font: frontmatterFont,
.foregroundColor: SynapseTheme.editorMuted,
.paragraphStyle: frontmatterParagraphStyle,
], range: frontmatter.contentRange)
}
let openingFence = NSRange(location: frontmatter.range.location, length: min(3, frontmatter.range.length))
Expand Down
Loading
Loading