diff --git a/macOS/Synapse/CloneRepositoryValidation.swift b/macOS/Synapse/CloneRepositoryValidation.swift new file mode 100644 index 0000000..30f4b59 --- /dev/null +++ b/macOS/Synapse/CloneRepositoryValidation.swift @@ -0,0 +1,8 @@ +import Foundation + +/// Validation rules for the clone-repository sheet (folder picker welcome screen). +enum CloneRepositoryValidation { + static func canClone(remoteURL: String, destinationURL: URL?) -> Bool { + !remoteURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && destinationURL != nil + } +} diff --git a/macOS/Synapse/FolderPickerView.swift b/macOS/Synapse/FolderPickerView.swift index abd9182..1c988e9 100644 --- a/macOS/Synapse/FolderPickerView.swift +++ b/macOS/Synapse/FolderPickerView.swift @@ -172,7 +172,7 @@ private struct CloneRepositorySheet: View { } private var canClone: Bool { - !remoteURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && destinationURL != nil + CloneRepositoryValidation.canClone(remoteURL: remoteURL, destinationURL: destinationURL) } private func pickDestination() { diff --git a/macOS/Synapse/MiniBrowserPaneView.swift b/macOS/Synapse/MiniBrowserPaneView.swift index da5ff8a..d6bb57e 100644 --- a/macOS/Synapse/MiniBrowserPaneView.swift +++ b/macOS/Synapse/MiniBrowserPaneView.swift @@ -25,17 +25,8 @@ final class MiniBrowserController: NSObject, ObservableObject, WKNavigationDeleg } func load(_ input: String) { - let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - - let normalized: String - if trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://") { - normalized = trimmed - } else { - normalized = "https://\(trimmed)" - } - - guard let url = URL(string: normalized) else { return } + guard let normalized = MiniBrowserURLNormalizer.normalizedURLString(from: input), + let url = URL(string: normalized) else { return } urlText = normalized webView.load(URLRequest(url: url)) } diff --git a/macOS/Synapse/MiniBrowserURLNormalizer.swift b/macOS/Synapse/MiniBrowserURLNormalizer.swift new file mode 100644 index 0000000..eb0c3f3 --- /dev/null +++ b/macOS/Synapse/MiniBrowserURLNormalizer.swift @@ -0,0 +1,20 @@ +import Foundation + +/// Normalizes user-entered browser input into a URL string suitable for `WKWebView.load`. +enum MiniBrowserURLNormalizer { + /// Returns the normalized URL string, or `nil` if input is empty or not a valid URL after normalization. + static func normalizedURLString(from input: String) -> String? { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let normalized: String + if trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://") { + normalized = trimmed + } else { + normalized = "https://\(trimmed)" + } + + guard URL(string: normalized) != nil else { return nil } + return normalized + } +} diff --git a/macOS/Synapse/SynapseApp.swift b/macOS/Synapse/SynapseApp.swift index 9a5624c..249cb91 100644 --- a/macOS/Synapse/SynapseApp.swift +++ b/macOS/Synapse/SynapseApp.swift @@ -53,11 +53,11 @@ class SynapseAppDelegate: NSObject, NSApplicationDelegate { var isDirectory: ObjCBool = false if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) && isDirectory.boolValue { // Check if this is a vault root or a subfolder - let vaultRoot = findVaultRoot(for: url) + let vaultRoot = VaultRootResolver.vaultRoot(for: url) appState.openFolder(vaultRoot) } else { // It's a file - find the vault root and open the file within it - let vaultRoot = findVaultRoot(for: url) + let vaultRoot = VaultRootResolver.vaultRoot(for: url) appState.openFolder(vaultRoot) // After opening the vault, open the specific file DispatchQueue.main.async { @@ -68,44 +68,6 @@ class SynapseAppDelegate: NSObject, NSApplicationDelegate { sender.reply(toOpenOrPrint: .success) } - - /// Find the vault root for a given URL - /// - If the URL is a file, walks up to find the vault root (directory containing .synapse folder) - /// - If the URL is a directory, checks if it's the vault root or walks up to find it - private func findVaultRoot(for url: URL) -> URL { - let fileManager = FileManager.default - - // Start from the file's directory if it's a file - var currentDir = url - var isDirectory: ObjCBool = false - if fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) && !isDirectory.boolValue { - currentDir = url.deletingLastPathComponent() - } - - // Walk up the directory tree looking for .synapse folder - while currentDir.path != "/" && currentDir.path != "/Users" { - let synapseDir = currentDir.appendingPathComponent(".synapse", isDirectory: true) - if fileManager.fileExists(atPath: synapseDir.path) { - return currentDir - } - - let parentDir = currentDir.deletingLastPathComponent() - // Stop if we can't go up anymore - if parentDir.path == currentDir.path { - break - } - currentDir = parentDir - } - - // If no .synapse folder found, return the original directory - // This handles legacy vaults without .synapse folder - var originalIsDirectory: ObjCBool = false - if fileManager.fileExists(atPath: url.path, isDirectory: &originalIsDirectory) && originalIsDirectory.boolValue { - return url - } else { - return url.deletingLastPathComponent() - } - } } @main diff --git a/macOS/Synapse/UpdateBannerCopy.swift b/macOS/Synapse/UpdateBannerCopy.swift new file mode 100644 index 0000000..6fdcd2b --- /dev/null +++ b/macOS/Synapse/UpdateBannerCopy.swift @@ -0,0 +1,25 @@ +import Foundation + +/// User-visible strings for the in-app update banner (kept separate from SwiftUI for unit testing). +enum UpdateBannerCopy { + static func iconName(downloadProgress: Double?, restartRequired: Bool) -> String { + if restartRequired { return "checkmark.circle.fill" } + if downloadProgress != nil { return "arrow.down.circle.fill" } + return "arrow.down.circle.fill" + } + + static func title(version: String, downloadProgress: Double?, restartRequired: Bool) -> String { + if restartRequired { return "Synapse v\(version) installed" } + if let progress = downloadProgress { + let pct = Int(progress * 100) + return "Downloading v\(version)… \(pct)%" + } + return "Update available: v\(version)" + } + + static func subtitle(downloadProgress: Double?, restartRequired: Bool) -> String { + if restartRequired { return "Restart to finish updating" } + if downloadProgress != nil { return "" } + return "Click Install to update automatically" + } +} diff --git a/macOS/Synapse/UpdateBannerView.swift b/macOS/Synapse/UpdateBannerView.swift index e4b25ae..5660cf9 100644 --- a/macOS/Synapse/UpdateBannerView.swift +++ b/macOS/Synapse/UpdateBannerView.swift @@ -71,23 +71,15 @@ struct UpdateBannerView: View { } private var iconName: String { - if restartRequired { return "checkmark.circle.fill" } - if downloadProgress != nil { return "arrow.down.circle.fill" } - return "arrow.down.circle.fill" + UpdateBannerCopy.iconName(downloadProgress: downloadProgress, restartRequired: restartRequired) } private var titleText: String { - if restartRequired { return "Synapse v\(version) installed" } - if downloadProgress != nil { - let pct = Int((downloadProgress ?? 0) * 100) - return "Downloading v\(version)… \(pct)%" - } - return "Update available: v\(version)" + UpdateBannerCopy.title(version: version, downloadProgress: downloadProgress, restartRequired: restartRequired) } private var subtitleText: String { - if restartRequired { return "Restart to finish updating" } - return "Click Install to update automatically" + UpdateBannerCopy.subtitle(downloadProgress: downloadProgress, restartRequired: restartRequired) } } diff --git a/macOS/Synapse/VaultRootResolver.swift b/macOS/Synapse/VaultRootResolver.swift new file mode 100644 index 0000000..2a70862 --- /dev/null +++ b/macOS/Synapse/VaultRootResolver.swift @@ -0,0 +1,32 @@ +import Foundation + +/// Resolves which folder should be opened as the vault root when the user opens a file or directory from Finder. +enum VaultRootResolver { + /// If the URL is a file, start from its parent. Walk up until a `.synapse` directory is found, or fall back to legacy behavior. + static func vaultRoot(for url: URL, fileManager: FileManager = .default) -> URL { + var currentDir = url + var isDirectory: ObjCBool = false + if fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory), !isDirectory.boolValue { + currentDir = url.deletingLastPathComponent() + } + + while currentDir.path != "/" && currentDir.path != "/Users" { + let synapseDir = currentDir.appendingPathComponent(".synapse", isDirectory: true) + if fileManager.fileExists(atPath: synapseDir.path) { + return currentDir + } + + let parentDir = currentDir.deletingLastPathComponent() + if parentDir.path == currentDir.path { + break + } + currentDir = parentDir + } + + var originalIsDirectory: ObjCBool = false + if fileManager.fileExists(atPath: url.path, isDirectory: &originalIsDirectory), originalIsDirectory.boolValue { + return url + } + return url.deletingLastPathComponent() + } +} diff --git a/macOS/SynapseTests/CloneRepositoryValidationTests.swift b/macOS/SynapseTests/CloneRepositoryValidationTests.swift new file mode 100644 index 0000000..c671621 --- /dev/null +++ b/macOS/SynapseTests/CloneRepositoryValidationTests.swift @@ -0,0 +1,24 @@ +import XCTest +@testable import Synapse + +/// Tests when the Clone button is enabled on the welcome / folder picker sheet. +final class CloneRepositoryValidationTests: XCTestCase { + + func test_canClone_requiresNonEmptyRemoteAndDestination() { + let dest = URL(fileURLWithPath: "/tmp/clone-here") + XCTAssertTrue(CloneRepositoryValidation.canClone(remoteURL: "https://github.com/a/b.git", destinationURL: dest)) + } + + func test_canClone_falseWhenRemoteWhitespaceOnly() { + let dest = URL(fileURLWithPath: "/tmp/clone-here") + XCTAssertFalse(CloneRepositoryValidation.canClone(remoteURL: " \n", destinationURL: dest)) + } + + func test_canClone_falseWhenNoDestination() { + XCTAssertFalse(CloneRepositoryValidation.canClone(remoteURL: "https://github.com/a/b.git", destinationURL: nil)) + } + + func test_canClone_falseWhenBothMissing() { + XCTAssertFalse(CloneRepositoryValidation.canClone(remoteURL: "", destinationURL: nil)) + } +} diff --git a/macOS/SynapseTests/FileSearchResultTests.swift b/macOS/SynapseTests/FileSearchResultTests.swift new file mode 100644 index 0000000..f20f5dd --- /dev/null +++ b/macOS/SynapseTests/FileSearchResultTests.swift @@ -0,0 +1,28 @@ +import XCTest +@testable import Synapse + +/// Tests the all-files search result row model (used by command palette / search UI). +final class FileSearchResultTests: XCTestCase { + + func test_identifiable_idsAreUniquePerInstance() { + let a = FileSearchResult( + url: URL(fileURLWithPath: "/tmp/a.md"), + snippet: "one", + lineNumber: 1 + ) + let b = FileSearchResult( + url: URL(fileURLWithPath: "/tmp/b.md"), + snippet: "two", + lineNumber: 2 + ) + XCTAssertNotEqual(a.id, b.id) + } + + func test_storesFields() { + let url = URL(fileURLWithPath: "/vault/notes/x.md") + let row = FileSearchResult(url: url, snippet: "…match…", lineNumber: 42) + XCTAssertEqual(row.url, url) + XCTAssertEqual(row.snippet, "…match…") + XCTAssertEqual(row.lineNumber, 42) + } +} diff --git a/macOS/SynapseTests/MiniBrowserURLNormalizerTests.swift b/macOS/SynapseTests/MiniBrowserURLNormalizerTests.swift new file mode 100644 index 0000000..7d895a5 --- /dev/null +++ b/macOS/SynapseTests/MiniBrowserURLNormalizerTests.swift @@ -0,0 +1,39 @@ +import XCTest +@testable import Synapse + +/// Tests URL normalization for the in-app browser address bar. +final class MiniBrowserURLNormalizerTests: XCTestCase { + + func test_empty_returnsNil() { + XCTAssertNil(MiniBrowserURLNormalizer.normalizedURLString(from: "")) + XCTAssertNil(MiniBrowserURLNormalizer.normalizedURLString(from: " \n\t ")) + } + + func test_bareHost_prependsHttps() { + XCTAssertEqual( + MiniBrowserURLNormalizer.normalizedURLString(from: "example.com"), + "https://example.com" + ) + } + + func test_https_preserved() { + XCTAssertEqual( + MiniBrowserURLNormalizer.normalizedURLString(from: "https://dep.github.io/synapse"), + "https://dep.github.io/synapse" + ) + } + + func test_http_preserved() { + XCTAssertEqual( + MiniBrowserURLNormalizer.normalizedURLString(from: "http://localhost:8080"), + "http://localhost:8080" + ) + } + + func test_whitespaceTrimmed() { + XCTAssertEqual( + MiniBrowserURLNormalizer.normalizedURLString(from: " dep.dev "), + "https://dep.dev" + ) + } +} diff --git a/macOS/SynapseTests/UpdateBannerCopyTests.swift b/macOS/SynapseTests/UpdateBannerCopyTests.swift new file mode 100644 index 0000000..b9f2f1a --- /dev/null +++ b/macOS/SynapseTests/UpdateBannerCopyTests.swift @@ -0,0 +1,55 @@ +import XCTest +@testable import Synapse + +/// Tests user-visible update banner strings (auto-update UX). +final class UpdateBannerCopyTests: XCTestCase { + + func test_icon_restartUsesCheckmark() { + XCTAssertEqual( + UpdateBannerCopy.iconName(downloadProgress: nil, restartRequired: true), + "checkmark.circle.fill" + ) + } + + func test_icon_downloadingUsesArrow() { + XCTAssertEqual( + UpdateBannerCopy.iconName(downloadProgress: 0.5, restartRequired: false), + "arrow.down.circle.fill" + ) + } + + func test_title_updateAvailable() { + XCTAssertEqual( + UpdateBannerCopy.title(version: "2.0.0", downloadProgress: nil, restartRequired: false), + "Update available: v2.0.0" + ) + } + + func test_title_downloadingIncludesPercent() { + XCTAssertEqual( + UpdateBannerCopy.title(version: "2.0.0", downloadProgress: 0.42, restartRequired: false), + "Downloading v2.0.0… 42%" + ) + } + + func test_title_installed() { + XCTAssertEqual( + UpdateBannerCopy.title(version: "2.0.0", downloadProgress: nil, restartRequired: true), + "Synapse v2.0.0 installed" + ) + } + + func test_subtitle_installPrompt() { + XCTAssertEqual( + UpdateBannerCopy.subtitle(downloadProgress: nil, restartRequired: false), + "Click Install to update automatically" + ) + } + + func test_subtitle_restartPrompt() { + XCTAssertEqual( + UpdateBannerCopy.subtitle(downloadProgress: nil, restartRequired: true), + "Restart to finish updating" + ) + } +} diff --git a/macOS/SynapseTests/VaultRootResolverTests.swift b/macOS/SynapseTests/VaultRootResolverTests.swift new file mode 100644 index 0000000..64d949d --- /dev/null +++ b/macOS/SynapseTests/VaultRootResolverTests.swift @@ -0,0 +1,65 @@ +import XCTest +@testable import Synapse + +/// Tests vault root resolution when opening files or folders from Finder (critical for correct workspace scope). +final class VaultRootResolverTests: XCTestCase { + + var tempRoot: URL! + + override func setUp() { + super.setUp() + tempRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("VaultRootResolverTests-\(UUID().uuidString)", isDirectory: true) + try! FileManager.default.createDirectory(at: tempRoot, withIntermediateDirectories: true) + } + + override func tearDown() { + try? FileManager.default.removeItem(at: tempRoot) + super.tearDown() + } + + func test_fileInsideNestedVault_returnsAncestorWithSynapseMarker() { + let vault = tempRoot.appendingPathComponent("myvault", isDirectory: true) + let notes = vault.appendingPathComponent("notes", isDirectory: true) + try! FileManager.default.createDirectory(at: notes, withIntermediateDirectories: true) + try! FileManager.default.createDirectory( + at: vault.appendingPathComponent(".synapse", isDirectory: true), + withIntermediateDirectories: true + ) + let fileURL = notes.appendingPathComponent("hello.md") + try! "x".write(to: fileURL, atomically: true, encoding: .utf8) + + let root = VaultRootResolver.vaultRoot(for: fileURL) + XCTAssertEqual(root.standardizedFileURL.path, vault.standardizedFileURL.path) + } + + func test_directoryThatIsVaultRoot_returnsSelf() { + let vault = tempRoot.appendingPathComponent("rootvault", isDirectory: true) + try! FileManager.default.createDirectory(at: vault, withIntermediateDirectories: true) + try! FileManager.default.createDirectory( + at: vault.appendingPathComponent(".synapse", isDirectory: true), + withIntermediateDirectories: true + ) + + let root = VaultRootResolver.vaultRoot(for: vault) + XCTAssertEqual(root.standardizedFileURL.path, vault.standardizedFileURL.path) + } + + func test_legacyVault_fileWithoutSynapseMarker_returnsFileParent() { + let folder = tempRoot.appendingPathComponent("legacy", isDirectory: true) + try! FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + let fileURL = folder.appendingPathComponent("note.md") + try! "x".write(to: fileURL, atomically: true, encoding: .utf8) + + let root = VaultRootResolver.vaultRoot(for: fileURL) + XCTAssertEqual(root.standardizedFileURL.path, folder.standardizedFileURL.path) + } + + func test_legacyVault_plainDirectory_returnsSelf() { + let folder = tempRoot.appendingPathComponent("plain", isDirectory: true) + try! FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + + let root = VaultRootResolver.vaultRoot(for: folder) + XCTAssertEqual(root.standardizedFileURL.path, folder.standardizedFileURL.path) + } +}