From 9d62453c339c3c9998fedc3b0dbdb9a4ce4f643e Mon Sep 17 00:00:00 2001 From: dep Date: Wed, 10 Jun 2026 07:53:15 -0400 Subject: [PATCH 01/11] =?UTF-8?q?fix(tests):=20make=20the=20real=20Keychai?= =?UTF-8?q?nStore=20inert=20under=20XCTest=20=E2=80=94=20no=20more=20passw?= =?UTF-8?q?ord=20prompts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix routed tests through InMemorySecretStore, but the app itself is the TEST_HOST: the Settings scene constructs SettingsView eagerly, whose @State initializer calls KeychainStore().get(). From the ad-hoc-signed test runner that SecItem access prompts for the login password on every test run. KeychainStore now short-circuits when XCTestConfigurationFilePath is set: get returns nil, set/delete are no-ops. A new test locks in the behavior. Co-Authored-By: Claude Fable 5 --- macOS/Synapse Notes.xcodeproj/project.pbxproj | 4 ++++ macOS/SynapseNotes/KeychainStore.swift | 11 +++++++++++ macOS/SynapseNotesTests/KeychainStoreTests.swift | 11 +++++++++++ 3 files changed, 26 insertions(+) diff --git a/macOS/Synapse Notes.xcodeproj/project.pbxproj b/macOS/Synapse Notes.xcodeproj/project.pbxproj index 746316e..45326f3 100644 --- a/macOS/Synapse Notes.xcodeproj/project.pbxproj +++ b/macOS/Synapse Notes.xcodeproj/project.pbxproj @@ -216,6 +216,7 @@ D6194D121C3A5687CAADABEE /* AppStatePinnedFolderFocusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B55AA96B17F979E4F856BA0 /* AppStatePinnedFolderFocusTests.swift */; }; D7ACCD7E652E70804D59FDEE /* SidebarAutoCollapseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABEC1DA743EC4078432666BE /* SidebarAutoCollapseTests.swift */; }; D9EE54B593BFC4CAED83B6E4 /* EditorFontStylingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3B6B8AE43A5A53AC28EC7C0 /* EditorFontStylingTests.swift */; }; + D9F5F85E6E75BA72EFD69407 /* AppStateSetupGitAsyncTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D7FB547E67BCAD2587FF1D9 /* AppStateSetupGitAsyncTests.swift */; }; DACDC2453A4A62AAD6D835B3 /* VaultIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = B425E00D2B566D0DDAA1A424 /* VaultIndex.swift */; }; DB40DF891317307BC6D01009 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6380ABF80B13A404D765805 /* Theme.swift */; }; DBE6291D8D98D994C083E44F /* TerminalBootCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8BE41491FBC3E8E049191BB /* TerminalBootCommand.swift */; }; @@ -275,6 +276,7 @@ 06B93FD12194D192DEFA2411 /* KeyCodeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyCodeTests.swift; sourceTree = ""; }; 0A0ACEC175A51BDC23B61E0A /* CommandPaletteWikiLinkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteWikiLinkTests.swift; sourceTree = ""; }; 0A3AAAB162484316A09E4F98 /* AppStateRelativePathTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateRelativePathTests.swift; sourceTree = ""; }; + 0D7FB547E67BCAD2587FF1D9 /* AppStateSetupGitAsyncTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateSetupGitAsyncTests.swift; sourceTree = ""; }; 0EB202F7E2555B3DFF17CF27 /* GitErrorHostnameExtractionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitErrorHostnameExtractionTests.swift; sourceTree = ""; }; 0EFF1A55AEF13CEE5C86DD71 /* MarkdownEditorInlineSemanticStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownEditorInlineSemanticStyles.swift; sourceTree = ""; }; 110835FD9934C200B95DBEB3 /* RelatedLinksTitleText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelatedLinksTitleText.swift; sourceTree = ""; }; @@ -570,6 +572,7 @@ 241B9481E6C74B5E5EEBAB08 /* AppStateSaveTests.swift */, A2E1F80ED8D1F091735B2F4E /* AppStateSearchTests.swift */, 7DAAF8FF8C6EFD3D4FCA4AB9 /* AppStateSettingsPropagationTests.swift */, + 0D7FB547E67BCAD2587FF1D9 /* AppStateSetupGitAsyncTests.swift */, F9DA552722A4F7DEA5F6D66F /* AppStateSplitPaneTests.swift */, B908F451DF8902DE532C1840 /* AppStateSyncToRemoteTests.swift */, 99E86DAF95F24BAB761C81A8 /* AppStateTabsTests.swift */, @@ -1016,6 +1019,7 @@ 4FE4D502ABEE52627ABF233E /* AppStateSaveTests.swift in Sources */, B59DEDF11939100850266EAD /* AppStateSearchTests.swift in Sources */, 5889163AE1AB27AD826FB7A0 /* AppStateSettingsPropagationTests.swift in Sources */, + D9F5F85E6E75BA72EFD69407 /* AppStateSetupGitAsyncTests.swift in Sources */, 10B4BC87F21C770AFD3CA8BF /* AppStateSplitPaneTests.swift in Sources */, 3B9076F7F7CC149A4CF7EB5C /* AppStateSyncToRemoteTests.swift in Sources */, F85C77A43A8701D0AD9143BE /* AppStateTabsTests.swift in Sources */, diff --git a/macOS/SynapseNotes/KeychainStore.swift b/macOS/SynapseNotes/KeychainStore.swift index 7de1de3..f7fee8d 100644 --- a/macOS/SynapseNotes/KeychainStore.swift +++ b/macOS/SynapseNotes/KeychainStore.swift @@ -16,6 +16,14 @@ struct KeychainStore: SecretStore { let service: String let account: String + /// True when running inside a test host. The app itself is the TEST_HOST and + /// constructs views (e.g. the Settings scene) that read the keychain eagerly; + /// from the ad-hoc-signed runner any SecItem access prompts for the login + /// password. Under XCTest the real keychain is inert: get returns nil, + /// set/delete are no-ops. Tests exercise the contract via InMemorySecretStore. + private static let isRunningInTests = + ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil + init(service: String = "com.SynapseNotes.anthropic", account: String = "apiKey") { self.service = service self.account = account @@ -32,6 +40,7 @@ struct KeychainStore: SecretStore { /// Returns the stored secret, or nil if none is set. func get() -> String? { + guard !Self.isRunningInTests else { return nil } var query = baseQuery query[kSecReturnData as String] = true query[kSecMatchLimit as String] = kSecMatchLimitOne @@ -49,6 +58,7 @@ struct KeychainStore: SecretStore { /// Stores the secret, overwriting any existing value. An empty string deletes the item. func set(_ value: String) { + guard !Self.isRunningInTests else { return } let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { delete(); return } @@ -65,6 +75,7 @@ struct KeychainStore: SecretStore { /// Removes the stored secret if present. func delete() { + guard !Self.isRunningInTests else { return } SecItemDelete(baseQuery as CFDictionary) } } diff --git a/macOS/SynapseNotesTests/KeychainStoreTests.swift b/macOS/SynapseNotesTests/KeychainStoreTests.swift index 458e3d2..1822e22 100644 --- a/macOS/SynapseNotesTests/KeychainStoreTests.swift +++ b/macOS/SynapseNotesTests/KeychainStoreTests.swift @@ -61,4 +61,15 @@ final class KeychainStoreTests: XCTestCase { let seeded = InMemorySecretStore("preset") XCTAssertEqual(seeded.get(), "preset") } + + /// The real KeychainStore must be inert inside a test host: the ad-hoc-signed + /// runner triggers a login-password prompt on any SecItem access, and the app + /// (as TEST_HOST) reads the key eagerly via SettingsView. Under XCTest, get + /// returns nil and set/delete are no-ops — no prompt, no keychain mutation. + func test_realKeychainStore_isInertUnderTests() { + let real = KeychainStore(service: "com.SynapseNotes.tests", account: "inert") + real.set("must-not-be-stored") + XCTAssertNil(real.get()) + real.delete() + } } From 5caf12e1635281b3435ab9e5f7aa04dd0b5741c8 Mon Sep 17 00:00:00 2001 From: dep Date: Wed, 10 Jun 2026 07:57:35 -0400 Subject: [PATCH 02/11] fix(perf): move setupGit git calls off the main thread (#257) setupGit ran git.currentBranch() and git.aheadCount() synchronously on the main thread during openFolder; both spawn a git subprocess and block on a semaphore until it exits, so every vault open paid that cost on the UI thread. - Compute branch/ahead on gitQueue and publish back via DispatchQueue.main.async, guarded by a gitService identity check so a vault switch mid-flight drops stale results. Sensible defaults (defaultBranchName / 0 / .idle) are set synchronously, and the gitService assignment stays synchronous so other code sees it immediately. - pullLatest now probes hasRemote() (also a subprocess) on gitQueue before claiming .pulling on main and delegating to the new performPull helper; status stays .idle for local-only repos exactly as before. - refreshGitDateCache is unchanged: it already dispatches to gitQueue internally, so its guaranteed-population ordering is intact. - Add AppStateSetupGitAsyncTests: real temp git repos (GitServiceLiveTests pattern, XCTSkip without git) proving gitBranch/gitAheadCount populate asynchronously after openFolder, including the one-commit-ahead case and the local-only .idle case. Full suite: 2017 tests, 1 skipped, 0 failures. Co-Authored-By: Claude Fable 5 --- macOS/SynapseNotes/AppState.swift | 42 ++++- .../AppStateSetupGitAsyncTests.swift | 162 ++++++++++++++++++ 2 files changed, 200 insertions(+), 4 deletions(-) create mode 100644 macOS/SynapseNotesTests/AppStateSetupGitAsyncTests.swift diff --git a/macOS/SynapseNotes/AppState.swift b/macOS/SynapseNotes/AppState.swift index 438601a..5bb92ae 100644 --- a/macOS/SynapseNotes/AppState.swift +++ b/macOS/SynapseNotes/AppState.swift @@ -1741,12 +1741,27 @@ class AppState: ObservableObject { if GitService.isGitRepo(at: url), let git = try? GitService(repoURL: url) { gitService = git - gitBranch = git.currentBranch() - gitAheadCount = git.aheadCount() + gitBranch = AppConstants.defaultBranchName + gitAheadCount = 0 gitSyncStatus = .idle + // currentBranch() and aheadCount() each spawn a git subprocess and block until + // it exits, so they must not run on the main thread (Issue #257). Compute them + // on gitQueue and publish the results back; the defaults above stay visible + // for the few milliseconds until the round-trip completes. + gitQueue.async { [weak self] in + let branch = git.currentBranch() + let ahead = git.aheadCount() + DispatchQueue.main.async { + // Drop the result if the vault changed (or closed) in the meantime. + guard let self, self.gitService === git else { return } + self.gitBranch = branch + self.gitAheadCount = ahead + } + } // Populate the file-date cache now that gitService is available — the initial // file scan may have committed before this ran, in which case its // refreshGitDateCache() call was a no-op. This second call guarantees population. + // (refreshGitDateCache does its own gitQueue dispatch, so it stays off-main.) refreshGitDateCache() startPushTimer() startPullTimer() @@ -1799,9 +1814,28 @@ class AppState: ObservableObject { } func pullLatest() { - guard let git = gitService, git.hasRemote() else { return } + guard let git = gitService else { return } guard case .idle = gitSyncStatus else { return } - gitSyncStatus = .pulling + // hasRemote() spawns a git subprocess, so probe it on the git queue rather than + // on the main thread (pullLatest is called synchronously from setupGit/openFolder, + // Issue #257). The status stays .idle for local-only repos, exactly as before. + gitQueue.async { [weak self] in + guard let self else { return } + guard git.hasRemote() else { return } + // Back on main: re-check that no other sync started while we probed the + // remote, then claim the .pulling state and run the pull on the git queue. + DispatchQueue.main.async { + guard self.gitService === git else { return } + guard case .idle = self.gitSyncStatus else { return } + self.gitSyncStatus = .pulling + self.performPull(git: git) + } + } + } + + /// Runs `git pull --rebase` on the git queue and publishes the resulting state back + /// to the main thread. Callers must already have set `gitSyncStatus = .pulling`. + private func performPull(git: GitService) { gitQueue.async { [weak self] in guard let self else { return } do { diff --git a/macOS/SynapseNotesTests/AppStateSetupGitAsyncTests.swift b/macOS/SynapseNotesTests/AppStateSetupGitAsyncTests.swift new file mode 100644 index 0000000..122b47f --- /dev/null +++ b/macOS/SynapseNotesTests/AppStateSetupGitAsyncTests.swift @@ -0,0 +1,162 @@ +import XCTest +@testable import Synapse + +/// Tests for the async git-state population in `AppState.setupGit` (Issue #257). +/// +/// `setupGit` used to run `git.currentBranch()` and `git.aheadCount()` synchronously on +/// the main thread during `openFolder`, blocking on subprocess exit. They now run on the +/// internal git queue and publish back to the main thread, so these tests open a real +/// temp git repository and wait for `gitBranch` / `gitAheadCount` to populate. +/// +/// Pattern follows GitServiceLiveTests (real repos, XCTSkip when git is unavailable). +final class AppStateSetupGitAsyncTests: XCTestCase { + + var sut: AppState! + var tempDir: URL! + var repoDir: URL! + var remoteDir: URL! + + /// A branch name that differs from the `AppConstants.defaultBranchName` placeholder, + /// so the test can distinguish "populated from git" from "still the default". + private let branchName = "feature/issue-257" + + override func setUp() { + super.setUp() + sut = AppState() + tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + repoDir = tempDir.appendingPathComponent("repo", isDirectory: true) + remoteDir = tempDir.appendingPathComponent("remote.git", isDirectory: true) + try! FileManager.default.createDirectory(at: repoDir, withIntermediateDirectories: true) + try! FileManager.default.createDirectory(at: remoteDir, withIntermediateDirectories: true) + } + + override func tearDown() { + try? FileManager.default.removeItem(at: tempDir) + sut = nil + super.tearDown() + } + + // MARK: - Helpers + + /// Runs git with the given arguments in `directory`. Returns true on exit status 0. + @discardableResult + private func git(_ args: [String], in directory: URL) -> Bool { + guard let gitPath = GitService.findGit() else { return false } + let p = Process() + p.executableURL = URL(fileURLWithPath: gitPath) + p.arguments = args + p.currentDirectoryURL = directory + p.standardOutput = Pipe() + p.standardError = Pipe() + do { try p.run() } catch { return false } + p.waitUntilExit() + return p.terminationStatus == 0 + } + + /// Creates a working repo on `branchName` with a local bare remote, upstream tracking, + /// and exactly one unpushed commit (so `aheadCount() == 1`). + private func makeRepoOneAheadOfRemote() throws { + guard GitService.findGit() != nil else { + throw XCTSkip("git not available on this system") + } + + git(["init", "--bare"], in: remoteDir) + + git(["init"], in: repoDir) + git(["config", "user.email", "test@example.com"], in: repoDir) + git(["config", "user.name", "Test"], in: repoDir) + git(["config", "commit.gpgsign", "false"], in: repoDir) + + let readme = repoDir.appendingPathComponent("README.md") + try "# Test".write(to: readme, atomically: true, encoding: .utf8) + git(["add", "-A"], in: repoDir) + git(["commit", "-m", "initial"], in: repoDir) + git(["checkout", "-b", branchName], in: repoDir) + + git(["remote", "add", "origin", remoteDir.path], in: repoDir) + guard git(["push", "-u", "origin", branchName], in: repoDir) else { + throw XCTSkip("git push to local bare remote failed") + } + + // One commit beyond the remote → aheadCount() == 1. + let note = repoDir.appendingPathComponent("note.md") + try "# Unpushed".write(to: note, atomically: true, encoding: .utf8) + git(["add", "-A"], in: repoDir) + git(["commit", "-m", "unpushed change"], in: repoDir) + + guard GitService.isGitRepo(at: repoDir) else { + throw XCTSkip("git repo was not initialised (git init may have failed)") + } + } + + /// Polls `condition` on the main run loop until it is true or `timeout` elapses. + private func waitUntil(_ description: String, + timeout: TimeInterval = 15, + condition: @escaping () -> Bool) { + let exp = expectation(description: description) + exp.assertForOverFulfill = false + func poll() { + if condition() { + exp.fulfill() + return + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { poll() } + } + poll() + wait(for: [exp], timeout: timeout) + } + + // MARK: - Tests + + func test_openFolder_onGitRepo_populatesBranchAndAheadCountAsynchronously() throws { + try makeRepoOneAheadOfRemote() + + sut.openFolder(repoDir) + + // gitService is assigned synchronously, so the status leaves .notGitRepo at once. + XCTAssertNotEqual(sut.gitSyncStatus, .notGitRepo, + "gitSyncStatus should leave .notGitRepo synchronously for a real repo") + + // The branch and ahead count are computed off the main thread and published back. + waitUntil("gitBranch populated from repo") { self.sut.gitBranch == self.branchName } + XCTAssertEqual(sut.gitBranch, branchName) + + waitUntil("gitAheadCount populated from repo") { self.sut.gitAheadCount == 1 } + XCTAssertEqual(sut.gitAheadCount, 1, + "aheadCount should reflect the one unpushed commit") + + // Let the setupGit-triggered pullLatest settle before teardown removes the repo. + waitUntil("gitSyncStatus settles to .idle") { self.sut.gitSyncStatus == .idle } + } + + func test_openFolder_onLocalOnlyGitRepo_populatesBranchAndSettlesToIdle() throws { + guard GitService.findGit() != nil else { + throw XCTSkip("git not available on this system") + } + + git(["init"], in: repoDir) + git(["config", "user.email", "test@example.com"], in: repoDir) + git(["config", "user.name", "Test"], in: repoDir) + git(["config", "commit.gpgsign", "false"], in: repoDir) + let readme = repoDir.appendingPathComponent("README.md") + try "# Test".write(to: readme, atomically: true, encoding: .utf8) + git(["add", "-A"], in: repoDir) + git(["commit", "-m", "initial"], in: repoDir) + git(["checkout", "-b", branchName], in: repoDir) + + guard GitService.isGitRepo(at: repoDir) else { + throw XCTSkip("git repo was not initialised (git init may have failed)") + } + + sut.openFolder(repoDir) + + waitUntil("gitBranch populated from repo") { self.sut.gitBranch == self.branchName } + XCTAssertEqual(sut.gitBranch, branchName) + XCTAssertEqual(sut.gitAheadCount, 0, "No remote → aheadCount stays 0") + + // pullLatest probes hasRemote() off-main; with no remote the status stays .idle. + XCTAssertEqual(sut.gitSyncStatus, .idle, + "Status must remain .idle for a local-only repo (no transient .pulling)") + } +} From ff66794e48beff7e5cf2fecbc5308f16bcbb819a Mon Sep 17 00:00:00 2001 From: dep Date: Wed, 10 Jun 2026 08:11:58 -0400 Subject: [PATCH 03/11] fix(git): surface git failures to the UI; remove checkbox force unwrap (#255) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Silent git failures could let the app quietly stop backing up notes. This surfaces them through the existing GitSyncStatus.error UI (red status icon + tooltip + popover message in the git sync indicator): - AppState.stageGitChanges: replace the empty catch on auto-save staging with a main-hop to gitSyncStatus = .error(...) — the key silent-data-loss gap. A later successful staging clears a stale error back to .idle. - AppState.performPull and both pullAndRefresh branches: errors were dropped to .idle (or swallowed via try? on the local-only WIP auto-commit); they now publish .error(error.localizedDescription). - pullLatest/pullAndRefresh idle guards now treat .error as retryable (via canStartGitSync) so the next sync attempt can recover instead of being blocked forever; in-progress and conflict states still block re-entry. Checkbox toggle crash-proofing: - Extract the preview checkbox toggle into a pure, tested helper MarkdownTaskCheckboxInteraction.togglingMarker(in:atUTF16Offset:) and drop the force-unwrapped Range(NSRange, in:) conversion in EditorView. Offsets are UTF-16 from MarkdownPreviewRenderer, so the conversion is provably safe for fresh offsets, but stale preview offsets now bail out (returning nil) instead of crashing on surrogate-pair splits or clobbering arbitrary 3-character spans. Tests: AppStateGitErrorSurfacingTests (real temp git repos, failures induced via .git/index.lock, XCTSkip without git) and MarkdownTaskCheckboxToggleMarkerTests (emoji/multibyte characterization + stale-offset cases). Full suite: 2031 tests, 1 pre-existing skip, 0 failures. Co-Authored-By: Claude Fable 5 --- macOS/Synapse Notes.xcodeproj/project.pbxproj | 8 + macOS/SynapseNotes/AppState.swift | 59 ++++-- macOS/SynapseNotes/EditorView.swift | 13 +- .../MarkdownTaskCheckboxInteraction.swift | 26 +++ .../AppStateGitErrorSurfacingTests.swift | 193 ++++++++++++++++++ ...arkdownTaskCheckboxToggleMarkerTests.swift | 80 ++++++++ 6 files changed, 357 insertions(+), 22 deletions(-) create mode 100644 macOS/SynapseNotesTests/AppStateGitErrorSurfacingTests.swift create mode 100644 macOS/SynapseNotesTests/MarkdownTaskCheckboxToggleMarkerTests.swift diff --git a/macOS/Synapse Notes.xcodeproj/project.pbxproj b/macOS/Synapse Notes.xcodeproj/project.pbxproj index 45326f3..e5d707f 100644 --- a/macOS/Synapse Notes.xcodeproj/project.pbxproj +++ b/macOS/Synapse Notes.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 0E72FEA87E7B572ABD524BBA /* AppStateRelativePathTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A3AAAB162484316A09E4F98 /* AppStateRelativePathTests.swift */; }; 100B592B0F8E409C474A00C1 /* GistPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = F02B068438E71AE98EB1FE80 /* GistPublisher.swift */; }; 10B4BC87F21C770AFD3CA8BF /* AppStateSplitPaneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9DA552722A4F7DEA5F6D66F /* AppStateSplitPaneTests.swift */; }; + 12B1D68B79DAF1A4FB5D8247 /* AppStateGitErrorSurfacingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2A7E7997B38846604684C1E /* AppStateGitErrorSurfacingTests.swift */; }; 15234D80452AA2E7B9DFDCCA /* AIRequestBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58DEDA3632AEAF7F81F9FD7F /* AIRequestBuilder.swift */; }; 1544C9F45B0FCC6B21E87F7D /* FileTreeDragDropTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8522CDE0F66E2CBBE14548 /* FileTreeDragDropTests.swift */; }; 172C4E1F1F3527C71D3DE159 /* InlineTagStylingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A11E3B1BEF42A7AA9EAAE50 /* InlineTagStylingTests.swift */; }; @@ -198,6 +199,7 @@ C4901425F2A54D5194A54B73 /* Grape in Frameworks */ = {isa = PBXBuildFile; productRef = 2658D8745056E194D47E3EC6 /* Grape */; }; C60016AD09C3CA58EA20A253 /* MarkdownCalloutStructTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56EEC9EFE247488B9CFFCDED /* MarkdownCalloutStructTests.swift */; }; C8D4635C3B403E5960D306D2 /* DatePageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C730F7180CA55FDC105228A3 /* DatePageView.swift */; }; + C97E2015192AD6F3D3E38903 /* MarkdownTaskCheckboxToggleMarkerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D809B3131FC64DC7BF49CC6 /* MarkdownTaskCheckboxToggleMarkerTests.swift */; }; CC0C3EB6FCED4C362CEB5919 /* AppStateRelatedLinksTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86AA4CD7217A37F061FC14C /* AppStateRelatedLinksTests.swift */; }; CC260DA9055067DAF97068CA /* InlineAIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B54D87558101D4D0356C986 /* InlineAIView.swift */; }; CD9FE8D4C38BFEAED26099A4 /* FlatFolderNavigatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0C5541C2A91D24172ABE7B /* FlatFolderNavigatorTests.swift */; }; @@ -305,6 +307,7 @@ 2788169302A62D1CE3240B96 /* AppStateNavigationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateNavigationTests.swift; sourceTree = ""; }; 29CAD8A0B830B328FC5769EB /* DatePageFormatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatePageFormatting.swift; sourceTree = ""; }; 2AF93A428A55CD52A1488410 /* AppThemeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppThemeTests.swift; sourceTree = ""; }; + 2D809B3131FC64DC7BF49CC6 /* MarkdownTaskCheckboxToggleMarkerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownTaskCheckboxToggleMarkerTests.swift; sourceTree = ""; }; 3642C0B4C5DF51584C3FB7C7 /* AppStateContentChangeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateContentChangeTests.swift; sourceTree = ""; }; 3ABD9B6E3A3ED294575CBF65 /* WikiLinkClickTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WikiLinkClickTests.swift; sourceTree = ""; }; 3B5764CC787B08BA6E1D64B4 /* MarkdownPreviewRendererTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownPreviewRendererTests.swift; sourceTree = ""; }; @@ -506,6 +509,7 @@ EFEEF6B9568D25C4A2B615D5 /* NavigationStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationStateTests.swift; sourceTree = ""; }; F02B068438E71AE98EB1FE80 /* GistPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GistPublisher.swift; sourceTree = ""; }; F218C386AFD56F637DC8F3C6 /* AsyncFileScanTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncFileScanTests.swift; sourceTree = ""; }; + F2A7E7997B38846604684C1E /* AppStateGitErrorSurfacingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateGitErrorSurfacingTests.swift; sourceTree = ""; }; F604CE4ECF1D7FF3CA1A636B /* VaultIndexRecencyMirrorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultIndexRecencyMirrorTests.swift; sourceTree = ""; }; F638E6858D9C03A15E702690 /* AppStatePendingSearchQueryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStatePendingSearchQueryTests.swift; sourceTree = ""; }; F6C670F1D06F1E8C530EEAA5 /* EditorStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorStateTests.swift; sourceTree = ""; }; @@ -556,6 +560,7 @@ 5EEAC9EFE313BC2825BCA71C /* AppStateFolderOperationsTests.swift */, 5DE524E56068B2DF3B0C556A /* AppStateGitDateCacheMergeTests.swift */, D2EC80568C4675330252028C /* AppStateGitDateFilteringTests.swift */, + F2A7E7997B38846604684C1E /* AppStateGitErrorSurfacingTests.swift */, 8F40452ED1B79CCE93DC83ED /* AppStateGitGuardTests.swift */, DFE158B5BC42A50F28250D5C /* AppStateGraphTabTests.swift */, 82590C6BF6E66678171DF8DE /* AppStateGraphTests.swift */, @@ -653,6 +658,7 @@ 9B3B5E5BEF7F68CFB75BE4AC /* MarkdownTablePrettifierTests.swift */, A61F2346098B568FB3D701E1 /* MarkdownTaskCheckboxHitTests.swift */, E16925836C0D8191A7B15229 /* MarkdownTaskCheckboxMatchesTests.swift */, + 2D809B3131FC64DC7BF49CC6 /* MarkdownTaskCheckboxToggleMarkerTests.swift */, BB009F631091F357BB77945E /* MiniBrowserURLNormalizerTests.swift */, 6DEF85FC5E955D1C3AB40872 /* NavigationStateActivePaneTests.swift */, EFEEF6B9568D25C4A2B615D5 /* NavigationStateTests.swift */, @@ -1003,6 +1009,7 @@ BE86F206E55E3FA0A6BF2F88 /* AppStateFolderOperationsTests.swift in Sources */, 33F163FAB5AB39D9660217CC /* AppStateGitDateCacheMergeTests.swift in Sources */, 2E7CDA10ECE1F31071384F86 /* AppStateGitDateFilteringTests.swift in Sources */, + 12B1D68B79DAF1A4FB5D8247 /* AppStateGitErrorSurfacingTests.swift in Sources */, A7E1B9EB2A01CDA2F24D0769 /* AppStateGitGuardTests.swift in Sources */, 4A271201C92D670AD0CF92CD /* AppStateGraphTabTests.swift in Sources */, A79A7F8EFDDD3DF1814B9D18 /* AppStateGraphTests.swift in Sources */, @@ -1100,6 +1107,7 @@ 7C9DB6B31ECFD057F1323C6F /* MarkdownTablePrettifierTests.swift in Sources */, 750735D3C43CA32CB7054567 /* MarkdownTaskCheckboxHitTests.swift in Sources */, 2E123ADFE2CDB5975FB4DAD2 /* MarkdownTaskCheckboxMatchesTests.swift in Sources */, + C97E2015192AD6F3D3E38903 /* MarkdownTaskCheckboxToggleMarkerTests.swift in Sources */, 7BF603CD2E2598AC6B045475 /* MiniBrowserURLNormalizerTests.swift in Sources */, 1D84374102318A2CBFC25FB3 /* NavigationStateActivePaneTests.swift in Sources */, 0A82BCD14591DD035F331CF1 /* NavigationStateTests.swift in Sources */, diff --git a/macOS/SynapseNotes/AppState.swift b/macOS/SynapseNotes/AppState.swift index 5bb92ae..6dddb9f 100644 --- a/macOS/SynapseNotes/AppState.swift +++ b/macOS/SynapseNotes/AppState.swift @@ -1813,9 +1813,24 @@ class AppState: ObservableObject { autoSaveTimer = timer } + /// Whether a new sync operation may start. `.error` is retryable — the next + /// attempt either succeeds (clearing the error) or refreshes the message — + /// while in-progress and conflict states still block re-entry (Issue #255). + private var canStartGitSync: Bool { + switch gitSyncStatus { + case .idle, .error: return true + default: return false + } + } + + /// Resets a stale `.error` badge once a later git operation succeeds. + private func clearGitErrorStatus() { + if case .error = gitSyncStatus { gitSyncStatus = .idle } + } + func pullLatest() { guard let git = gitService else { return } - guard case .idle = gitSyncStatus else { return } + guard canStartGitSync else { return } // hasRemote() spawns a git subprocess, so probe it on the git queue rather than // on the main thread (pullLatest is called synchronously from setupGit/openFolder, // Issue #257). The status stays .idle for local-only repos, exactly as before. @@ -1826,7 +1841,7 @@ class AppState: ObservableObject { // remote, then claim the .pulling state and run the pull on the git queue. DispatchQueue.main.async { guard self.gitService === git else { return } - guard case .idle = self.gitSyncStatus else { return } + guard self.canStartGitSync else { return } self.gitSyncStatus = .pulling self.performPull(git: git) } @@ -1856,7 +1871,7 @@ class AppState: ObservableObject { self.gitSyncStatus = .idle } } catch { - DispatchQueue.main.async { self.gitSyncStatus = .idle } + DispatchQueue.main.async { self.gitSyncStatus = .error(error.localizedDescription) } } } } @@ -1976,7 +1991,7 @@ class AppState: ObservableObject { return } - guard case .idle = gitSyncStatus else { + guard canStartGitSync else { // Already syncing — just refresh the file list refreshAllFiles() return @@ -1986,13 +2001,22 @@ class AppState: ObservableObject { // Local-only repo: still auto-commit any uncommitted work, then refresh. gitQueue.async { [weak self] in guard let self else { return } - if git.hasChanges() { - try? git.stageAll() - try? git.commit(message: "WIP: auto-save before refresh") - } - DispatchQueue.main.async { - self.refreshAllFiles() - self.reloadSelectedFileFromDiskIfNeeded(force: true) + do { + if git.hasChanges() { + try git.stageAll() + try git.commit(message: "WIP: auto-save before refresh") + } + DispatchQueue.main.async { + self.clearGitErrorStatus() + self.refreshAllFiles() + self.reloadSelectedFileFromDiskIfNeeded(force: true) + } + } catch { + DispatchQueue.main.async { + self.gitSyncStatus = .error(error.localizedDescription) + self.refreshAllFiles() + self.reloadSelectedFileFromDiskIfNeeded(force: true) + } } } return @@ -2025,7 +2049,7 @@ class AppState: ObservableObject { } } catch { DispatchQueue.main.async { - self.gitSyncStatus = .idle + self.gitSyncStatus = .error(error.localizedDescription) self.refreshAllFiles() } } @@ -3149,12 +3173,19 @@ class AppState: ObservableObject { private func stageGitChanges() { guard settings.autoPush, let git = gitService else { return } - gitQueue.async { + gitQueue.async { [weak self] in do { if git.hasChanges() { try git.stageAll() } - } catch {} + DispatchQueue.main.async { self?.clearGitErrorStatus() } + } catch { + // Staging is the first link in the auto-push chain — if it fails, + // notes silently stop being backed up, so surface it (Issue #255). + DispatchQueue.main.async { + self?.gitSyncStatus = .error("Auto-save staging failed: \(error.localizedDescription)") + } + } } } diff --git a/macOS/SynapseNotes/EditorView.swift b/macOS/SynapseNotes/EditorView.swift index 36d6403..0d2502c 100644 --- a/macOS/SynapseNotes/EditorView.swift +++ b/macOS/SynapseNotes/EditorView.swift @@ -244,14 +244,11 @@ struct EditorView: View { }, onToggleCheckbox: { offset in guard !isReadOnly else { return } - var content = displayContent - let ns = content as NSString - guard offset + 3 <= ns.length else { return } - let marker = ns.substring(with: NSRange(location: offset, length: 3)) - let replacement = marker == "[ ]" ? "[x]" : "[ ]" - let range = Range(NSRange(location: offset, length: 3), in: content)! - content.replaceSubrange(range, with: replacement) - activeTextBinding.wrappedValue = content + guard let toggled = MarkdownTaskCheckboxInteraction.togglingMarker( + in: displayContent, + atUTF16Offset: offset + ) else { return } + activeTextBinding.wrappedValue = toggled appState.isDirty = true } ) diff --git a/macOS/SynapseNotes/MarkdownTaskCheckboxInteraction.swift b/macOS/SynapseNotes/MarkdownTaskCheckboxInteraction.swift index 870c9ad..b69119c 100644 --- a/macOS/SynapseNotes/MarkdownTaskCheckboxInteraction.swift +++ b/macOS/SynapseNotes/MarkdownTaskCheckboxInteraction.swift @@ -22,6 +22,32 @@ struct MarkdownTaskCheckboxInteraction { guard characterIndex != NSNotFound else { return nil } return matches(in: source, parser: parser).first { NSLocationInRange(characterIndex, $0.markerRange) } } + + /// Toggles the "[ ]" / "[x]" marker whose opening bracket sits at `utf16Offset`, + /// returning the updated source, or nil when the offset does not address a marker. + /// + /// Offsets come from the preview renderer's UTF-16 NSRange arithmetic + /// (MarkdownPreviewRenderer's `data-offset`), where the 3-unit ASCII marker always + /// spans whole Characters — so the `Range(_:in:)` conversion is safe for fresh + /// offsets. The preview can lag behind the editor buffer though, so a stale offset + /// must bail out instead of crashing or clobbering unrelated text (Issue #255). + static func togglingMarker(in source: String, atUTF16Offset utf16Offset: Int) -> String? { + let ns = source as NSString + let nsRange = NSRange(location: utf16Offset, length: 3) + guard utf16Offset >= 0, utf16Offset + 3 <= ns.length else { return nil } + + let replacement: String + switch ns.substring(with: nsRange) { + case "[ ]": replacement = "[x]" + case "[x]", "[X]": replacement = "[ ]" + default: return nil + } + + guard let range = Range(nsRange, in: source) else { return nil } + var toggled = source + toggled.replaceSubrange(range, with: replacement) + return toggled + } } extension LinkAwareTextView { diff --git a/macOS/SynapseNotesTests/AppStateGitErrorSurfacingTests.swift b/macOS/SynapseNotesTests/AppStateGitErrorSurfacingTests.swift new file mode 100644 index 0000000..55d2a94 --- /dev/null +++ b/macOS/SynapseNotesTests/AppStateGitErrorSurfacingTests.swift @@ -0,0 +1,193 @@ +import XCTest +import Combine +@testable import Synapse + +/// Tests that git failures are surfaced to the UI as `gitSyncStatus = .error(...)` +/// instead of being silently swallowed (Issue #255). +/// +/// Each test runs against a real temporary git repository (GitServiceLiveTests +/// pattern) and skips gracefully when git is not installed. Failures are induced +/// deterministically by holding `.git/index.lock`, which makes `git add` / +/// `git commit` fail the same way a crashed git process or permission problem would. +final class AppStateGitErrorSurfacingTests: XCTestCase { + + var sut: AppState! + var tempDir: URL! + private var cancellables: Set = [] + + override func setUp() { + super.setUp() + sut = AppState() + tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try! FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + } + + override func tearDown() { + cancellables.removeAll() + try? FileManager.default.removeItem(at: tempDir) + sut = nil + super.tearDown() + } + + // MARK: - Auto-save staging (the empty-catch path) + + func test_autoSaveStaging_whenStageFails_setsErrorStatus() throws { + let repo = try makeLocalRepo() + sut.openFolder(repo) + + let note = repo.appendingPathComponent("note.md") + try "# Note".write(to: note, atomically: true, encoding: .utf8) + sut.openFile(note) + sut.settings.autoPush = true + + // Hold the index lock so `git add` fails deterministically. + holdIndexLock(in: repo) + + let captured = expectStatus(matching: { if case .error = $0 { return true }; return false }) + + sut.saveCurrentFile(content: "# Note\n\nchanged content") + + wait(for: [captured.expectation], timeout: 10) + guard case .error(let message)? = captured.value() else { + return XCTFail("Expected .error status after a failed auto-save staging") + } + XCTAssertTrue(message.contains("Auto-save staging failed"), + "Error should identify the failing operation, got: \(message)") + } + + func test_autoSaveStaging_whenStageSucceeds_clearsPreviousError() throws { + let repo = try makeLocalRepo() + sut.openFolder(repo) + + let note = repo.appendingPathComponent("note.md") + try "# Note".write(to: note, atomically: true, encoding: .utf8) + sut.openFile(note) + sut.settings.autoPush = true + sut.gitSyncStatus = .error("stale failure from an earlier save") + + let captured = expectStatus(matching: { $0 == .idle }) + + sut.saveCurrentFile(content: "# Note\n\nrecovered content") + + wait(for: [captured.expectation], timeout: 10) + XCTAssertEqual(captured.value(), .idle, + "A successful staging should clear a stale .error back to .idle") + } + + // MARK: - pullAndRefresh (local-only WIP auto-commit path) + + func test_pullAndRefresh_localRepo_whenWIPCommitFails_setsErrorStatus() throws { + let repo = try makeLocalRepo() + sut.openFolder(repo) + + // Uncommitted change on disk so pullAndRefresh attempts the WIP auto-commit. + let note = repo.appendingPathComponent("note.md") + try "# Uncommitted".write(to: note, atomically: true, encoding: .utf8) + + holdIndexLock(in: repo) + + let captured = expectStatus(matching: { if case .error = $0 { return true }; return false }) + + sut.pullAndRefresh() + + wait(for: [captured.expectation], timeout: 10) + guard case .error? = captured.value() else { + return XCTFail("Expected .error status when the WIP auto-commit fails") + } + } + + func test_pullAndRefresh_localRepo_fromErrorState_retriesAndRecoversToIdle() throws { + let repo = try makeLocalRepo() + sut.openFolder(repo) + sut.gitSyncStatus = .error("previous git failure") + + let captured = expectStatus(matching: { $0 == .idle }) + + // Clean tree: the WIP commit is a no-op and the refresh succeeds. + sut.pullAndRefresh() + + wait(for: [captured.expectation], timeout: 10) + XCTAssertEqual(captured.value(), .idle, + ".error must be retryable: a successful CMD-R should recover to .idle") + } + + // MARK: - pullLatest / performPull (remote pull path) + + func test_pullLatest_whenPullFails_setsErrorStatus() throws { + let repo = try makeLocalRepo() + // Point origin at a path that is not a repository so pull fails fast and offline. + runGit(["remote", "add", "origin", tempDir.appendingPathComponent("missing-remote").path], in: repo) + + let captured = expectStatus(matching: { if case .error = $0 { return true }; return false }) + + // openFolder triggers setupGit -> pullLatest against the broken remote. + sut.openFolder(repo) + + wait(for: [captured.expectation], timeout: 10) + guard case .error? = captured.value() else { + return XCTFail("Expected .error status when pulling from a broken remote") + } + } + + // MARK: - Helpers + + /// Subscribes to `gitSyncStatus` and fulfills once a published value matches. + private func expectStatus( + matching predicate: @escaping (GitSyncStatus) -> Bool + ) -> (expectation: XCTestExpectation, value: () -> GitSyncStatus?) { + let exp = expectation(description: "gitSyncStatus matches predicate") + exp.assertForOverFulfill = false + var captured: GitSyncStatus? + sut.$gitSyncStatus + .sink { status in + if captured == nil, predicate(status) { + captured = status + exp.fulfill() + } + } + .store(in: &cancellables) + return (exp, { captured }) + } + + /// Creates `.git/index.lock` so any staging/committing git command fails. + private func holdIndexLock(in repo: URL) { + let lock = repo.appendingPathComponent(".git/index.lock") + FileManager.default.createFile(atPath: lock.path, contents: Data()) + } + + private func makeLocalRepo() throws -> URL { + guard GitService.findGit() != nil else { + throw XCTSkip("git not available on this system") + } + let repo = tempDir.appendingPathComponent("repo", isDirectory: true) + try FileManager.default.createDirectory(at: repo, withIntermediateDirectories: true) + + runGit(["init"], in: repo) + runGit(["config", "user.email", "test@example.com"], in: repo) + runGit(["config", "user.name", "Test"], in: repo) + runGit(["config", "commit.gpgsign", "false"], in: repo) + + let readme = repo.appendingPathComponent("README.md") + try "# Test".write(to: readme, atomically: true, encoding: .utf8) + runGit(["add", "-A"], in: repo) + runGit(["commit", "-m", "initial"], in: repo) + + guard GitService.isGitRepo(at: repo) else { + throw XCTSkip("git init failed") + } + return repo + } + + private func runGit(_ args: [String], in url: URL) { + guard let gitPath = GitService.findGit() else { return } + let p = Process() + p.executableURL = URL(fileURLWithPath: gitPath) + p.arguments = args + p.currentDirectoryURL = url + p.standardOutput = Pipe() + p.standardError = Pipe() + try? p.run() + p.waitUntilExit() + } +} diff --git a/macOS/SynapseNotesTests/MarkdownTaskCheckboxToggleMarkerTests.swift b/macOS/SynapseNotesTests/MarkdownTaskCheckboxToggleMarkerTests.swift new file mode 100644 index 0000000..bb41aa9 --- /dev/null +++ b/macOS/SynapseNotesTests/MarkdownTaskCheckboxToggleMarkerTests.swift @@ -0,0 +1,80 @@ +import XCTest +@testable import Synapse + +/// Tests for toggling a task checkbox marker by UTF-16 offset — the path driven by +/// the markdown preview's `data-offset` attributes (Issue #255: this used to be a +/// force-unwrapped `Range(NSRange, in:)` conversion in EditorView). +final class MarkdownTaskCheckboxToggleMarkerTests: XCTestCase { + + // MARK: - Characterization: marker offsets are UTF-16 and emoji-safe + + /// The parser/renderer compute marker offsets in UTF-16 units, so even with + /// emoji and other multibyte characters before the checkbox the NSRange both + /// addresses the literal marker and converts cleanly to a String range. + func test_markerRange_withEmojiBeforeCheckbox_isUTF16AndConvertible() { + let source = "🚀🚀 rocket line\n- [ ] task with emoji 🎉\n- [x] done ✅" + let hits = MarkdownTaskCheckboxInteraction.matches(in: source) + XCTAssertEqual(hits.count, 2) + + let ns = source as NSString + XCTAssertEqual(ns.substring(with: hits[0].markerRange), "[ ]") + XCTAssertEqual(ns.substring(with: hits[1].markerRange), "[x]") + for hit in hits { + XCTAssertNotNil(Range(hit.markerRange, in: source), + "A 3-unit ASCII marker must always convert to a String range") + } + } + + // MARK: - togglingMarker(in:atUTF16Offset:) + + func test_togglingMarker_unchecked_withEmojiBeforeCheckbox_checks() { + let source = "🚀🚀 rocket line\n- [ ] task with emoji 🎉" + let offset = MarkdownTaskCheckboxInteraction.matches(in: source)[0].markerRange.location + + let toggled = MarkdownTaskCheckboxInteraction.togglingMarker(in: source, atUTF16Offset: offset) + + XCTAssertEqual(toggled, "🚀🚀 rocket line\n- [x] task with emoji 🎉") + } + + func test_togglingMarker_checked_withEmojiBeforeCheckbox_unchecks() { + let source = "🚀 rocket\n- [x] done ✅" + let offset = MarkdownTaskCheckboxInteraction.matches(in: source)[0].markerRange.location + + let toggled = MarkdownTaskCheckboxInteraction.togglingMarker(in: source, atUTF16Offset: offset) + + XCTAssertEqual(toggled, "🚀 rocket\n- [ ] done ✅") + } + + func test_togglingMarker_uppercaseChecked_unchecks() { + let toggled = MarkdownTaskCheckboxInteraction.togglingMarker(in: "- [X] done", atUTF16Offset: 2) + + XCTAssertEqual(toggled, "- [ ] done") + } + + func test_togglingMarker_plainASCII_checks() { + let toggled = MarkdownTaskCheckboxInteraction.togglingMarker(in: "- [ ] task", atUTF16Offset: 2) + + XCTAssertEqual(toggled, "- [x] task") + } + + func test_togglingMarker_offsetBeyondEnd_returnsNil() { + XCTAssertNil(MarkdownTaskCheckboxInteraction.togglingMarker(in: "- [ ]", atUTF16Offset: 3)) + XCTAssertNil(MarkdownTaskCheckboxInteraction.togglingMarker(in: "", atUTF16Offset: 0)) + } + + func test_togglingMarker_negativeOffset_returnsNil() { + XCTAssertNil(MarkdownTaskCheckboxInteraction.togglingMarker(in: "- [ ] task", atUTF16Offset: -1)) + } + + func test_togglingMarker_staleOffsetSplittingEmoji_returnsNilWithoutCrash() { + // Offset 1 lands inside the rocket emoji's surrogate pair — the crash case + // the old force unwrap was vulnerable to with stale preview offsets. + XCTAssertNil(MarkdownTaskCheckboxInteraction.togglingMarker(in: "🚀 - [ ] task", atUTF16Offset: 1)) + } + + func test_togglingMarker_staleOffsetOnNonMarkerText_returnsNil() { + // The old inline code replaced ANY 3 characters with "[ ]" — silent text + // corruption on stale offsets. Non-marker text must now be left untouched. + XCTAssertNil(MarkdownTaskCheckboxInteraction.togglingMarker(in: "hello world", atUTF16Offset: 0)) + } +} From 2dd550b0a52f276b51d55c6c967e88bc7de9ac46 Mon Sep 17 00:00:00 2001 From: dep Date: Wed, 10 Jun 2026 08:25:55 -0400 Subject: [PATCH 04/11] refactor(state): EditorState owns keystroke-frequency state; stop mirroring into AppState (#254) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EditorState is now the sole owner of fileContent, isDirty, and the pending cursor/scroll/search signals. AppState exposes non-published computed forwarding accessors with the same names, so its ~43 test files and most call sites compile unchanged — but mutating these properties no longer fires AppState.objectWillChange, so typing stops invalidating every view observing the AppState monolith (perf half tracked in #258). - Pending-signal consume helpers move onto EditorState; the free functions in EditorView.swift remain as thin forwarders for existing call sites/tests - EditorView and RawEditor observe EditorState directly so programmatic content changes and pending cursor/scroll signals still reach the editor - ContentView's dirty-dependent header pieces extracted into tiny leaf views (UnsavedIndicator, SaveHeaderButton) that observe EditorState, keeping the large ContentView body off the keystroke path - selectedFile stays AppState-owned (@Published, low-frequency) with the existing one-way mirror into editorState; tabs/activeTabIndex/paneStates untouched per issue scope - New AppStateObservationSplitTests prove the decoupling: editor mutations fire editorState.objectWillChange and never AppState's Co-Authored-By: Claude Fable 5 --- macOS/Synapse Notes.xcodeproj/project.pbxproj | 4 + macOS/SynapseNotes/AppState.swift | 61 ++++++--- macOS/SynapseNotes/ContentView.swift | 44 +++++-- macOS/SynapseNotes/EditorState.swift | 41 ++++++ macOS/SynapseNotes/EditorView.swift | 56 ++++----- .../AppStateObservationSplitTests.swift | 119 ++++++++++++++++++ .../AppStatePendingSearchQueryTests.swift | 32 +++-- .../SynapseNotesTests/EditorStateTests.swift | 16 +-- .../FSEventsVaultWatcherTests.swift | 2 +- 9 files changed, 299 insertions(+), 76 deletions(-) create mode 100644 macOS/SynapseNotesTests/AppStateObservationSplitTests.swift diff --git a/macOS/Synapse Notes.xcodeproj/project.pbxproj b/macOS/Synapse Notes.xcodeproj/project.pbxproj index e5d707f..ad7237a 100644 --- a/macOS/Synapse Notes.xcodeproj/project.pbxproj +++ b/macOS/Synapse Notes.xcodeproj/project.pbxproj @@ -78,6 +78,7 @@ 46A311D94EA3679AAA4FCC79 /* FolderPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621DD480ABAE023F532B8613 /* FolderPickerView.swift */; }; 470CA39CF13239F7790E8E6A /* GraphNodeColorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED00F83C56CAEA99861374B6 /* GraphNodeColorTests.swift */; }; 47E834CD48727E2DA9D1C147 /* MarkdownTablePrettifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F8CCC8DE0551D8C09D24609 /* MarkdownTablePrettifier.swift */; }; + 483669AEE960F25266A752D3 /* AppStateObservationSplitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD46E5B1198C3929CE50A1CE /* AppStateObservationSplitTests.swift */; }; 4A271201C92D670AD0CF92CD /* AppStateGraphTabTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFE158B5BC42A50F28250D5C /* AppStateGraphTabTests.swift */; }; 4B3CF3FB9620D4DD9E2E82E2 /* MarkdownPreviewCSSTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23FF4B814CF36CBFA3E891F9 /* MarkdownPreviewCSSTests.swift */; }; 4B7D0E530EAA13F6B125ACB7 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24C81DC01DE3F2CA6D1DC3F8 /* Constants.swift */; }; @@ -446,6 +447,7 @@ AACD74F412D8DE67726A83A6 /* AppStateTagTabsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateTagTabsTests.swift; sourceTree = ""; }; ABEC1DA743EC4078432666BE /* SidebarAutoCollapseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarAutoCollapseTests.swift; sourceTree = ""; }; ACD56E61128AEB9396A73181 /* MarkdownPreviewSemanticHidingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownPreviewSemanticHidingTests.swift; sourceTree = ""; }; + AD46E5B1198C3929CE50A1CE /* AppStateObservationSplitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateObservationSplitTests.swift; sourceTree = ""; }; AD8968AAE633103666F0386A /* SettingsManagerCollapsedPanesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManagerCollapsedPanesTests.swift; sourceTree = ""; }; B3478DB7BF51451BB83CFC1C /* SyntaxHighlighter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyntaxHighlighter.swift; sourceTree = ""; }; B425E00D2B566D0DDAA1A424 /* VaultIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultIndex.swift; sourceTree = ""; }; @@ -567,6 +569,7 @@ 5060FDAEE02A38EAF4A158AA /* AppStateInactivePaneTests.swift */, 2788169302A62D1CE3240B96 /* AppStateNavigationTests.swift */, 96156DD8690A26583B712F0C /* AppStateNewNoteFlowTests.swift */, + AD46E5B1198C3929CE50A1CE /* AppStateObservationSplitTests.swift */, 258F39D57BAE2B1D4DC8F99C /* AppStateOpenFolderDirtyFileTests.swift */, F638E6858D9C03A15E702690 /* AppStatePendingSearchQueryTests.swift */, 7B55AA96B17F979E4F856BA0 /* AppStatePinnedFolderFocusTests.swift */, @@ -1016,6 +1019,7 @@ 959D9634AF2D016B84F507A8 /* AppStateInactivePaneTests.swift in Sources */, 936EBEACAF72C21722F4DE45 /* AppStateNavigationTests.swift in Sources */, D50D2341C2B809E71831D287 /* AppStateNewNoteFlowTests.swift in Sources */, + 483669AEE960F25266A752D3 /* AppStateObservationSplitTests.swift in Sources */, DFCB1DACD693048E6BD6333E /* AppStateOpenFolderDirtyFileTests.swift in Sources */, F4163BF689BE7BC5A40BCB43 /* AppStatePendingSearchQueryTests.swift in Sources */, D6194D121C3A5687CAADABEE /* AppStatePinnedFolderFocusTests.swift in Sources */, diff --git a/macOS/SynapseNotes/AppState.swift b/macOS/SynapseNotes/AppState.swift index 6dddb9f..4604e45 100644 --- a/macOS/SynapseNotes/AppState.swift +++ b/macOS/SynapseNotes/AppState.swift @@ -164,16 +164,32 @@ class AppState: ObservableObject { /// Owns navigation data: tabs, history, split-pane layout. let navigationState = NavigationState() - /// Cancellables that forward sub-object changes to AppState.objectWillChange - /// so that existing views using @EnvironmentObject var appState continue to re-render. + /// Cancellables that mirror low-frequency AppState @Published values into the + /// focused sub-objects (see bindSubObjectObservers). Keystroke-frequency editor + /// state is NOT mirrored — EditorState owns it outright (#254). private var subObjectCancellables: [AnyCancellable] = [] @Published var rootURL: URL? @Published var selectedFile: URL? /// Set when a pinned folder is tapped — signals FileTreeView to collapse others and focus this folder. @Published var focusPinnedFolder: URL? = nil - @Published var fileContent: String = "" - @Published var isDirty: Bool = false + + // MARK: - Keystroke-Frequency Editor State (owned by EditorState, #254) + // + // These accessors forward to `editorState`, the sole owner of editor content, + // dirty state, and pending cursor/scroll signals. They are deliberately NOT + // @Published: mutating them must not fire AppState.objectWillChange, so typing + // no longer re-renders every view observing AppState. Views that render these + // values observe `editorState` directly via @EnvironmentObject. + + var fileContent: String { + get { editorState.fileContent } + set { editorState.fileContent = newValue } + } + var isDirty: Bool { + get { editorState.isDirty } + set { editorState.isDirty = newValue } + } @Published var allFiles: [URL] = [] @Published var allProjectFiles: [URL] = [] @Published var recentFiles: [URL] = [] @@ -214,11 +230,29 @@ class AppState: ObservableObject { @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 - @Published var pendingCursorTargetPaneIndex: Int? = nil - @Published var pendingScrollOffsetY: CGFloat? = nil - @Published var pendingSearchQuery: String? = nil + + // Pending cursor/scroll signals — forwarded to EditorState (sole owner, #254). + // Not @Published: see the keystroke-frequency editor state note above. + var pendingCursorPosition: Int? { + get { editorState.pendingCursorPosition } + set { editorState.pendingCursorPosition = newValue } + } + var pendingCursorRange: NSRange? { + get { editorState.pendingCursorRange } + set { editorState.pendingCursorRange = newValue } + } + var pendingCursorTargetPaneIndex: Int? { + get { editorState.pendingCursorTargetPaneIndex } + set { editorState.pendingCursorTargetPaneIndex = newValue } + } + var pendingScrollOffsetY: CGFloat? { + get { editorState.pendingScrollOffsetY } + set { editorState.pendingScrollOffsetY = newValue } + } + var pendingSearchQuery: String? { + get { editorState.pendingSearchQuery } + set { editorState.pendingSearchQuery = newValue } + } @Published var commandPaletteMode: CommandPaletteMode = .files @Published var targetDirectoryForTemplate: URL? /// Target directory for new note creation (Issue #194) - stores the selected folder in the New Note sheet @@ -388,12 +422,11 @@ class AppState: ObservableObject { $isIndexing.sink { [weak self] v in self?.vaultIndex.isIndexing = v }, $lastContentChange.sink { [weak self] v in self?.vaultIndex.lastContentChange = v }, - // EditorState mirrors — only low-frequency file-selection properties. + // EditorState mirror — only the low-frequency file-selection property. // High-frequency editor properties (fileContent, isDirty, pendingCursor*, - // pendingScrollOffsetY, pendingSearchQuery) are intentionally NOT mirrored - // here: they change on every keystroke and undo operation, and sinking them - // into EditorState during @Published willSet can interleave with AppKit's - // NSUndoManager stack, causing EXC_BAD_ACCESS on Cmd+Z. + // pendingScrollOffsetY, pendingSearchQuery) are NOT mirrored: EditorState is + // their sole owner and AppState exposes non-published forwarding accessors + // (#254), so typing never fires AppState.objectWillChange. $selectedFile.sink { [weak self] v in self?.editorState.selectedFile = v }, // NavigationState mirrors — low-frequency, safe to sink diff --git a/macOS/SynapseNotes/ContentView.swift b/macOS/SynapseNotes/ContentView.swift index 5b03086..f382560 100644 --- a/macOS/SynapseNotes/ContentView.swift +++ b/macOS/SynapseNotes/ContentView.swift @@ -503,9 +503,7 @@ struct ContentView: View { Spacer(minLength: 0) - if appState.isDirty { - TinyBadge(text: "Unsaved", color: SynapseTheme.success) - } + UnsavedIndicator() // Right side: Other toolbar buttons (without back/forward) HStack(spacing: SynapseTheme.Layout.spaceSmall) { @@ -532,14 +530,7 @@ struct ContentView: View { } if appState.selectedFile != nil { - Button(action: { - appState.saveAndSyncCurrentFile() - }) { - Image(systemName: "square.and.arrow.down") - } - .buttonStyle(PrimaryChromeButtonStyle()) - .help("Save (⌘S)") - .opacity(appState.isDirty ? 1 : 0.78) + SaveHeaderButton() } // Exit vault button - far right @@ -556,6 +547,37 @@ struct ContentView: View { .background(SynapseTheme.panelElevated) } + // MARK: - Dirty-State Header Leaves (#254) + // + // These observe EditorState (sole owner of `isDirty`) in tiny leaf views so the + // 1,400-line ContentView body is never invalidated by keystroke-frequency state. + + private struct UnsavedIndicator: View { + @EnvironmentObject var editorState: EditorState + + var body: some View { + if editorState.isDirty { + TinyBadge(text: "Unsaved", color: SynapseTheme.success) + } + } + } + + private struct SaveHeaderButton: View { + @EnvironmentObject var appState: AppState + @EnvironmentObject var editorState: EditorState + + var body: some View { + Button(action: { + appState.saveAndSyncCurrentFile() + }) { + Image(systemName: "square.and.arrow.down") + } + .buttonStyle(PrimaryChromeButtonStyle()) + .help("Save (⌘S)") + .opacity(editorState.isDirty ? 1 : 0.78) + } + } + private func headerToggleButton(systemName: String, isActive: Bool, action: @escaping () -> Void, help: String) -> some View { Button(action: action) { Image(systemName: systemName) diff --git a/macOS/SynapseNotes/EditorState.swift b/macOS/SynapseNotes/EditorState.swift index 0a5d9cf..18fbf6f 100644 --- a/macOS/SynapseNotes/EditorState.swift +++ b/macOS/SynapseNotes/EditorState.swift @@ -28,4 +28,45 @@ final class EditorState: ObservableObject { @Published var pendingScrollOffsetY: CGFloat? = nil /// When set, the editor should pre-populate the search field. @Published var pendingSearchQuery: String? = nil + + // MARK: - Pending Signal Consumption + + /// Returns the pending search query (if any) and clears it. + func consumePendingSearchQuery() -> String? { + guard let q = pendingSearchQuery else { return nil } + pendingSearchQuery = nil + return q + } + + /// Returns the pending cursor range if `textView` is editable and the signal targets + /// `paneIndex` (or no specific pane), clearing the range and pane target. + func consumePendingCursorRange(for textView: NSTextView, paneIndex: Int) -> NSRange? { + guard textView.isEditable, + let range = pendingCursorRange, + pendingCursorTargetPaneIndex == nil || pendingCursorTargetPaneIndex == paneIndex else { return nil } + pendingCursorRange = nil + pendingCursorTargetPaneIndex = nil + return range + } + + /// Returns the pending cursor position if `textView` is editable and the signal targets + /// `paneIndex` (or no specific pane), clearing the position and pane target. + func consumePendingCursorPosition(for textView: NSTextView, paneIndex: Int) -> Int? { + guard textView.isEditable, + let position = pendingCursorPosition, + pendingCursorTargetPaneIndex == nil || pendingCursorTargetPaneIndex == paneIndex else { return nil } + pendingCursorPosition = nil + pendingCursorTargetPaneIndex = nil + return position + } + + /// Returns the pending scroll offset if `textView` is editable and the signal targets + /// `paneIndex` (or no specific pane), clearing the offset. + func consumePendingScrollOffset(for textView: NSTextView, paneIndex: Int) -> CGFloat? { + guard textView.isEditable, + let offset = pendingScrollOffsetY, + pendingCursorTargetPaneIndex == nil || pendingCursorTargetPaneIndex == paneIndex else { return nil } + pendingScrollOffsetY = nil + return offset + } } diff --git a/macOS/SynapseNotes/EditorView.swift b/macOS/SynapseNotes/EditorView.swift index 0d2502c..8ec863d 100644 --- a/macOS/SynapseNotes/EditorView.swift +++ b/macOS/SynapseNotes/EditorView.swift @@ -3,36 +3,24 @@ import AppKit import ImageIO import WebKit +// Pending-signal consumption lives on EditorState (the sole owner of pending +// cursor/scroll state, #254). These free functions are thin forwarders kept for +// existing call sites and tests. + func consumePendingSearchQuery(from appState: AppState) -> String? { - guard let q = appState.pendingSearchQuery else { return nil } - appState.pendingSearchQuery = nil - return q + appState.editorState.consumePendingSearchQuery() } func consumePendingCursorRange(from appState: AppState, for textView: NSTextView, paneIndex: Int) -> NSRange? { - guard textView.isEditable, - let range = appState.pendingCursorRange, - appState.pendingCursorTargetPaneIndex == nil || appState.pendingCursorTargetPaneIndex == paneIndex else { return nil } - appState.pendingCursorRange = nil - appState.pendingCursorTargetPaneIndex = nil - return range + appState.editorState.consumePendingCursorRange(for: textView, paneIndex: paneIndex) } func consumePendingCursorPosition(from appState: AppState, for textView: NSTextView, paneIndex: Int) -> Int? { - guard textView.isEditable, - let position = appState.pendingCursorPosition, - appState.pendingCursorTargetPaneIndex == nil || appState.pendingCursorTargetPaneIndex == paneIndex else { return nil } - appState.pendingCursorPosition = nil - appState.pendingCursorTargetPaneIndex = nil - return position + appState.editorState.consumePendingCursorPosition(for: textView, paneIndex: paneIndex) } func consumePendingScrollOffset(from appState: AppState, for textView: NSTextView, paneIndex: Int) -> CGFloat? { - guard textView.isEditable, - let offset = appState.pendingScrollOffsetY, - appState.pendingCursorTargetPaneIndex == nil || appState.pendingCursorTargetPaneIndex == paneIndex else { return nil } - appState.pendingScrollOffsetY = nil - return offset + appState.editorState.consumePendingScrollOffset(for: textView, paneIndex: paneIndex) } func restoreScrollOffset(_ offset: CGFloat, in scrollView: NSScrollView) { @@ -180,6 +168,9 @@ func activatePaneOnReadOnlyInteraction(isEditable: Bool, onActivatePane: (() -> struct EditorView: View { @EnvironmentObject var appState: AppState + /// Sole owner of keystroke-frequency editor state (#254). Observing it here + /// keeps the editor live-updating without typing invalidating AppState observers. + @EnvironmentObject var editorState: EditorState var paneIndex: Int = 0 /// When set, renders in read-only mode using these values instead of live appState. @@ -203,9 +194,9 @@ struct EditorView: View { private var isReadOnly: Bool { readOnlyFile != nil } private var usesExternalEditableState: Bool { editableFile != nil && editableContent != nil } private var displayFile: URL? { readOnlyFile ?? editableFile ?? appState.selectedFile } - private var displayContent: String { readOnlyContent ?? editableContent?.wrappedValue ?? appState.fileContent } - private var displayIsDirty: Bool { editableIsDirty?.wrappedValue ?? appState.isDirty } - private var activeTextBinding: Binding { editableContent ?? $appState.fileContent } + private var displayContent: String { readOnlyContent ?? editableContent?.wrappedValue ?? editorState.fileContent } + private var displayIsDirty: Bool { editableIsDirty?.wrappedValue ?? editorState.isDirty } + private var activeTextBinding: Binding { editableContent ?? $editorState.fileContent } private var participatesInGlobalEditorCommands: Bool { !usesExternalEditableState } private var isInViewMode: Bool { isReadOnly || (participatesInGlobalEditorCommands && !appState.isEditMode) } private var isDark: Bool { NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua } @@ -249,7 +240,7 @@ struct EditorView: View { atUTF16Offset: offset ) else { return } activeTextBinding.wrappedValue = toggled - appState.isDirty = true + editorState.isDirty = true } ) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -336,7 +327,7 @@ struct EditorView: View { if let editableIsDirty { editableIsDirty.wrappedValue = true } else { - appState.isDirty = true + editorState.isDirty = true } } @@ -490,8 +481,8 @@ struct EditorView: View { editableContent.wrappedValue = content editableIsDirty?.wrappedValue = true } else { - appState.fileContent = content - appState.isDirty = true + editorState.fileContent = content + editorState.isDirty = true } showHistoryModal = false @@ -696,6 +687,9 @@ struct RawEditor: NSViewRepresentable { var participatesInGlobalEditorCommands: Bool = true var onDidEdit: (() -> Void)? = nil @EnvironmentObject var appState: AppState + /// Observed so programmatic content changes and pending cursor/scroll signals + /// (owned by EditorState, #254) trigger updateNSView without an AppState publish. + @EnvironmentObject var editorState: EditorState func makeCoordinator() -> Coordinator { Coordinator(self) } @@ -900,18 +894,18 @@ struct RawEditor: NSViewRepresentable { } if participatesInGlobalEditorCommands { - if let range = consumePendingCursorRange(from: appState, for: textView, paneIndex: paneIndex) { + if let range = editorState.consumePendingCursorRange(for: textView, paneIndex: paneIndex) { let len = textView.string.count let safeLoc = min(range.location, len) let safeLen = min(range.length, len - safeLoc) let safeRange = NSRange(location: safeLoc, length: safeLen) textView.setSelectedRange(safeRange) - if let offset = consumePendingScrollOffset(from: appState, for: textView, paneIndex: paneIndex) { + if let offset = editorState.consumePendingScrollOffset(for: textView, paneIndex: paneIndex) { restoreScrollOffset(offset, in: scrollView) } else { textView.scrollRangeToVisible(safeRange) } - } else if let position = consumePendingCursorPosition(from: appState, for: textView, paneIndex: paneIndex) { + } else if let position = editorState.consumePendingCursorPosition(for: textView, paneIndex: paneIndex) { let clamped = min(position, textView.string.count) textView.setSelectedRange(NSRange(location: clamped, length: 0)) textView.scrollRangeToVisible(NSRange(location: clamped, length: 0)) @@ -929,7 +923,7 @@ struct RawEditor: NSViewRepresentable { DispatchQueue.main.async { self.scrollToRange = nil } } - if participatesInGlobalEditorCommands, isEditable, let q = consumePendingSearchQuery(from: appState) { + if participatesInGlobalEditorCommands, isEditable, let q = editorState.consumePendingSearchQuery() { DispatchQueue.main.async { NotificationCenter.default.post( name: .scrollToSearchMatch, diff --git a/macOS/SynapseNotesTests/AppStateObservationSplitTests.swift b/macOS/SynapseNotesTests/AppStateObservationSplitTests.swift new file mode 100644 index 0000000..560dc57 --- /dev/null +++ b/macOS/SynapseNotesTests/AppStateObservationSplitTests.swift @@ -0,0 +1,119 @@ +import XCTest +import Combine +@testable import Synapse + +/// Regression coverage for the observation split (#254): EditorState is the sole +/// owner of keystroke-frequency editor state, and mutating it must NOT fire +/// AppState.objectWillChange. AppState exposes non-published forwarding accessors +/// so existing call sites keep working against the same storage. +final class AppStateObservationSplitTests: XCTestCase { + + var sut: AppState! + var cancellables: Set! + + override func setUp() { + super.setUp() + sut = AppState() + cancellables = [] + } + + override func tearDown() { + cancellables = nil + sut = nil + super.tearDown() + } + + // MARK: - Mutating editor state must not invalidate AppState observers + + func test_mutatingEditorState_doesNotFireAppStateObjectWillChange() { + var appStateChanges = 0 + sut.objectWillChange.sink { _ in appStateChanges += 1 }.store(in: &cancellables) + + sut.editorState.fileContent = "typed a character" + sut.editorState.isDirty = true + sut.editorState.pendingCursorPosition = 5 + sut.editorState.pendingCursorRange = NSRange(location: 0, length: 3) + sut.editorState.pendingCursorTargetPaneIndex = 1 + sut.editorState.pendingScrollOffsetY = 42 + sut.editorState.pendingSearchQuery = "query" + + XCTAssertEqual(appStateChanges, 0, + "Keystroke-frequency EditorState mutations must not re-render AppState observers") + } + + func test_mutatingForwardingAccessorsOnAppState_doesNotFireAppStateObjectWillChange() { + var appStateChanges = 0 + sut.objectWillChange.sink { _ in appStateChanges += 1 }.store(in: &cancellables) + + sut.fileContent = "typed via forwarding accessor" + sut.isDirty = true + sut.pendingCursorPosition = 7 + sut.pendingCursorRange = NSRange(location: 1, length: 2) + sut.pendingCursorTargetPaneIndex = 0 + sut.pendingScrollOffsetY = 99 + sut.pendingSearchQuery = "find me" + + XCTAssertEqual(appStateChanges, 0, + "AppState forwarding accessors are not @Published and must not fire objectWillChange") + } + + // MARK: - EditorState observers still get notified + + func test_mutatingEditorState_firesEditorStateObjectWillChange() { + var editorStateChanges = 0 + sut.editorState.objectWillChange.sink { _ in editorStateChanges += 1 }.store(in: &cancellables) + + sut.editorState.fileContent = "typed a character" + sut.editorState.isDirty = true + + XCTAssertEqual(editorStateChanges, 2, + "Views observing EditorState must be invalidated by editor mutations") + } + + func test_mutatingForwardingAccessorsOnAppState_firesEditorStateObjectWillChange() { + var editorStateChanges = 0 + sut.editorState.objectWillChange.sink { _ in editorStateChanges += 1 }.store(in: &cancellables) + + sut.fileContent = "typed via forwarding accessor" + sut.isDirty = true + + XCTAssertEqual(editorStateChanges, 2, + "Forwarding accessors write the same EditorState storage and must publish there") + } + + // MARK: - Forwarding accessors share one source of truth + + func test_forwardingAccessors_readAndWriteEditorStateStorage() { + sut.fileContent = "via appState" + XCTAssertEqual(sut.editorState.fileContent, "via appState") + + sut.editorState.fileContent = "via editorState" + XCTAssertEqual(sut.fileContent, "via editorState") + + sut.isDirty = true + XCTAssertTrue(sut.editorState.isDirty) + + sut.pendingCursorRange = NSRange(location: 3, length: 4) + XCTAssertEqual(sut.editorState.pendingCursorRange, NSRange(location: 3, length: 4)) + + sut.editorState.pendingSearchQuery = "shared" + XCTAssertEqual(sut.pendingSearchQuery, "shared") + } + + // MARK: - Pending-signal consumption lives on EditorState + + func test_consumeHelpers_onEditorState_clearSharedStorage() { + let textView = NSTextView() + textView.isEditable = true + + sut.pendingCursorRange = NSRange(location: 0, length: 2) + sut.pendingCursorTargetPaneIndex = nil + XCTAssertEqual(sut.editorState.consumePendingCursorRange(for: textView, paneIndex: 0), + NSRange(location: 0, length: 2)) + XCTAssertNil(sut.pendingCursorRange, "Consumption must clear the single shared storage") + + sut.pendingSearchQuery = "needle" + XCTAssertEqual(sut.editorState.consumePendingSearchQuery(), "needle") + XCTAssertNil(sut.pendingSearchQuery) + } +} diff --git a/macOS/SynapseNotesTests/AppStatePendingSearchQueryTests.swift b/macOS/SynapseNotesTests/AppStatePendingSearchQueryTests.swift index 28b7179..6b3f250 100644 --- a/macOS/SynapseNotesTests/AppStatePendingSearchQueryTests.swift +++ b/macOS/SynapseNotesTests/AppStatePendingSearchQueryTests.swift @@ -79,22 +79,32 @@ final class AppStatePendingSearchQueryTests: XCTestCase { } // MARK: - Observable - - func test_pendingSearchQuery_set_triggersObjectWillChange() { - var changeCount = 0 - let cancellable = sut.objectWillChange.sink { _ in changeCount += 1 } + // pendingSearchQuery is owned by EditorState (#254): mutations publish on + // editorState.objectWillChange and deliberately do NOT fire AppState's. + + func test_pendingSearchQuery_set_triggersEditorStateObjectWillChange_notAppState() { + var editorStateChanges = 0 + var appStateChanges = 0 + let editorCancellable = sut.editorState.objectWillChange.sink { _ in editorStateChanges += 1 } + let appCancellable = sut.objectWillChange.sink { _ in appStateChanges += 1 } sut.pendingSearchQuery = "test" - XCTAssertGreaterThanOrEqual(changeCount, 1) - cancellable.cancel() + XCTAssertGreaterThanOrEqual(editorStateChanges, 1) + XCTAssertEqual(appStateChanges, 0) + editorCancellable.cancel() + appCancellable.cancel() } - func test_pendingSearchQuery_clear_triggersObjectWillChange() { + func test_pendingSearchQuery_clear_triggersEditorStateObjectWillChange_notAppState() { sut.pendingSearchQuery = "test" - var changeCount = 0 - let cancellable = sut.objectWillChange.sink { _ in changeCount += 1 } + var editorStateChanges = 0 + var appStateChanges = 0 + let editorCancellable = sut.editorState.objectWillChange.sink { _ in editorStateChanges += 1 } + let appCancellable = sut.objectWillChange.sink { _ in appStateChanges += 1 } sut.pendingSearchQuery = nil - XCTAssertGreaterThanOrEqual(changeCount, 1) - cancellable.cancel() + XCTAssertGreaterThanOrEqual(editorStateChanges, 1) + XCTAssertEqual(appStateChanges, 0) + editorCancellable.cancel() + appCancellable.cancel() } // MARK: - Helpers diff --git a/macOS/SynapseNotesTests/EditorStateTests.swift b/macOS/SynapseNotesTests/EditorStateTests.swift index c2d39ca..c8e0f22 100644 --- a/macOS/SynapseNotesTests/EditorStateTests.swift +++ b/macOS/SynapseNotesTests/EditorStateTests.swift @@ -39,11 +39,10 @@ final class EditorStateTests: XCTestCase { } func test_editorState_fileContent_initiallyEmpty() { - // EditorState.fileContent is initialised to empty; it is NOT synced from - // AppState to avoid interleaving with NSUndoManager during Cmd+Z (see - // bindSubObjectObservers comment). Views that need fileContent should - // subscribe to appState directly. + // EditorState is the sole owner of fileContent (#254); AppState.fileContent + // is a non-published forwarding accessor over this storage. XCTAssertEqual(sut.editorState.fileContent, "") + XCTAssertEqual(sut.fileContent, "") } func test_editorState_isDirty_initiallyFalse() { @@ -61,9 +60,10 @@ final class EditorStateTests: XCTestCase { "appState.selectedFile must be mirrored into editorState.selectedFile") } - // MARK: - High-frequency editor properties are NOT mirrored (crash safety) + // MARK: - High-frequency editor properties are owned by EditorState (#254) // fileContent, isDirty, pendingCursor*, pendingScrollOffsetY, and pendingSearchQuery - // are intentionally NOT synced from AppState into EditorState. Sinking these during - // @Published willSet can interleave with NSUndoManager and cause EXC_BAD_ACCESS on Cmd+Z. - // Views that need them must subscribe to appState directly. + // live solely on EditorState; AppState exposes non-published forwarding accessors so + // mutations never fire AppState.objectWillChange. Views that render these values must + // observe editorState directly. See AppStateObservationSplitTests for the regression + // coverage of this decoupling. } diff --git a/macOS/SynapseNotesTests/FSEventsVaultWatcherTests.swift b/macOS/SynapseNotesTests/FSEventsVaultWatcherTests.swift index e729ed1..27ea776 100644 --- a/macOS/SynapseNotesTests/FSEventsVaultWatcherTests.swift +++ b/macOS/SynapseNotesTests/FSEventsVaultWatcherTests.swift @@ -102,7 +102,7 @@ final class FSEventsVaultWatcherTests: XCTestCase { // Expect fileContent to eventually reflect the new on-disk content. let reloadExp = expectation(description: "file content reloaded") - sut.$fileContent + sut.editorState.$fileContent .first(where: { $0 == "updated externally" }) .sink { _ in reloadExp.fulfill() } .store(in: &cancellables) From 249791a0e4fb7e84671c0feeb6ce58d13fbfff1c Mon Sep 17 00:00:00 2001 From: dep Date: Wed, 10 Jun 2026 08:36:35 -0400 Subject: [PATCH 05/11] perf(ui): eliminate per-keystroke view-tree re-renders (#258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the perf half of the observation split (#254/#258). Audit of the full typing path (textDidChange -> binding write -> isDirty, plus the caret path via textViewDidChangeSelection) found only one remaining excess publish: the editor re-set isDirty = true on EVERY keystroke, and @Published fires objectWillChange even for value-preserving writes, so each keystroke published EditorState twice. Both dirty-marking paths (the textStorage delegate fallback and markEditorDirty, including the sidebar note pane binding) now guard the re-set behind a current-value check. Audit results, for the record: - No AppState/VaultIndex/NavigationState/SettingsManager publish fires per keystroke; lastContentChange fires only on save/reload-from-disk; autosave is a fixed 120s timer; search-match counts publish only while find is active - Caret moves publish nothing: textViewDidChangeSelection only does AppKit styling work (already debounced); saveCursorPosition posts only on tab/pane switches, never per caret move - RawEditor.updateNSView keeps its content guard (string != text) so the per-keystroke pass never re-sets text; SplitPaneEditorView/PaneView observe AppState only, so they stay off the keystroke path entirely - ContentView reaches keystroke state only through the #254 leaf views (UnsavedIndicator, SaveHeaderButton) Verification: Instruments isn't available in this environment, so new KeystrokeRenderCountTests host probe views in a real NSHostingView with the same environment-object injection SynapseNotesApp uses. An AppState-observing probe (ContentView's shape) keeps a flat body-evaluation count across simulated keystrokes + caret signals, while an EditorState-observing probe (the editor leaves' shape) re-renders — and a control test proves a genuine low-frequency AppState publish (isSearchPresented) still invalidates the AppState probe, so the flat count cannot come from a dead harness. Full ContentView hosting was deliberately avoided: its 1,400-line body drags in file-system scans, git, and timers that make the test flaky for no extra proof beyond the probe + the existing AppStateObservationSplitTests. Full suite: 2039 tests, 0 failures, 1 skipped. Co-Authored-By: Claude Fable 5 --- macOS/Synapse Notes.xcodeproj/project.pbxproj | 4 + macOS/SynapseNotes/EditorView.swift | 10 +- .../KeystrokeRenderCountTests.swift | 118 ++++++++++++++++++ 3 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 macOS/SynapseNotesTests/KeystrokeRenderCountTests.swift diff --git a/macOS/Synapse Notes.xcodeproj/project.pbxproj b/macOS/Synapse Notes.xcodeproj/project.pbxproj index ad7237a..4ea7bb8 100644 --- a/macOS/Synapse Notes.xcodeproj/project.pbxproj +++ b/macOS/Synapse Notes.xcodeproj/project.pbxproj @@ -25,6 +25,7 @@ 172C4E1F1F3527C71D3DE159 /* InlineTagStylingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A11E3B1BEF42A7AA9EAAE50 /* InlineTagStylingTests.swift */; }; 17E6E2EC1F030DFAA7A1AA48 /* MarkdownPreviewBlockRevealTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4627D80D824902FA0D51573B /* MarkdownPreviewBlockRevealTests.swift */; }; 1920DBA6F5A8E5D6156A0A68 /* VaultFullTextSearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B787611C6B2C96A09C16C73A /* VaultFullTextSearchTests.swift */; }; + 195EB18FC5B083CA0D5BF452 /* KeystrokeRenderCountTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E53727C83ADDA52CAE690AA7 /* KeystrokeRenderCountTests.swift */; }; 1AB0D9338BE326342A1B0129 /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BD9A4F4664E7E7F5F405135 /* AppTheme.swift */; }; 1C1DDE1D7852ACB813AFDC4B /* RespectGitignoreSettingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01F60B6E95850384FC16643E /* RespectGitignoreSettingTests.swift */; }; 1C5F0E08CD3414F6CAC10635 /* MarkdownPreviewRendererTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5764CC787B08BA6E1D64B4 /* MarkdownPreviewRendererTests.swift */; }; @@ -496,6 +497,7 @@ E18771A5B6B615DDCC2CC193 /* FontEnumerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontEnumerator.swift; sourceTree = ""; }; E1E3D357F071ACBF2016AEA7 /* MarkdownCallout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownCallout.swift; sourceTree = ""; }; E38F6CCA42696C5E82B8FC99 /* SettingsManagerSidebarCollapseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManagerSidebarCollapseTests.swift; sourceTree = ""; }; + E53727C83ADDA52CAE690AA7 /* KeystrokeRenderCountTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeystrokeRenderCountTests.swift; sourceTree = ""; }; E53C4F1F9D71478B0FB489B9 /* SettingsPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsPersistenceTests.swift; sourceTree = ""; }; E60A5A7B35E4BA0A0789846F /* FileTreeSortingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTreeSortingTests.swift; sourceTree = ""; }; E712F3D99CEEE8038B89F996 /* RelatedLinksPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelatedLinksPaneView.swift; sourceTree = ""; }; @@ -645,6 +647,7 @@ 5A11E3B1BEF42A7AA9EAAE50 /* InlineTagStylingTests.swift */, A84218FCBC7806F50E6BF682 /* KeychainStoreTests.swift */, 06B93FD12194D192DEFA2411 /* KeyCodeTests.swift */, + E53727C83ADDA52CAE690AA7 /* KeystrokeRenderCountTests.swift */, 83546378C054CB0E3E4999BE /* ListContinuationTests.swift */, A9182F671271FBBF2DE43D1E /* MarkdownCalloutDetectorTests.swift */, 56EEC9EFE247488B9CFFCDED /* MarkdownCalloutStructTests.swift */, @@ -1095,6 +1098,7 @@ 172C4E1F1F3527C71D3DE159 /* InlineTagStylingTests.swift in Sources */, 5366B533EBF7FF44B882B007 /* KeyCodeTests.swift in Sources */, 3BAE5845C73805CD95CDD3C1 /* KeychainStoreTests.swift in Sources */, + 195EB18FC5B083CA0D5BF452 /* KeystrokeRenderCountTests.swift in Sources */, D2DAC498727FB48FD7B91740 /* ListContinuationTests.swift in Sources */, F9161E6876F8449ED7794D80 /* MarkdownCalloutDetectorTests.swift in Sources */, C60016AD09C3CA58EA20A253 /* MarkdownCalloutStructTests.swift in Sources */, diff --git a/macOS/SynapseNotes/EditorView.swift b/macOS/SynapseNotes/EditorView.swift index 8ec863d..03e234e 100644 --- a/macOS/SynapseNotes/EditorView.swift +++ b/macOS/SynapseNotes/EditorView.swift @@ -324,9 +324,11 @@ struct EditorView: View { } private func markEditorDirty() { + // @Published fires objectWillChange even for value-preserving writes, so skip + // the re-set once dirty — otherwise every keystroke publishes twice (#258). if let editableIsDirty { - editableIsDirty.wrappedValue = true - } else { + if !editableIsDirty.wrappedValue { editableIsDirty.wrappedValue = true } + } else if !editorState.isDirty { editorState.isDirty = true } } @@ -1021,7 +1023,9 @@ struct RawEditor: NSViewRepresentable { parent.text = newText if let onDidEdit = parent.onDidEdit { onDidEdit() - } else { + } else if !parent.appState.isDirty { + // Skip the value-preserving re-set: @Published would still fire + // objectWillChange, doubling per-keystroke EditorState publishes (#258). parent.appState.isDirty = true } } diff --git a/macOS/SynapseNotesTests/KeystrokeRenderCountTests.swift b/macOS/SynapseNotesTests/KeystrokeRenderCountTests.swift new file mode 100644 index 0000000..fd779d5 --- /dev/null +++ b/macOS/SynapseNotesTests/KeystrokeRenderCountTests.swift @@ -0,0 +1,118 @@ +import XCTest +import SwiftUI +@testable import Synapse + +/// End-to-end render-count regression for #258, hosted in a real NSHostingView. +/// +/// The existing AppStateObservationSplitTests prove `objectWillChange` silence at the +/// Combine level; these tests prove the consequence under actual SwiftUI hosting: the +/// body of a view observing the AppState monolith (ContentView's shape) is NOT +/// re-evaluated by keystroke-frequency EditorState mutations, while a view observing +/// EditorState (the editor leaves' shape) IS — so the harness cannot false-pass. +final class KeystrokeRenderCountTests: XCTestCase { + + /// Body-evaluation tally shared by the probe views below. + private final class RenderTally { + private var counts: [String: Int] = [:] + func tick(_ key: String) { counts[key, default: 0] += 1 } + func count(_ key: String) -> Int { counts[key] ?? 0 } + } + + /// Stand-in for any view observing the AppState monolith (e.g. ContentView's + /// 1,400-line body): typing must never re-evaluate this body. + private struct AppStateProbe: View { + @EnvironmentObject var appState: AppState + let tally: RenderTally + var body: some View { + tally.tick("appState") + return Color.clear.frame(width: 1, height: 1) + } + } + + /// Stand-in for the editor leaf views (EditorView, UnsavedIndicator, + /// SaveHeaderButton): typing SHOULD re-evaluate this body, proving the + /// hosting harness actually detects invalidations. + private struct EditorStateProbe: View { + @EnvironmentObject var editorState: EditorState + let tally: RenderTally + var body: some View { + tally.tick("editorState") + return Color.clear.frame(width: 1, height: 1) + } + } + + private var window: NSWindow! + + override func tearDown() { + window?.orderOut(nil) + window = nil + super.tearDown() + } + + /// Hosts both probes in an offscreen window with the same environment-object + /// injection SynapseNotesApp uses (AppState plus its editorState sub-object). + private func host(_ appState: AppState, tally: RenderTally) { + let root = VStack(spacing: 0) { + AppStateProbe(tally: tally) + EditorStateProbe(tally: tally) + } + .environmentObject(appState) + .environmentObject(appState.editorState) + let hosting = NSHostingView(rootView: root) + hosting.frame = NSRect(x: 0, y: 0, width: 100, height: 100) + window = NSWindow( + contentRect: hosting.frame, + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.contentView = hosting + window.layoutIfNeeded() + pump() + } + + /// Lets SwiftUI coalesce pending publishes and run its render pass. + private func pump(_ interval: TimeInterval = 0.05) { + RunLoop.main.run(until: Date().addingTimeInterval(interval)) + } + + func test_typingAndCaretSignals_doNotReEvaluateAppStateObserverBody() { + let appState = AppState() + let tally = RenderTally() + host(appState, tally: tally) + + XCTAssertGreaterThan(tally.count("appState"), 0, + "Harness sanity: hosting must evaluate the probe bodies at least once") + let appStateBaseline = tally.count("appState") + let editorStateBaseline = tally.count("editorState") + + // Simulate five keystrokes plus the caret/scroll signals the editor emits. + for i in 0..<5 { + appState.editorState.fileContent += "x" + appState.editorState.isDirty = true + appState.editorState.pendingCursorPosition = i + pump() + } + + XCTAssertEqual(tally.count("appState"), appStateBaseline, + "Typing re-evaluated an AppState-observing body — the keystroke path is publishing on AppState again (#258 regression)") + XCTAssertGreaterThan(tally.count("editorState"), editorStateBaseline, + "Harness sanity: EditorState observers must re-render on typing, otherwise this test proves nothing") + } + + func test_lowFrequencyAppStatePublish_stillInvalidatesAppStateObserverBody() { + let appState = AppState() + let tally = RenderTally() + host(appState, tally: tally) + + let baseline = tally.count("appState") + // Control: a genuine low-frequency @Published write (search toggle) must + // still invalidate AppState observers — proves the probe is live, so the + // flat count above cannot come from a dead binding. + appState.isSearchPresented = true + pump() + + XCTAssertGreaterThan(tally.count("appState"), baseline, + "Low-frequency AppState publishes must still re-render AppState observers") + } +} From 8ccef1ea5f415b16f4145ef8d714f07fff3f1ca5 Mon Sep 17 00:00:00 2001 From: dep Date: Wed, 10 Jun 2026 09:03:42 -0400 Subject: [PATCH 06/11] fix(dev): stop the keychain password prompt on every dev-build launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two causes, two fixes: - Debug builds were ad-hoc signed, so every rebuild produced a new code signature and the keychain ACL for the Anthropic key never matched. Debug now signs with the stable Apple Development identity (team 299R8V27FZ); one "Always Allow" sticks across rebuilds. CI is unaffected (tests.yml passes CODE_SIGNING_ALLOWED=NO). - The Settings scene constructs SettingsView at app launch, and its @State initializer read the keychain eagerly — so even an unchanged signature paid a keychain hit per launch. The key now loads lazily when the field appears, with a guard so the programmatic load does not echo back into a keychain write. Co-Authored-By: Claude Fable 5 --- macOS/Synapse Notes.xcodeproj/project.pbxproj | 7 +++++++ macOS/SynapseNotes/SettingsView.swift | 14 +++++++++++++- macOS/project.yml | 8 ++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/macOS/Synapse Notes.xcodeproj/project.pbxproj b/macOS/Synapse Notes.xcodeproj/project.pbxproj index 4ea7bb8..4e4d48b 100644 --- a/macOS/Synapse Notes.xcodeproj/project.pbxproj +++ b/macOS/Synapse Notes.xcodeproj/project.pbxproj @@ -876,6 +876,10 @@ BuildIndependentTargetsInParallel = YES; LastUpgradeCheck = 1600; TargetAttributes = { + E540C16C643A385615A9A437 = { + DevelopmentTeam = 299R8V27FZ; + ProvisioningStyle = Manual; + }; }; }; buildConfigurationList = FC17E84115FED15B8E080022 /* Build configuration list for PBXProject "Synapse Notes" */; @@ -1355,7 +1359,10 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = SynapseNotes/SynapseNotes.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = 299R8V27FZ; ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = SynapseNotes/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/macOS/SynapseNotes/SettingsView.swift b/macOS/SynapseNotes/SettingsView.swift index 0228c0a..22e3c88 100644 --- a/macOS/SynapseNotes/SettingsView.swift +++ b/macOS/SynapseNotes/SettingsView.swift @@ -21,7 +21,11 @@ struct SettingsView: View { @State private var templateVarsExpanded = false @State private var themeImportError: String? @State private var showThemeImportError = false - @State private var anthropicKey: String = KeychainStore().get() ?? "" + // Loaded lazily in onAppear: the Settings scene constructs this view at app + // launch, and an eager KeychainStore().get() here would hit the keychain on + // every launch (prompting whenever the build's code signature changes). + @State private var anthropicKey: String = "" + @State private var anthropicKeyLoaded = false private let settingsFieldWidth: CGFloat = 440 @@ -683,7 +687,15 @@ struct SettingsView: View { SecureField("sk-ant-...", text: $anthropicKey) .font(.system(.body, design: .monospaced)) .textFieldStyle(.roundedBorder) + .onAppear { + guard !anthropicKeyLoaded else { return } + anthropicKey = KeychainStore().get() ?? "" + // Flip the flag on the next runloop turn so the + // onChange from this programmatic load is skipped. + DispatchQueue.main.async { anthropicKeyLoaded = true } + } .onChange(of: anthropicKey) { newValue in + guard anthropicKeyLoaded else { return } KeychainStore().set(newValue) } diff --git a/macOS/project.yml b/macOS/project.yml index ae7d5f9..176bfcf 100644 --- a/macOS/project.yml +++ b/macOS/project.yml @@ -37,6 +37,14 @@ targets: INFOPLIST_FILE: SynapseNotes/Info.plist ENABLE_HARDENED_RUNTIME: YES configs: + Debug: + # Sign dev builds with a stable identity instead of ad-hoc: ad-hoc + # signatures change on every rebuild, so the keychain ACL for the + # Anthropic API key never matches and macOS re-prompts on each launch. + # With a stable identity, one "Always Allow" sticks across rebuilds. + CODE_SIGN_STYLE: Manual + DEVELOPMENT_TEAM: 299R8V27FZ + CODE_SIGN_IDENTITY: Apple Development Release: CODE_SIGN_STYLE: Manual DEVELOPMENT_TEAM: 299R8V27FZ From dc27e8c72a01ec3089b024448223186f45dff84e Mon Sep 17 00:00:00 2001 From: dep Date: Wed, 10 Jun 2026 09:23:56 -0400 Subject: [PATCH 07/11] fix(security): store the GitHub PAT in the Keychain, scrub it from settings files (#256) The PAT was serialized in plaintext into the legacy settings file and the machine-local global settings.yml. It now lives in KeychainStore(service: "com.SynapseNotes.github", account: "PAT"): - SettingsManager.githubPAT is a computed passthrough to an injectable SecretStore (defaults to the real KeychainStore; tests inject InMemorySecretStore), read lazily so launch never touches the keychain, with manual objectWillChange + a githubPATDidChange subject replacing the old $githubPAT projection. - One-time migration on every load path (legacy JSON/YAML Config and GlobalConfig): a file-resident PAT is moved to the Keychain and the scrubbed file persisted once. Save paths no longer write the PAT at all. - SettingsView PAT field uses the same lazy onAppear pattern as the Anthropic key so the Settings scene never reads the keychain eagerly. - Sign the SynapseTests bundle with the app's Debug identity: dyld refused to load the ad-hoc-signed bundle into the Apple Development-signed test host (pre-existing breakage from the dev-signing change). - Tests: PAT round-trips via the store and never lands on disk; migration covered for legacy JSON, legacy YAML, global-only, and vault modes. Co-Authored-By: Claude Fable 5 --- macOS/Synapse Notes.xcodeproj/project.pbxproj | 3 + macOS/SynapseNotes/AppState.swift | 2 +- macOS/SynapseNotes/SettingsManager.swift | 93 ++++++++++--- macOS/SynapseNotes/SettingsView.swift | 23 +++- .../SettingsManagerGitHubPATTests.swift | 130 +++++++++++++++--- .../SettingsManagerTests.swift | 25 ++-- macOS/project.yml | 8 ++ 7 files changed, 228 insertions(+), 56 deletions(-) diff --git a/macOS/Synapse Notes.xcodeproj/project.pbxproj b/macOS/Synapse Notes.xcodeproj/project.pbxproj index 4e4d48b..d995644 100644 --- a/macOS/Synapse Notes.xcodeproj/project.pbxproj +++ b/macOS/Synapse Notes.xcodeproj/project.pbxproj @@ -1276,7 +1276,10 @@ isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = 299R8V27FZ; GENERATE_INFOPLIST_FILE = YES; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/macOS/SynapseNotes/AppState.swift b/macOS/SynapseNotes/AppState.swift index 4604e45..5a8d77e 100644 --- a/macOS/SynapseNotes/AppState.swift +++ b/macOS/SynapseNotes/AppState.swift @@ -443,7 +443,7 @@ class AppState: ObservableObject { let appRefreshPublishers: [AnyPublisher] = [ settings.$dailyNotesEnabled.map { _ in () }.eraseToAnyPublisher(), settings.$hideMarkdownWhileEditing.map { _ in () }.eraseToAnyPublisher(), - settings.$githubPAT.map { _ in () }.eraseToAnyPublisher(), + settings.githubPATDidChange.eraseToAnyPublisher(), settings.$editorBodyFontFamily.map { _ in () }.eraseToAnyPublisher(), settings.$editorMonospaceFontFamily.map { _ in () }.eraseToAnyPublisher(), settings.$editorFontSize.map { _ in () }.eraseToAnyPublisher(), diff --git a/macOS/SynapseNotes/SettingsManager.swift b/macOS/SynapseNotes/SettingsManager.swift index 4ceb1a0..4740494 100644 --- a/macOS/SynapseNotes/SettingsManager.swift +++ b/macOS/SynapseNotes/SettingsManager.swift @@ -271,9 +271,32 @@ class SettingsManager: ObservableObject { @Published var collapsedSidebarIDs: Set { didSet { save() } } - @Published var githubPAT: String { - didSet { save() } + /// GitHub PAT, stored in the macOS Keychain — never written to settings files. + /// Computed (not @Published) so the keychain is only touched on first access, + /// never at launch; changes are published manually below. + var githubPAT: String { + get { + if let cachedGithubPAT { return cachedGithubPAT } + let value = githubPATStore.get() ?? "" + cachedGithubPAT = value + return value + } + set { + objectWillChange.send() + cachedGithubPAT = newValue + githubPATStore.set(newValue) + githubPATDidChange.send() + } } + /// Fires when the PAT changes (replaces the old `$githubPAT` projection). + let githubPATDidChange = PassthroughSubject() + /// Keychain slot for the GitHub PAT (injectable so tests use InMemorySecretStore). + private let githubPATStore: SecretStore + /// Lazily-populated cache so repeated reads don't hit the keychain. + private var cachedGithubPAT: String? + /// Set when a settings file still contained a plaintext PAT; the scrubbed file + /// is persisted once after the load completes. + private var pendingGithubPATScrub = false /// Default Anthropic model (API ID) for inline AI editing (machine-local). @Published var aiDefaultModel: String { didSet { save() } @@ -698,7 +721,6 @@ class SettingsManager: ObservableObject { var vaultPath: String? init( - githubPAT: String?, aiDefaultModel: String? = nil, sidebarPaneHeights: [String: CGFloat]?, collapsedPanes: [String]?, @@ -708,7 +730,7 @@ class SettingsManager: ObservableObject { vaultPaths: [String]? = nil, lastNoteFolderPerVault: [String: String]? = nil ) { - self.githubPAT = githubPAT + self.githubPAT = nil // PAT lives in the Keychain; field kept for migration decode only self.aiDefaultModel = aiDefaultModel self.sidebarPaneHeights = sidebarPaneHeights self.collapsedPanes = collapsedPanes @@ -735,18 +757,24 @@ class SettingsManager: ObservableObject { } } + /// Default Keychain slot for the GitHub PAT. + static func defaultGithubPATStore() -> SecretStore { + KeychainStore(service: "com.SynapseNotes.github", account: "PAT") + } + /// Initialize with default config path in Application Support (legacy mode for backward compatibility) - convenience init() { + convenience init(githubPATStore: SecretStore = SettingsManager.defaultGithubPATStore()) { let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] let configDir = appSupport.appendingPathComponent("Synapse") try? FileManager.default.createDirectory(at: configDir, withIntermediateDirectories: true) let configPath = configDir.appendingPathComponent(Self.globalSettingsFilename).path - self.init(configPath: configPath) + self.init(configPath: configPath, githubPATStore: githubPATStore) } /// Initialize with a specific config path (legacy mode, useful for testing) - init(configPath: String) { + init(configPath: String, githubPATStore: SecretStore = SettingsManager.defaultGithubPATStore()) { self.isInitializing = true + self.githubPATStore = githubPATStore self.configPath = configPath self.vaultRootURL = nil self.globalConfigPath = nil @@ -765,7 +793,6 @@ class SettingsManager: ObservableObject { self.sidebarPaneHeights = Self.defaultPaneHeights self.collapsedPanes = [] self.collapsedSidebarIDs = [FixedSidebar.right2ID.uuidString] - self.githubPAT = "" self.aiDefaultModel = AIModel.default.apiID self.fileTreeMode = .folder self.pinnedItems = [] @@ -786,13 +813,18 @@ class SettingsManager: ObservableObject { applyLegacyConfig(Self.loadConfig(from: configPath)) self.isInitializing = false + scrubGithubPATFromFileIfNeeded() } /// Initialize with vault root - stores settings in .synapse/settings.yml /// - Parameters: /// - vaultRoot: The vault root URL (nil means use defaults) /// - globalConfigPath: Optional path for global/sensitive settings (defaults to Application Support) - convenience init(vaultRoot: URL?, globalConfigPath: String? = nil) { + convenience init( + vaultRoot: URL?, + globalConfigPath: String? = nil, + githubPATStore: SecretStore = SettingsManager.defaultGithubPATStore() + ) { let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] let configDir = appSupport.appendingPathComponent("Synapse") try? FileManager.default.createDirectory(at: configDir, withIntermediateDirectories: true) @@ -800,13 +832,19 @@ class SettingsManager: ObservableObject { self.init( vaultRoot: vaultRoot, - globalConfigPath: globalConfigPath ?? defaultGlobalPath + globalConfigPath: globalConfigPath ?? defaultGlobalPath, + githubPATStore: githubPATStore ) } /// Full initializer with vault root and global config path - init(vaultRoot: URL?, globalConfigPath: String) { + init( + vaultRoot: URL?, + globalConfigPath: String, + githubPATStore: SecretStore = SettingsManager.defaultGithubPATStore() + ) { self.isInitializing = true + self.githubPATStore = githubPATStore self.configPath = vaultRoot?.appendingPathComponent(".synapse/\(Self.vaultSettingsFilename)").path ?? globalConfigPath self.vaultRootURL = vaultRoot self.globalConfigPath = globalConfigPath @@ -825,7 +863,6 @@ class SettingsManager: ObservableObject { self.sidebarPaneHeights = Self.defaultPaneHeights self.collapsedPanes = [] self.collapsedSidebarIDs = [FixedSidebar.right2ID.uuidString] - self.githubPAT = "" self.aiDefaultModel = AIModel.default.apiID self.fileTreeMode = .folder self.pinnedItems = [] @@ -857,6 +894,7 @@ class SettingsManager: ObservableObject { applyGlobalConfig(Self.loadGlobalConfig(from: globalConfigPath)) } self.isInitializing = false + scrubGithubPATFromFileIfNeeded() } deinit { @@ -897,7 +935,7 @@ class SettingsManager: ObservableObject { } else if isInitializing { collapsedSidebarIDs = [FixedSidebar.right2ID.uuidString] } - githubPAT = config.githubPAT ?? "" + migrateGithubPATFromFile(config.githubPAT) aiDefaultModel = config.aiDefaultModel ?? AIModel.default.apiID fileTreeMode = FileTreeMode(rawValue: config.fileTreeMode ?? "") ?? .folder pinnedItems = config.pinnedItems ?? [] @@ -929,7 +967,6 @@ class SettingsManager: ObservableObject { sidebarPaneHeights = Self.defaultPaneHeights collapsedPanes = [] collapsedSidebarIDs = [FixedSidebar.right2ID.uuidString] - githubPAT = "" aiDefaultModel = AIModel.default.apiID fileTreeMode = .folder pinnedItems = [] @@ -1035,7 +1072,7 @@ class SettingsManager: ObservableObject { } private func applyGlobalConfig(_ globalConfig: GlobalConfig?) { - githubPAT = globalConfig?.githubPAT ?? "" + migrateGithubPATFromFile(globalConfig?.githubPAT) aiDefaultModel = globalConfig?.aiDefaultModel ?? AIModel.default.apiID sidebars = Self.applyPaneAssignments(globalConfig?.sidebarPaneAssignments) sidebarPaneHeights = globalConfig?.sidebarPaneHeights ?? Self.defaultPaneHeights @@ -1057,9 +1094,27 @@ class SettingsManager: ObservableObject { lastNoteFolderPerVault = globalConfig?.lastNoteFolderPerVault ?? [:] } + /// One-time migration: a settings file that still contains a plaintext PAT has + /// it moved to the Keychain and the file scrubbed once the load completes. + private func migrateGithubPATFromFile(_ filePAT: String?) { + guard let filePAT, !filePAT.isEmpty else { return } + githubPAT = filePAT + pendingGithubPATScrub = true + } + + /// Persists the settings file once, without the PAT, after a migration. + private func scrubGithubPATFromFileIfNeeded() { + guard pendingGithubPATScrub else { return } + pendingGithubPATScrub = false + flush() + } + func reloadFromDisk() { isApplyingExternalChange = true - defer { isApplyingExternalChange = false } + defer { + isApplyingExternalChange = false + scrubGithubPATFromFileIfNeeded() + } if useLegacyMode { applyLegacyConfig(Self.loadConfig(from: configPath)) @@ -1215,7 +1270,6 @@ class SettingsManager: ObservableObject { let sidebarPaneHeights: [String: CGFloat] let collapsedPanes: [String] let collapsedSidebarIDs: [String] - let githubPAT: String let aiDefaultModel: String let fileTreeMode: FileTreeMode let pinnedItems: [PinnedItem] @@ -1255,7 +1309,6 @@ class SettingsManager: ObservableObject { sidebarPaneHeights = s.sidebarPaneHeights collapsedPanes = Array(s.collapsedPanes) collapsedSidebarIDs = Array(s.collapsedSidebarIDs) - githubPAT = s.githubPAT aiDefaultModel = s.aiDefaultModel fileTreeMode = s.fileTreeMode pinnedItems = s.pinnedItems @@ -1292,7 +1345,6 @@ class SettingsManager: ObservableObject { private func writeGlobalOnly() { guard let globalConfigPath else { return } let globalConfig = GlobalConfig( - githubPAT: githubPAT.isEmpty ? nil : githubPAT, aiDefaultModel: aiDefaultModel, sidebarPaneHeights: sidebarPaneHeights.isEmpty ? nil : sidebarPaneHeights, collapsedPanes: collapsedPanes.isEmpty ? nil : collapsedPanes, @@ -1327,7 +1379,6 @@ class SettingsManager: ObservableObject { var sidebarPaneHeights: [String: CGFloat]? var collapsedPanes: [String]? var collapsedSidebarIDs: [String]? - var githubPAT: String? var aiDefaultModel: String? var fileTreeMode: String? var pinnedItems: [PinnedItem]? @@ -1358,7 +1409,6 @@ class SettingsManager: ObservableObject { sidebarPaneHeights: sidebarPaneHeights.isEmpty ? nil : sidebarPaneHeights, collapsedPanes: collapsedPanes.isEmpty ? nil : collapsedPanes, collapsedSidebarIDs: collapsedSidebarIDs.isEmpty ? nil : collapsedSidebarIDs, - githubPAT: githubPAT.isEmpty ? nil : githubPAT, aiDefaultModel: aiDefaultModel.isEmpty ? nil : aiDefaultModel, fileTreeMode: fileTreeMode.rawValue, pinnedItems: pinnedItems.isEmpty ? nil : pinnedItems, @@ -1417,7 +1467,6 @@ class SettingsManager: ObservableObject { guard let globalConfigPath else { return } let globalConfig = GlobalConfig( - githubPAT: githubPAT.isEmpty ? nil : githubPAT, aiDefaultModel: aiDefaultModel, sidebarPaneHeights: sidebarPaneHeights.isEmpty ? nil : sidebarPaneHeights, collapsedPanes: collapsedPanes.isEmpty ? nil : collapsedPanes, diff --git a/macOS/SynapseNotes/SettingsView.swift b/macOS/SynapseNotes/SettingsView.swift index 22e3c88..4ec8213 100644 --- a/macOS/SynapseNotes/SettingsView.swift +++ b/macOS/SynapseNotes/SettingsView.swift @@ -26,6 +26,10 @@ struct SettingsView: View { // every launch (prompting whenever the build's code signature changes). @State private var anthropicKey: String = "" @State private var anthropicKeyLoaded = false + // Same lazy pattern: the GitHub PAT now lives in the Keychain, so the field + // is local state loaded in onAppear instead of a binding into SettingsManager. + @State private var githubPAT: String = "" + @State private var githubPATLoaded = false private let settingsFieldWidth: CGFloat = 440 @@ -638,20 +642,31 @@ struct SettingsView: View { .foregroundStyle(.secondary) HStack(spacing: 8) { - SecureField("ghp_...", text: $settings.githubPAT) + SecureField("ghp_...", text: $githubPAT) .font(.system(.body, design: .monospaced)) .textFieldStyle(.roundedBorder) + .onAppear { + guard !githubPATLoaded else { return } + githubPAT = settings.githubPAT + // Flip the flag on the next runloop turn so the + // onChange from this programmatic load is skipped. + DispatchQueue.main.async { githubPATLoaded = true } + } + .onChange(of: githubPAT) { newValue in + guard githubPATLoaded else { return } + settings.githubPAT = newValue + } - if settings.hasGitHubPAT { + if !githubPAT.isEmpty { Button("Clear") { - settings.githubPAT = "" + githubPAT = "" } .font(.system(size: 11)) .foregroundStyle(.red) } } - Text("Used to publish notes to public GitHub Gists. The token needs the 'gist' scope.") + Text("Stored securely in your macOS Keychain. Used to publish notes to public GitHub Gists. The token needs the 'gist' scope.") .font(.system(size: 11, weight: .medium, design: .rounded)) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) diff --git a/macOS/SynapseNotesTests/SettingsManagerGitHubPATTests.swift b/macOS/SynapseNotesTests/SettingsManagerGitHubPATTests.swift index 7d98cf1..89faa95 100644 --- a/macOS/SynapseNotesTests/SettingsManagerGitHubPATTests.swift +++ b/macOS/SynapseNotesTests/SettingsManagerGitHubPATTests.swift @@ -1,9 +1,12 @@ import XCTest @testable import Synapse -/// Tests for GitHub PAT (Personal Access Token) setting in SettingsManager +/// Tests for GitHub PAT (Personal Access Token) setting in SettingsManager. +/// The PAT lives in a `SecretStore` (the Keychain in production); tests inject an +/// `InMemorySecretStore` so the system keychain is never touched (#256). final class SettingsManagerGitHubPATTests: XCTestCase { var sut: SettingsManager! + var store: InMemorySecretStore! var tempDir: URL! var configFilePath: String! @@ -13,12 +16,14 @@ final class SettingsManagerGitHubPATTests: XCTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) try! FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) configFilePath = tempDir.appendingPathComponent("Synapse-settings.json").path - sut = SettingsManager(configPath: configFilePath) + store = InMemorySecretStore() + sut = SettingsManager(configPath: configFilePath, githubPATStore: store) } override func tearDown() { try? FileManager.default.removeItem(at: tempDir) sut = nil + store = nil super.tearDown() } @@ -40,28 +45,53 @@ final class SettingsManagerGitHubPATTests: XCTestCase { sut.githubPAT = "ghp_1234567890abcdef" sut.githubPAT = "" XCTAssertEqual(sut.githubPAT, "") + XCTAssertNil(store.get(), "Clearing the PAT should delete it from the secret store") } - func test_githubPAT_persistsToDisk() { + // MARK: - Keychain Persistence (never the settings file) + + func test_githubPAT_persistsViaSecretStore_notDisk() { let token = "ghp_persistencetest123" sut.githubPAT = token + // Force a settings-file write (PAT itself no longer triggers one). + sut.autoSave = true + + XCTAssertEqual(store.get(), token, "GitHub PAT should be written to the secret store") + let contents = try! String(contentsOfFile: configFilePath, encoding: .utf8) + XCTAssertFalse(contents.contains(token), "GitHub PAT must never be written to the settings file") + + // A new instance sharing the same store retrieves the token. + let newManager = SettingsManager(configPath: configFilePath, githubPATStore: store) + XCTAssertEqual(newManager.githubPAT, token, "GitHub PAT should round-trip via the secret store") - // Create new instance pointing to same config file - let newManager = SettingsManager(configPath: configFilePath) - XCTAssertEqual(newManager.githubPAT, token, "GitHub PAT should persist to disk") + // A new instance with a fresh store sees nothing (proves the file holds no PAT). + let freshManager = SettingsManager(configPath: configFilePath, githubPATStore: InMemorySecretStore()) + XCTAssertEqual(freshManager.githubPAT, "", "GitHub PAT must not be recoverable from disk") } - // MARK: - Setting Triggers Save + // MARK: - Setting Publishes Change - func test_settingGithubPAT_triggersSave() { - var saveCount = 0 + func test_settingGithubPAT_publishesObjectWillChange() { + var changeCount = 0 let cancellable = sut.objectWillChange.sink { _ in - saveCount += 1 + changeCount += 1 + } + + sut.githubPAT = "ghp_test123" + + XCTAssertGreaterThanOrEqual(changeCount, 1, "Setting GitHub PAT should publish objectWillChange") + cancellable.cancel() + } + + func test_settingGithubPAT_firesGithubPATDidChange() { + var changeCount = 0 + let cancellable = sut.githubPATDidChange.sink { _ in + changeCount += 1 } sut.githubPAT = "ghp_test123" - XCTAssertGreaterThanOrEqual(saveCount, 1, "Setting GitHub PAT should trigger save notification") + XCTAssertEqual(changeCount, 1, "Setting GitHub PAT should fire githubPATDidChange") cancellable.cancel() } @@ -79,11 +109,13 @@ final class SettingsManagerGitHubPATTests: XCTestCase { let data = try! JSONSerialization.data(withJSONObject: config) try! data.write(to: URL(fileURLWithPath: configFilePath)) - let newManager = SettingsManager(configPath: configFilePath) + let newManager = SettingsManager(configPath: configFilePath, githubPATStore: InMemorySecretStore()) XCTAssertEqual(newManager.githubPAT, "", "Missing GitHub PAT should default to empty") } - func test_load_withGitHubPAT() { + // MARK: - One-time Migration to the Secret Store + + func test_load_legacyJSONWithGitHubPAT_migratesToSecretStoreAndScrubsFile() { let token = "ghp_loadedfromconfig" let config: [String: Any] = [ "onBootCommand": "", @@ -96,8 +128,68 @@ final class SettingsManagerGitHubPATTests: XCTestCase { let data = try! JSONSerialization.data(withJSONObject: config) try! data.write(to: URL(fileURLWithPath: configFilePath)) - let newManager = SettingsManager(configPath: configFilePath) - XCTAssertEqual(newManager.githubPAT, token) + let migrationStore = InMemorySecretStore() + let newManager = SettingsManager(configPath: configFilePath, githubPATStore: migrationStore) + + XCTAssertEqual(newManager.githubPAT, token, "Migrated PAT should remain available in memory") + XCTAssertEqual(migrationStore.get(), token, "Migration should write the PAT to the secret store") + let contents = try! String(contentsOfFile: configFilePath, encoding: .utf8) + XCTAssertFalse(contents.contains(token), "Migration should scrub the PAT from the settings file") + } + + func test_load_legacyYAMLWithGitHubPAT_migratesToSecretStoreAndScrubsFile() { + let token = "ghp_yamlmigration" + let yaml = """ + onBootCommand: "" + fileExtensionFilter: "*.md, *.txt" + templatesDirectory: templates + autoSave: false + autoPush: false + githubPAT: \(token) + """ + try! yaml.write(toFile: configFilePath, atomically: true, encoding: .utf8) + + let migrationStore = InMemorySecretStore() + let newManager = SettingsManager(configPath: configFilePath, githubPATStore: migrationStore) + + XCTAssertEqual(newManager.githubPAT, token, "Migrated PAT should remain available in memory") + XCTAssertEqual(migrationStore.get(), token, "Migration should write the PAT to the secret store") + let contents = try! String(contentsOfFile: configFilePath, encoding: .utf8) + XCTAssertFalse(contents.contains(token), "Migration should scrub the PAT from the settings file") + } + + func test_load_globalConfigWithGitHubPAT_migratesToSecretStoreAndScrubsFile() { + let token = "ghp_globalmigration" + let globalPath = tempDir.appendingPathComponent("global-settings.yml").path + try! "githubPAT: \(token)\n".write(toFile: globalPath, atomically: true, encoding: .utf8) + + let migrationStore = InMemorySecretStore() + let manager = SettingsManager(vaultRoot: nil, globalConfigPath: globalPath, githubPATStore: migrationStore) + + XCTAssertEqual(manager.githubPAT, token, "Migrated PAT should remain available in memory") + XCTAssertEqual(migrationStore.get(), token, "Migration should write the PAT to the secret store") + let contents = try! String(contentsOfFile: globalPath, encoding: .utf8) + XCTAssertFalse(contents.contains(token), "Migration should scrub the PAT from the global settings file") + } + + func test_load_vaultModeGlobalConfigWithGitHubPAT_migratesAndScrubsAllFiles() { + let token = "ghp_vaultmodemigration" + let vaultDir = tempDir.appendingPathComponent("vault", isDirectory: true) + try! FileManager.default.createDirectory(at: vaultDir, withIntermediateDirectories: true) + let globalPath = tempDir.appendingPathComponent("global-settings.yml").path + try! "githubPAT: \(token)\n".write(toFile: globalPath, atomically: true, encoding: .utf8) + + let migrationStore = InMemorySecretStore() + let manager = SettingsManager(vaultRoot: vaultDir, globalConfigPath: globalPath, githubPATStore: migrationStore) + + XCTAssertEqual(manager.githubPAT, token, "Migrated PAT should remain available in memory") + XCTAssertEqual(migrationStore.get(), token, "Migration should write the PAT to the secret store") + let globalContents = try! String(contentsOfFile: globalPath, encoding: .utf8) + XCTAssertFalse(globalContents.contains(token), "Migration should scrub the PAT from the global settings file") + let vaultSettingsPath = vaultDir.appendingPathComponent(".synapse/settings.yml").path + if let vaultContents = try? String(contentsOfFile: vaultSettingsPath, encoding: .utf8) { + XCTAssertFalse(vaultContents.contains(token), "The vault settings file must never contain the PAT") + } } // MARK: - Token Presence Check @@ -136,16 +228,16 @@ final class SettingsManagerGitHubPATTests: XCTestCase { } func test_flushDebouncedSaveBeforeReloadIfNeeded_settingsSurviveSubsequentReload() { - // Change a setting (which in tests flushes synchronously to disk), - // call flushDebouncedSaveBeforeReloadIfNeeded (no-op since save was immediate), - // then reload from disk. The new value must survive because it was persisted. + // Change the PAT (which lands in the secret store, not the file), + // call flushDebouncedSaveBeforeReloadIfNeeded (no-op since saves are immediate), + // then reload from disk. The value must survive because it lives in the store. sut.githubPAT = "ghp_survivetest" sut.flushDebouncedSaveBeforeReloadIfNeeded() sut.reloadFromDisk() XCTAssertEqual(sut.githubPAT, "ghp_survivetest", - "githubPAT should survive a reload when it was already flushed to disk") + "githubPAT should survive a reload because it lives in the secret store") } func test_flushDebouncedSaveBeforeReloadIfNeeded_multipleCallsAreIdempotent() { diff --git a/macOS/SynapseNotesTests/SettingsManagerTests.swift b/macOS/SynapseNotesTests/SettingsManagerTests.swift index daaeca2..cb2adb7 100644 --- a/macOS/SynapseNotesTests/SettingsManagerTests.swift +++ b/macOS/SynapseNotesTests/SettingsManagerTests.swift @@ -501,7 +501,7 @@ final class SettingsManagerTests: XCTestCase { ".synapse folder should be created automatically") } - func test_vaultSpecificSettings_githubPATStaysInApplicationSupport() { + func test_vaultSpecificSettings_githubPATGoesToSecretStoreNotSettingsFiles() { let vaultDir = tempDir.appendingPathComponent("TestVault", isDirectory: true) try! FileManager.default.createDirectory(at: vaultDir, withIntermediateDirectories: true) @@ -510,20 +510,25 @@ final class SettingsManagerTests: XCTestCase { try! FileManager.default.createDirectory(at: appSupportDir, withIntermediateDirectories: true) let globalConfigPath = appSupportDir.appendingPathComponent("settings.yml").path - // Initialize manager with both vault root and global config path - var manager = SettingsManager(vaultRoot: vaultDir, globalConfigPath: globalConfigPath) + // Initialize manager with both vault root, global config path, and an + // in-memory secret store (the PAT lives in the Keychain in production) + let store = InMemorySecretStore() + let manager = SettingsManager(vaultRoot: vaultDir, globalConfigPath: globalConfigPath, githubPATStore: store) - // Set githubPAT - should go to global config + // Set githubPAT - should go to the secret store only manager.githubPAT = "ghp_test_token" + // Force a settings-file write so both files exist for inspection + manager.autoSave = true - // Verify token was saved to global config, not vault config - let notedSettingsFile = vaultDir.appendingPathComponent(".synapse/settings.yml") + XCTAssertEqual(store.get(), "ghp_test_token", + "githubPAT should be saved to the secret store") + + // Verify token is in neither settings file let globalText = try! String(contentsOfFile: globalConfigPath, encoding: .utf8) - XCTAssertTrue(globalText.contains("githubPAT:")) - XCTAssertTrue(globalText.contains("ghp_test_token"), - "githubPAT should be saved to global config") + XCTAssertFalse(globalText.contains("ghp_test_token"), + "githubPAT should NOT be saved to the global config") - // Verify token is NOT in vault config + let notedSettingsFile = vaultDir.appendingPathComponent(".synapse/settings.yml") let vaultText = try! String(contentsOf: notedSettingsFile, encoding: .utf8) XCTAssertFalse(vaultText.contains("githubPAT:"), "githubPAT should NOT be saved to vault-specific config") diff --git a/macOS/project.yml b/macOS/project.yml index 176bfcf..b26c0ea 100644 --- a/macOS/project.yml +++ b/macOS/project.yml @@ -72,5 +72,13 @@ targets: GENERATE_INFOPLIST_FILE: YES TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Synapse Notes.app/Contents/MacOS/Synapse Notes" BUNDLE_LOADER: "$(TEST_HOST)" + configs: + Debug: + # Must match the app target's signing: dyld refuses to load a test + # bundle whose Team ID differs from the (Manual, Apple Development) + # signed test host. + CODE_SIGN_STYLE: Manual + DEVELOPMENT_TEAM: 299R8V27FZ + CODE_SIGN_IDENTITY: Apple Development dependencies: - target: Synapse From 286afaba66eddf84ab8a4834a13284c3ad1da432 Mon Sep 17 00:00:00 2001 From: dep Date: Wed, 10 Jun 2026 09:32:18 -0400 Subject: [PATCH 08/11] refactor(editor): extract preview, embeds, completion, code-block, HTML converter types from EditorView.swift (#253) Pure file moves, no logic changes. Co-Authored-By: Claude Fable 5 --- macOS/Synapse Notes.xcodeproj/project.pbxproj | 20 + .../CompletionViewController.swift | 153 ++ macOS/SynapseNotes/EditorEmbeds.swift | 1011 +++++++++ macOS/SynapseNotes/EditorView.swift | 1846 ----------------- .../HTMLToMarkdownConverter.swift | 178 ++ .../LinkAwareTextView+CodeBlocks.swift | 211 ++ macOS/SynapseNotes/MarkdownPreviewView.swift | 301 +++ 7 files changed, 1874 insertions(+), 1846 deletions(-) create mode 100644 macOS/SynapseNotes/CompletionViewController.swift create mode 100644 macOS/SynapseNotes/EditorEmbeds.swift create mode 100644 macOS/SynapseNotes/HTMLToMarkdownConverter.swift create mode 100644 macOS/SynapseNotes/LinkAwareTextView+CodeBlocks.swift create mode 100644 macOS/SynapseNotes/MarkdownPreviewView.swift diff --git a/macOS/Synapse Notes.xcodeproj/project.pbxproj b/macOS/Synapse Notes.xcodeproj/project.pbxproj index d995644..b166905 100644 --- a/macOS/Synapse Notes.xcodeproj/project.pbxproj +++ b/macOS/Synapse Notes.xcodeproj/project.pbxproj @@ -154,6 +154,7 @@ 957ABB20E1B7261C147EB20B /* CloneRepositoryValidation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73E7A1F6FF2B92F1AECD9D29 /* CloneRepositoryValidation.swift */; }; 959D9634AF2D016B84F507A8 /* AppStateInactivePaneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5060FDAEE02A38EAF4A158AA /* AppStateInactivePaneTests.swift */; }; 96B28BA0C6A304D9848A90F0 /* SettingsManagerSidebarCollapseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E38F6CCA42696C5E82B8FC99 /* SettingsManagerSidebarCollapseTests.swift */; }; + 98C2A8CA9BCEDDDB24509D4A /* LinkAwareTextView+CodeBlocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F46700D49A2DD8455C9A8A /* LinkAwareTextView+CodeBlocks.swift */; }; 991801887FAA387CAAC84D8F /* MarkdownPreviewCSS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20765639CD137EFA64E5F828 /* MarkdownPreviewCSS.swift */; }; 994956DA61AD1E367D94DD97 /* AppThemeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AF93A428A55CD52A1488410 /* AppThemeTests.swift */; }; 996A1FBF94C507E03B920DD7 /* PinnedItemStructTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71A577424461F3671C9EB9EF /* PinnedItemStructTests.swift */; }; @@ -178,6 +179,7 @@ AAC297094394859DB2403374 /* MarkdownPreviewSemanticHiding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74BC44457745DCE1B0D81D45 /* MarkdownPreviewSemanticHiding.swift */; }; AC269AD6EEEC8B445F9CC317 /* AIRequestBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60E22BED5A6410F36911EDA6 /* AIRequestBuilderTests.swift */; }; AC5C25866472910EC05AC2A0 /* HTMLToMarkdownTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 987E1B649BAA6DBF8B062EE1 /* HTMLToMarkdownTests.swift */; }; + ADC8FE44A1B312FF01646B11 /* HTMLToMarkdownConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCBC3A64E0A563CA932CFD31 /* HTMLToMarkdownConverter.swift */; }; ADE218C972D4E4C39328E2C5 /* SortCriterionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47D8A00BEAC46C6928C5AE28 /* SortCriterionTests.swift */; }; AEF05E2787758819B9941B3C /* SettingsManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCACF2EB4506E8D0CECF05B /* SettingsManagerTests.swift */; }; AF15A2DF68D23F38C7772509 /* FontEnumerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18771A5B6B615DDCC2CC193 /* FontEnumerator.swift */; }; @@ -195,11 +197,13 @@ BFD06E40F78AF2CAF21D04AF /* AppConstantsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7655C25E6F669827478D643B /* AppConstantsTests.swift */; }; C0C12C29ABE66D89283421F4 /* AppStateContentChangeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3642C0B4C5DF51584C3FB7C7 /* AppStateContentChangeTests.swift */; }; C176C8B90116F30D3D431F48 /* TemplatesDirectoryUIBehaviorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC9952C7207C6BFB3BF12CC /* TemplatesDirectoryUIBehaviorTests.swift */; }; + C221AD814CD393798CE0409B /* EditorEmbeds.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C319D8BD16361045EE21864 /* EditorEmbeds.swift */; }; C2679A2BD98BDFA5FA41B266 /* MarkdownEditorSemanticStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC7C3FC87225B414EB23A27C /* MarkdownEditorSemanticStyles.swift */; }; C2EB58E96CB1A5C4D271DF04 /* GistPublisherHTTPTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9019329C5553BD93867161D /* GistPublisherHTTPTests.swift */; }; C42C30EBF243BBE671B3D4FB /* SettingsManagerApplyPaneAssignmentsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 703D69E0596A8F0C009BA65F /* SettingsManagerApplyPaneAssignmentsTests.swift */; }; C4901425F2A54D5194A54B73 /* Grape in Frameworks */ = {isa = PBXBuildFile; productRef = 2658D8745056E194D47E3EC6 /* Grape */; }; C60016AD09C3CA58EA20A253 /* MarkdownCalloutStructTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56EEC9EFE247488B9CFFCDED /* MarkdownCalloutStructTests.swift */; }; + C74F6D9935DBBEB345A2F094 /* MarkdownPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F58C010C3BC248AEA35CE843 /* MarkdownPreviewView.swift */; }; C8D4635C3B403E5960D306D2 /* DatePageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C730F7180CA55FDC105228A3 /* DatePageView.swift */; }; C97E2015192AD6F3D3E38903 /* MarkdownTaskCheckboxToggleMarkerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D809B3131FC64DC7BF49CC6 /* MarkdownTaskCheckboxToggleMarkerTests.swift */; }; CC0C3EB6FCED4C362CEB5919 /* AppStateRelatedLinksTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86AA4CD7217A37F061FC14C /* AppStateRelatedLinksTests.swift */; }; @@ -249,6 +253,7 @@ F6170085EEDEC0FEFB29B4F8 /* AIModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C28BA5BF6AF2A285C81A84C /* AIModel.swift */; }; F661197178F1AD653D16FEE7 /* GitStageChangesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9597F3D23EECF35FB13CAEED /* GitStageChangesTests.swift */; }; F6CEF5866E01779FB2F5DA02 /* AppStateUntitledNoteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85BF72CADB7EA1F317CA73F7 /* AppStateUntitledNoteTests.swift */; }; + F75A08E672F234FCAEA24DD2 /* CompletionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 216137B83F317ACE119704B4 /* CompletionViewController.swift */; }; F7DEAD2997B96D0BFE19C7F9 /* RelatedLinksTitleText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110835FD9934C200B95DBEB3 /* RelatedLinksTitleText.swift */; }; F85C77A43A8701D0AD9143BE /* AppStateTabsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E86DAF95F24BAB761C81A8 /* AppStateTabsTests.swift */; }; F9161E6876F8449ED7794D80 /* MarkdownCalloutDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9182F671271FBBF2DE43D1E /* MarkdownCalloutDetectorTests.swift */; }; @@ -299,6 +304,7 @@ 1FAFBA8491471536C682215C /* FolderAppearancePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderAppearancePicker.swift; sourceTree = ""; }; 20765639CD137EFA64E5F828 /* MarkdownPreviewCSS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownPreviewCSS.swift; sourceTree = ""; }; 2080178498715BA2E9CE7F9C /* VaultIndexTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultIndexTests.swift; sourceTree = ""; }; + 216137B83F317ACE119704B4 /* CompletionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionViewController.swift; sourceTree = ""; }; 229E65CDC839E51F300D87D0 /* SettingsManagerPaneHeightsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManagerPaneHeightsTests.swift; sourceTree = ""; }; 23FF4B814CF36CBFA3E891F9 /* MarkdownPreviewCSSTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownPreviewCSSTests.swift; sourceTree = ""; }; 241B9481E6C74B5E5EEBAB08 /* AppStateSaveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateSaveTests.swift; sourceTree = ""; }; @@ -386,6 +392,7 @@ 7A6820DD3112C970C66C5BD3 /* AppStateWikiLinkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateWikiLinkTests.swift; sourceTree = ""; }; 7B55AA96B17F979E4F856BA0 /* AppStatePinnedFolderFocusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStatePinnedFolderFocusTests.swift; sourceTree = ""; }; 7BD9A4F4664E7E7F5F405135 /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = ""; }; + 7C319D8BD16361045EE21864 /* EditorEmbeds.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorEmbeds.swift; sourceTree = ""; }; 7C9A395C004CF88B96255AB4 /* GitService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitService.swift; sourceTree = ""; }; 7DAAF8FF8C6EFD3D4FCA4AB9 /* AppStateSettingsPropagationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateSettingsPropagationTests.swift; sourceTree = ""; }; 7F11197598BEFCA9CE509634 /* EditorState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorState.swift; sourceTree = ""; }; @@ -477,11 +484,13 @@ C9019329C5553BD93867161D /* GistPublisherHTTPTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GistPublisherHTTPTests.swift; sourceTree = ""; }; C9EC7BC6D2DE1E9F16B99605 /* FSEventsVaultWatcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FSEventsVaultWatcherTests.swift; sourceTree = ""; }; CC9A2CCFC8D76F6FA9B12305 /* NavigationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationState.swift; sourceTree = ""; }; + CCBC3A64E0A563CA932CFD31 /* HTMLToMarkdownConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLToMarkdownConverter.swift; sourceTree = ""; }; CDC255B3F17982D1DA9B927B /* SynapseNotesApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SynapseNotesApp.swift; sourceTree = ""; }; CE33CEA66E67EC988C94FC5E /* SettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManager.swift; sourceTree = ""; }; CFE935FC16158D93B5B6C05C /* TabBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarView.swift; sourceTree = ""; }; D00CA4844F0E36301A2C61A6 /* FolderAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderAppearance.swift; sourceTree = ""; }; D0C6708A91911EC9011CB953 /* InlineAIController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineAIController.swift; sourceTree = ""; }; + D0F46700D49A2DD8455C9A8A /* LinkAwareTextView+CodeBlocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LinkAwareTextView+CodeBlocks.swift"; sourceTree = ""; }; D27CF1236F7775109B48756D /* FileSearchResultTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSearchResultTests.swift; sourceTree = ""; }; D2EC80568C4675330252028C /* AppStateGitDateFilteringTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateGitDateFilteringTests.swift; sourceTree = ""; }; D3B6B8AE43A5A53AC28EC7C0 /* EditorFontStylingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorFontStylingTests.swift; sourceTree = ""; }; @@ -514,6 +523,7 @@ F02B068438E71AE98EB1FE80 /* GistPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GistPublisher.swift; sourceTree = ""; }; F218C386AFD56F637DC8F3C6 /* AsyncFileScanTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncFileScanTests.swift; sourceTree = ""; }; F2A7E7997B38846604684C1E /* AppStateGitErrorSurfacingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateGitErrorSurfacingTests.swift; sourceTree = ""; }; + F58C010C3BC248AEA35CE843 /* MarkdownPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownPreviewView.swift; sourceTree = ""; }; F604CE4ECF1D7FF3CA1A636B /* VaultIndexRecencyMirrorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultIndexRecencyMirrorTests.swift; sourceTree = ""; }; F638E6858D9C03A15E702690 /* AppStatePendingSearchQueryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStatePendingSearchQueryTests.swift; sourceTree = ""; }; F6C670F1D06F1E8C530EEAA5 /* EditorStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorStateTests.swift; sourceTree = ""; }; @@ -751,10 +761,12 @@ 73E7A1F6FF2B92F1AECD9D29 /* CloneRepositoryValidation.swift */, 41A855494502914F7C204B02 /* CollapsibleSection.swift */, 3DA805991148562D94A36617 /* CommandPaletteView.swift */, + 216137B83F317ACE119704B4 /* CompletionViewController.swift */, 24C81DC01DE3F2CA6D1DC3F8 /* Constants.swift */, 4F38622C9378AF531F95E05B /* ContentView.swift */, 29CAD8A0B830B328FC5769EB /* DatePageFormatting.swift */, C730F7180CA55FDC105228A3 /* DatePageView.swift */, + 7C319D8BD16361045EE21864 /* EditorEmbeds.swift */, 70A8A5CC2F996769E0CD694C /* EditorModeToggle.swift */, 7F11197598BEFCA9CE509634 /* EditorState.swift */, 481E479A033EE81D9CF886E5 /* EditorTabRouter.swift */, @@ -768,10 +780,12 @@ 7C9A395C004CF88B96255AB4 /* GitService.swift */, 5F447EE62D897ADFD26A3A2D /* GlobalGraphView.swift */, B50320B1919639905251EA20 /* GraphPaneView.swift */, + CCBC3A64E0A563CA932CFD31 /* HTMLToMarkdownConverter.swift */, 658EA3321387C8DF857E0932 /* Info.plist */, D0C6708A91911EC9011CB953 /* InlineAIController.swift */, 9B54D87558101D4D0356C986 /* InlineAIView.swift */, 77494AFD9121ABE56BF45594 /* KeychainStore.swift */, + D0F46700D49A2DD8455C9A8A /* LinkAwareTextView+CodeBlocks.swift */, E1E3D357F071ACBF2016AEA7 /* MarkdownCallout.swift */, 47E58E5951953EF520E265CE /* MarkdownDocument.swift */, 0EFF1A55AEF13CEE5C86DD71 /* MarkdownEditorInlineSemanticStyles.swift */, @@ -782,6 +796,7 @@ 822CD34D3501462C7E54EE9C /* MarkdownPreviewCursorReveal.swift */, F9C95EF39655CB3BACA6E413 /* MarkdownPreviewRenderer.swift */, 74BC44457745DCE1B0D81D45 /* MarkdownPreviewSemanticHiding.swift */, + F58C010C3BC248AEA35CE843 /* MarkdownPreviewView.swift */, 1F8CCC8DE0551D8C09D24609 /* MarkdownTablePrettifier.swift */, 899E05E166DE4C34A16EF3A1 /* MarkdownTaskCheckboxInteraction.swift */, C5EB1051089CA28E0244725C /* MiniBrowserPaneView.swift */, @@ -935,10 +950,12 @@ 957ABB20E1B7261C147EB20B /* CloneRepositoryValidation.swift in Sources */, 8E30A2170B73EFD72D4574A5 /* CollapsibleSection.swift in Sources */, FFB6DAEB709AE113A8039AC1 /* CommandPaletteView.swift in Sources */, + F75A08E672F234FCAEA24DD2 /* CompletionViewController.swift in Sources */, 4B7D0E530EAA13F6B125ACB7 /* Constants.swift in Sources */, EDCCE56A291B55521F4250C3 /* ContentView.swift in Sources */, 7EF6F83BC70CEA84185D7A40 /* DatePageFormatting.swift in Sources */, C8D4635C3B403E5960D306D2 /* DatePageView.swift in Sources */, + C221AD814CD393798CE0409B /* EditorEmbeds.swift in Sources */, 3C12E6F17FBD8619EC3669BC /* EditorModeToggle.swift in Sources */, 2913A93CEC3CE4AAE7C0A377 /* EditorState.swift in Sources */, DC7059B2DEAEA3EF197026B7 /* EditorTabRouter.swift in Sources */, @@ -952,9 +969,11 @@ 0A28812686DE4A1A204E87BC /* GitService.swift in Sources */, 8B03F86871F20001465C62CD /* GlobalGraphView.swift in Sources */, 9E2F95A036FB7A5B399F7795 /* GraphPaneView.swift in Sources */, + ADC8FE44A1B312FF01646B11 /* HTMLToMarkdownConverter.swift in Sources */, 2F2E76C68073C0922244D1E7 /* InlineAIController.swift in Sources */, CC260DA9055067DAF97068CA /* InlineAIView.swift in Sources */, FB84B6C8F183243641D2590E /* KeychainStore.swift in Sources */, + 98C2A8CA9BCEDDDB24509D4A /* LinkAwareTextView+CodeBlocks.swift in Sources */, A6D4D7C24F4ABD2CAA465097 /* MarkdownCallout.swift in Sources */, CDD840A586D7F0EC12F9A75A /* MarkdownDocument.swift in Sources */, 3ADA57036C249201F69BFD3E /* MarkdownEditorInlineSemanticStyles.swift in Sources */, @@ -965,6 +984,7 @@ 3CD1E9CA022A6228FB48D629 /* MarkdownPreviewCursorReveal.swift in Sources */, 33CA9313B45341731D44A4E8 /* MarkdownPreviewRenderer.swift in Sources */, AAC297094394859DB2403374 /* MarkdownPreviewSemanticHiding.swift in Sources */, + C74F6D9935DBBEB345A2F094 /* MarkdownPreviewView.swift in Sources */, 47E834CD48727E2DA9D1C147 /* MarkdownTablePrettifier.swift in Sources */, 237E41D444BB8BFDEFF9BA9E /* MarkdownTaskCheckboxInteraction.swift in Sources */, 8067187FE928ABEC436ACEF5 /* MiniBrowserPaneView.swift in Sources */, diff --git a/macOS/SynapseNotes/CompletionViewController.swift b/macOS/SynapseNotes/CompletionViewController.swift new file mode 100644 index 0000000..b4ededa --- /dev/null +++ b/macOS/SynapseNotes/CompletionViewController.swift @@ -0,0 +1,153 @@ +import AppKit + +// MARK: - Completion popover + +class CompletionViewController: NSViewController { + var onSelect: ((URL) -> Void)? + private let searchField = NSSearchField() + private let tableView = NSTableView() + private let scrollView = NSScrollView() + private var allFiles: [URL] = [] + private var filteredFiles: [URL] = [] + + override func loadView() { + view = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 260)) + + searchField.placeholderString = "Search files..." + searchField.sendsSearchStringImmediately = true + searchField.target = self + searchField.action = #selector(searchChanged) + searchField.delegate = self + searchField.font = .systemFont(ofSize: 12) + + let col = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("name")) + col.isEditable = false + tableView.addTableColumn(col) + tableView.headerView = nil + tableView.rowHeight = 26 + tableView.dataSource = self + tableView.delegate = self + tableView.doubleAction = #selector(selectItem) + tableView.target = self + tableView.allowsEmptySelection = false + + scrollView.documentView = tableView + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + + let container = NSView() + container.translatesAutoresizingMaskIntoConstraints = false + searchField.translatesAutoresizingMaskIntoConstraints = false + scrollView.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(searchField) + container.addSubview(scrollView) + + NSLayoutConstraint.activate([ + searchField.topAnchor.constraint(equalTo: container.topAnchor, constant: 8), + searchField.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 8), + searchField.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -8), + scrollView.topAnchor.constraint(equalTo: searchField.bottomAnchor, constant: 6), + scrollView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + view.addSubview(container) + NSLayoutConstraint.activate([ + container.topAnchor.constraint(equalTo: view.topAnchor), + container.leadingAnchor.constraint(equalTo: view.leadingAnchor), + container.trailingAnchor.constraint(equalTo: view.trailingAnchor), + container.bottomAnchor.constraint(equalTo: view.bottomAnchor), + scrollView.heightAnchor.constraint(greaterThanOrEqualToConstant: 160), + ]) + } + + @objc private func searchChanged() { + applyFilter() + } + + private func normalize(_ value: String) -> String { + value + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .components(separatedBy: .newlines).joined() + .lowercased() + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func scoreFile(_ url: URL, query: String) -> Int? { + if query.isEmpty { return 1 } + let name = normalize(url.deletingPathExtension().lastPathComponent) + let path = normalize(url.path) + if let range = name.range(of: query) { + let offset = name.distance(from: name.startIndex, to: range.lowerBound) + return 400 - min(offset, 300) + } + if let range = path.range(of: query) { + let offset = path.distance(from: path.startIndex, to: range.lowerBound) + return 200 - min(offset, 180) + } + return nil + } + + private func applyFilter() { + let query = normalize(searchField.stringValue) + filteredFiles = allFiles + .compactMap { url -> (URL, Int)? in + guard let score = scoreFile(url, query: query) else { return nil } + return (url, score) + } + .sorted { $0.1 > $1.1 } + .map { $0.0 } + tableView.reloadData() + if !filteredFiles.isEmpty { + tableView.selectRowIndexes([0], byExtendingSelection: false) + } + } + + @objc func selectItem() { + let row = tableView.selectedRow + guard row >= 0, row < filteredFiles.count else { return } + onSelect?(filteredFiles[row]) + } + + func selectCurrentItem() { selectItem() } + + func moveSelection(by delta: Int) { + guard !filteredFiles.isEmpty else { return } + let current = max(0, tableView.selectedRow) + let next = max(0, min(filteredFiles.count - 1, current + delta)) + tableView.selectRowIndexes([next], byExtendingSelection: false) + tableView.scrollRowToVisible(next) + } +} + +extension CompletionViewController: NSTableViewDataSource, NSTableViewDelegate { + func numberOfRows(in tableView: NSTableView) -> Int { filteredFiles.count } + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + let name = filteredFiles[row].deletingPathExtension().lastPathComponent + let cell = NSTextField(labelWithString: name) + cell.font = .systemFont(ofSize: 13) + cell.lineBreakMode = .byTruncatingMiddle + return cell + } + + func tableViewSelectionDidChange(_ notification: Notification) {} +} + +extension CompletionViewController: NSSearchFieldDelegate, NSControlTextEditingDelegate { + func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + switch commandSelector { + case #selector(NSResponder.moveUp(_:)): + moveSelection(by: -1) + return true + case #selector(NSResponder.moveDown(_:)): + moveSelection(by: 1) + return true + case #selector(NSResponder.insertNewline(_:)): + selectCurrentItem() + return true + default: + return false + } + } +} diff --git a/macOS/SynapseNotes/EditorEmbeds.swift b/macOS/SynapseNotes/EditorEmbeds.swift new file mode 100644 index 0000000..1567b6e --- /dev/null +++ b/macOS/SynapseNotes/EditorEmbeds.swift @@ -0,0 +1,1011 @@ +import SwiftUI +import AppKit + +struct InlineImageMatch { + let id: String + let range: NSRange + let paragraphRange: NSRange + let source: String + let caption: String +} + +struct InlineEmbedMatch { + let id: String + let range: NSRange + let paragraphRange: NSRange + let noteName: String + let content: String? + let noteURL: URL? +} + +// MARK: - Embedded Notes Data Model + +/// Information about an embedded note for the side panel +struct EmbeddedNoteInfo: Identifiable, Equatable { + let id: String + let noteName: String + let content: String? + let noteURL: URL? + let isUnresolved: Bool + + static func == (lhs: EmbeddedNoteInfo, rhs: EmbeddedNoteInfo) -> Bool { + lhs.id == rhs.id && + lhs.noteName == rhs.noteName && + lhs.content == rhs.content && + lhs.noteURL == rhs.noteURL && + lhs.isUnresolved == rhs.isUnresolved + } +} + +// MARK: - Unified Sidebar Embed Model + +/// The type of content embedded in the sidebar +enum SidebarEmbedType { + case note + case image +} + +/// Unified information about any embed (note or image) for the sidebar +struct SidebarEmbedInfo: Identifiable, Equatable { + let id: String + let type: SidebarEmbedType + let title: String? // For notes (note name) + let caption: String? // For images (caption text) + let content: String? // For notes (note content) + let source: String? // For images (URL/path string) + let resolvedURL: URL? // Resolved URL for both notes and images + let isUnresolved: Bool + let range: NSRange // Position in document for sorting + + /// Creates a SidebarEmbedInfo from an InlineEmbedMatch (note embed) + static func fromEmbedMatch(_ match: InlineEmbedMatch) -> SidebarEmbedInfo { + SidebarEmbedInfo( + id: match.id, + type: .note, + title: match.noteName, + caption: nil, + content: match.content, + source: nil, + resolvedURL: match.noteURL, + isUnresolved: match.noteURL == nil, + range: match.range + ) + } + + /// Creates a SidebarEmbedInfo from an InlineImageMatch (image embed) + static func fromImageMatch(_ match: InlineImageMatch, relativeTo noteURL: URL?) -> SidebarEmbedInfo { + let resolved = resolvedSidebarImageURL(for: match.source, relativeTo: noteURL) + return SidebarEmbedInfo( + id: match.id, + type: .image, + title: nil, + caption: match.caption.isEmpty ? nil : match.caption, + content: nil, + source: match.source, + resolvedURL: resolved, + isUnresolved: resolved == nil, + range: match.range + ) + } +} + +/// Resolves an image source string to a URL for sidebar display +func resolvedSidebarImageURL(for source: String, relativeTo noteURL: URL?) -> URL? { + let cleanedSource = source.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleanedSource.isEmpty else { return nil } + + // Handle web URLs + if cleanedSource.hasPrefix("http://") || cleanedSource.hasPrefix("https://") { + return URL(string: cleanedSource) + } + + // Handle file:// URLs + if cleanedSource.hasPrefix("file://") { + return URL(string: cleanedSource) + } + + // Handle absolute paths + if cleanedSource.hasPrefix("/") { + return URL(fileURLWithPath: cleanedSource) + } + + // Handle relative paths + guard let noteURL = noteURL else { return nil } + let baseURL = noteURL.deletingLastPathComponent() + return URL(fileURLWithPath: cleanedSource, relativeTo: baseURL).standardizedFileURL +} + +// MARK: - Embedded Notes Side Panel + +struct EmbeddedNotesPanel: NSViewRepresentable { + let notes: [SidebarEmbedInfo] + let allFiles: [URL] + let selectedEmbedID: String? + let onOpenFile: (URL, Bool) -> Void // (url, openInNewTab) + let onScrollToEmbed: ((NSRange) -> Void)? + + func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSScrollView() + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + scrollView.borderType = .noBorder + scrollView.drawsBackground = true + scrollView.backgroundColor = SynapseTheme.editorBackground + + let documentView = FlippedNSView() + documentView.autoresizingMask = [.width] + documentView.wantsLayer = true + documentView.layer?.backgroundColor = SynapseTheme.editorBackground.cgColor + scrollView.documentView = documentView + + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + guard let documentView = scrollView.documentView else { return } + + scrollView.drawsBackground = true + scrollView.backgroundColor = SynapseTheme.editorBackground + scrollView.contentView.backgroundColor = SynapseTheme.editorBackground + scrollView.documentView?.wantsLayer = true + scrollView.documentView?.layer?.backgroundColor = SynapseTheme.editorBackground.cgColor + + let width: CGFloat = 304 // 320 - 16 padding + let spacing: CGFloat = 12 + var currentY: CGFloat = 8 + var selectedView: NSView? + var selectedViewY: CGFloat = 0 + + // Track which embed IDs we've processed + var processedIDs = Set() + + for embed in notes { + processedIDs.insert(embed.id) + let isSelected = embed.id == selectedEmbedID + + // Find existing view for this embed ID + let existingView = documentView.subviews.first { $0.identifier?.rawValue == embed.id } + + switch embed.type { + case .note: + let embedView: EmbeddedNoteView + if let existing = existingView as? EmbeddedNoteView { + embedView = existing + } else { + embedView = EmbeddedNoteView() + embedView.identifier = NSUserInterfaceItemIdentifier(embed.id) + embedView.onOpenNote = { url, openInNewTab in + onOpenFile(url, openInNewTab) + } + documentView.addSubview(embedView) + } + + embedView.configure( + noteName: embed.title ?? "Note", + content: embed.content, + noteURL: embed.resolvedURL, + isUnresolved: embed.isUnresolved + ) + + // Calculate height + let preferredSize = embedView.preferredSize(for: embed.content) + let height = min(preferredSize.height, 400) + + embedView.frame = NSRect(x: 0, y: currentY, width: width, height: height) + + if isSelected { + selectedView = embedView + selectedViewY = currentY + } + + currentY += height + spacing + + case .image: + let imageView: EmbeddedImageView + if let existing = existingView as? EmbeddedImageView { + imageView = existing + } else { + imageView = EmbeddedImageView() + imageView.identifier = NSUserInterfaceItemIdentifier(embed.id) + imageView.onScrollToMarkdown = { [range = embed.range] in + onScrollToEmbed?(range) + } + documentView.addSubview(imageView) + } + + imageView.configure( + caption: embed.caption, + imageURL: embed.resolvedURL, + isUnresolved: embed.isUnresolved, + isSelected: isSelected + ) + + let height: CGFloat = embed.caption != nil ? 246 : 228 + imageView.frame = NSRect(x: 0, y: currentY, width: width, height: height) + + if isSelected { + selectedView = imageView + selectedViewY = currentY + } + + currentY += height + spacing + } + } + + // Remove views that are no longer needed + documentView.subviews.forEach { view in + if let id = view.identifier?.rawValue, !processedIDs.contains(id) { + view.removeFromSuperview() + } + } + + // Set document view size + let totalHeight = max(currentY - spacing + 8, scrollView.bounds.height) + documentView.frame = NSRect(x: 0, y: 0, width: width, height: totalHeight) + + // Scroll selected view into view + if let selectedView = selectedView { + let visibleRect = NSRect( + x: 0, + y: selectedViewY, + width: width, + height: selectedView.frame.height + ) + scrollView.contentView.scrollToVisible(visibleRect) + } + } +} + +// NSView subclass with flipped coordinate system so (0,0) is at top-left +final class FlippedNSView: NSView { + override var isFlipped: Bool { true } +} + +final class EmbeddedNoteView: NSView { + private let contentScrollView = NSScrollView() + private let contentTextView = NSTextView() + private let titleField = NSTextField(labelWithString: "") + private let borderView = NSView() + private let openButton = NSButton() + private var targetURL: URL? + var onOpenNote: ((URL, Bool) -> Void)? // (url, openInNewTab) + + // Fixed dimensions for the right-aligned panel + private let panelWidth: CGFloat = 280 + private let maxPanelHeight: CGFloat = 400 + private let minPanelHeight: CGFloat = 120 + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setup() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + func configure(noteName: String, content: String?, noteURL: URL?, isUnresolved: Bool) { + targetURL = noteURL + titleField.stringValue = isUnresolved ? "Note not found: \(noteName)" : noteName + + if isUnresolved { + contentTextView.string = "" + contentScrollView.isHidden = true + borderView.layer?.backgroundColor = SynapseTheme.nsPanelElevated.cgColor + borderView.layer?.borderColor = SynapseTheme.nsError.cgColor + } else if let content = content { + let styledContent = styleMarkdownContent(content, fontSize: 11) + contentTextView.textStorage?.setAttributedString(styledContent) + contentScrollView.isHidden = false + borderView.layer?.backgroundColor = SynapseTheme.nsPanelElevated.cgColor + borderView.layer?.borderColor = SynapseTheme.nsBorder.cgColor + } + + openButton.isHidden = (noteURL == nil) + updateColors() + } + + /// Re-applies all theme-dependent colors. Safe to call any time the theme changes. + func updateColors() { + borderView.layer?.backgroundColor = SynapseTheme.nsPanelElevated.cgColor + titleField.textColor = SynapseTheme.nsTextPrimary + contentTextView.backgroundColor = SynapseTheme.editorCodeBackground + contentTextView.textColor = SynapseTheme.nsTextSecondary + contentScrollView.backgroundColor = SynapseTheme.editorCodeBackground + // Re-style markdown content with the new theme colors + if let text = contentTextView.string.isEmpty ? nil : contentTextView.string { + let styledContent = styleMarkdownContent(text, fontSize: 11) + contentTextView.textStorage?.setAttributedString(styledContent) + } + } + + override func layout() { + super.layout() + wantsLayer = true + layer?.cornerRadius = 6 + layer?.masksToBounds = true + + let padding: CGFloat = 12 + let buttonHeight: CGFloat = 28 + let titleHeight: CGFloat = 20 + let spacing: CGFloat = 8 + + // Border view fills the entire frame + borderView.frame = bounds + + // Title at top + titleField.frame = NSRect( + x: padding, + y: bounds.height - padding - titleHeight, + width: bounds.width - padding * 2, + height: titleHeight + ) + + // Open button at bottom + openButton.frame = NSRect( + x: bounds.width - padding - 80, + y: padding, + width: 80, + height: buttonHeight + ) + + // Content scroll view fills the middle area + if !contentScrollView.isHidden { + let contentY = buttonHeight + padding + spacing + let contentHeight = bounds.height - contentY - titleHeight - spacing * 2 + contentScrollView.frame = NSRect( + x: padding, + y: contentY, + width: bounds.width - padding * 2, + height: max(0, contentHeight) + ) + } + } + + @objc private func openNote() { + guard let url = targetURL else { return } + // Check if Command key is held (for opening in new tab) + let openInNewTab = NSEvent.modifierFlags.contains(.command) + onOpenNote?(url, openInNewTab) + } + + private func setup() { + // Border view + borderView.wantsLayer = true + borderView.layer?.cornerRadius = 6 + borderView.layer?.masksToBounds = true + borderView.layer?.borderWidth = 1 + borderView.layer?.backgroundColor = SynapseTheme.nsPanelElevated.cgColor + borderView.layer?.borderColor = SynapseTheme.nsBorder.cgColor + borderView.autoresizingMask = [.width, .height] + addSubview(borderView) + + // Title field + titleField.font = .systemFont(ofSize: 13, weight: .semibold) + titleField.textColor = SynapseTheme.nsTextPrimary + titleField.lineBreakMode = .byTruncatingTail + addSubview(titleField) + + // Content text view (read-only) + contentTextView.isEditable = false + contentTextView.isSelectable = true + contentTextView.isRichText = false + contentTextView.backgroundColor = SynapseTheme.editorCodeBackground + contentTextView.textContainerInset = NSSize(width: 8, height: 8) + contentTextView.font = .systemFont(ofSize: 11) + contentTextView.textColor = SynapseTheme.nsTextSecondary + + // Content scroll view + contentScrollView.documentView = contentTextView + contentScrollView.hasVerticalScroller = true + contentScrollView.autohidesScrollers = true + contentScrollView.borderType = .bezelBorder + contentScrollView.backgroundColor = SynapseTheme.editorCodeBackground + contentScrollView.isHidden = true + addSubview(contentScrollView) + + // Open button + openButton.title = "Open" + openButton.target = self + openButton.action = #selector(openNote) + openButton.bezelStyle = .rounded + openButton.font = .systemFont(ofSize: 11, weight: .medium) + addSubview(openButton) + } + + // Return the preferred size for this panel + func preferredSize(for content: String?) -> NSSize { + let padding: CGFloat = 12 + let buttonHeight: CGFloat = 28 + let titleHeight: CGFloat = 20 + let spacing: CGFloat = 8 + + if content == nil { + // Unresolved: just title + button + return NSSize(width: panelWidth, height: minPanelHeight) + } + + // Calculate content height based on text + let textStorage = NSTextStorage(string: content!) + let layoutManager = NSLayoutManager() + let textContainer = NSTextContainer(containerSize: NSSize(width: panelWidth - padding * 2 - 20, height: .greatestFiniteMagnitude)) + textContainer.lineFragmentPadding = 0 + + layoutManager.addTextContainer(textContainer) + textStorage.addLayoutManager(layoutManager) + layoutManager.ensureLayout(for: textContainer) + + let contentHeight = layoutManager.usedRect(for: textContainer).height + 16 // +16 for insets + let totalHeight = padding + buttonHeight + spacing + min(contentHeight, 300) + spacing + titleHeight + padding + + return NSSize(width: panelWidth, height: min(max(totalHeight, minPanelHeight), maxPanelHeight)) + } +} + +// MARK: - Embedded Image View + +final class EmbeddedImageView: NSView { + private let imageView = NSImageView() + private let captionField = NSTextField(labelWithString: "") + private let borderView = NSView() + private let previewBackgroundView = NSView() + private let openButton = NSButton() + private var targetURL: URL? + private var isSelected: Bool = false + var onScrollToMarkdown: (() -> Void)? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setup() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + func configure(caption: String?, imageURL: URL?, isUnresolved: Bool, isSelected: Bool = false) { + targetURL = imageURL + self.isSelected = isSelected + openButton.isHidden = imageURL == nil || isUnresolved + + if isUnresolved { + captionField.stringValue = caption ?? "Image not found" + imageView.image = nil + } else { + captionField.stringValue = caption ?? "" + // Load image asynchronously + if let imageURL = imageURL { + loadImage(from: imageURL) + } + } + + captionField.isHidden = (caption == nil || caption?.isEmpty == true) + + // Update border color based on selection state + updateBorderAppearance() + updateColors() + } + + private func updateBorderAppearance() { + borderView.layer?.borderWidth = isSelected ? 3 : 1 + borderView.layer?.borderColor = isSelected + ? NSColor(SynapseTheme.accent).cgColor + : NSColor(SynapseTheme.border).cgColor + } + + /// Re-applies all theme-dependent colors. Safe to call any time the theme changes. + func updateColors() { + borderView.layer?.backgroundColor = SynapseTheme.nsPanelElevated.cgColor + previewBackgroundView.layer?.backgroundColor = SynapseTheme.editorCodeBackground.cgColor + updateBorderAppearance() + captionField.textColor = NSColor(SynapseTheme.textSecondary) + } + + private func loadImage(from url: URL) { + // Load image in background + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let image = NSImage(contentsOf: url) else { return } + DispatchQueue.main.async { + self?.imageView.image = image + self?.needsLayout = true + } + } + } + + override func layout() { + super.layout() + + wantsLayer = true + layer?.cornerRadius = 6 + layer?.masksToBounds = true + + // Update border frame and appearance + borderView.frame = bounds + updateBorderAppearance() + + let padding: CGFloat = 12 + let spacing: CGFloat = 10 + let buttonHeight: CGFloat = openButton.isHidden ? 0 : 24 + let captionHeight: CGFloat = captionField.isHidden ? 0 : 20 + + let buttonY = padding + let previewBottom = buttonY + buttonHeight + (openButton.isHidden ? 0 : spacing) + let previewTop = bounds.height - padding - captionHeight - (captionField.isHidden ? 0 : spacing) + let previewRect = NSRect( + x: padding, + y: previewBottom, + width: bounds.width - padding * 2, + height: max(120, previewTop - previewBottom) + ) + + previewBackgroundView.frame = previewRect + + let contentRect = previewRect.insetBy(dx: 8, dy: 8) + if let image = imageView.image, image.size.width > 0, image.size.height > 0 { + let widthRatio = contentRect.width / image.size.width + let heightRatio = contentRect.height / image.size.height + let scale = min(widthRatio, heightRatio) + let drawSize = NSSize(width: image.size.width * scale, height: image.size.height * scale) + imageView.frame = NSRect( + x: round(contentRect.midX - drawSize.width / 2), + y: round(contentRect.midY - drawSize.height / 2), + width: round(drawSize.width), + height: round(drawSize.height) + ) + } else { + imageView.frame = contentRect + } + imageView.imageScaling = .scaleProportionallyUpOrDown + imageView.contentTintColor = nil + + // Caption label + if !captionField.isHidden { + captionField.frame = NSRect( + x: padding, + y: bounds.height - padding - captionHeight, + width: bounds.width - padding * 2, + height: captionHeight + ) + captionField.font = .monospacedSystemFont(ofSize: 12, weight: .semibold) + captionField.textColor = NSColor(SynapseTheme.textSecondary) + captionField.lineBreakMode = .byTruncatingMiddle + captionField.alignment = .left + } + + let buttonWidth = min(124, bounds.width - padding * 2) + openButton.frame = NSRect( + x: round((bounds.width - buttonWidth) / 2), + y: padding, + width: buttonWidth, + height: buttonHeight + ) + openButton.bezelStyle = .rounded + openButton.font = .systemFont(ofSize: 11, weight: .semibold) + } + + private var imageViewerController: ImageViewerWindowController? + + @objc private func openImage() { + guard let targetURL = targetURL else { return } + + let viewer = ImageViewerWindowController(imageURL: targetURL, caption: captionField.stringValue.isEmpty ? nil : captionField.stringValue) + imageViewerController = viewer // retain strongly so it isn't deallocated before image loads + viewer.showFullScreen() + } + + private func setup() { + // Setup border view layer properties + borderView.wantsLayer = true + borderView.layer?.cornerRadius = 6 + borderView.layer?.masksToBounds = true + borderView.layer?.backgroundColor = SynapseTheme.nsPanelElevated.cgColor + + addSubview(borderView) + previewBackgroundView.wantsLayer = true + previewBackgroundView.layer?.cornerRadius = 8 + previewBackgroundView.layer?.masksToBounds = true + previewBackgroundView.layer?.backgroundColor = SynapseTheme.editorCodeBackground.cgColor + previewBackgroundView.layer?.borderWidth = 0 + addSubview(previewBackgroundView) + addSubview(imageView) + addSubview(captionField) + + openButton.title = "Open" + openButton.bezelStyle = .rounded + openButton.target = self + openButton.action = #selector(openImage) + addSubview(openButton) + + // Click on image thumbnail scrolls editor to the markdown + let click = NSClickGestureRecognizer(target: self, action: #selector(thumbnailClicked)) + imageView.addGestureRecognizer(click) + + // Initial border appearance + updateBorderAppearance() + } + + @objc private func thumbnailClicked() { + onScrollToMarkdown?() + } +} + +// MARK: - Full Screen Image Viewer + +/// A full-screen window for viewing images with zoom and pan support +final class ImageViewerWindowController: NSWindowController { + private let imageView = NSImageView() + private let imageContainerView = NSView() + private var imageURL: URL? + private var localMonitor: Any? + private var scrollMonitor: Any? + private var scrollView: NSScrollView! + private var currentZoom: CGFloat = 1.0 + private var minZoom: CGFloat = 0.1 + private var maxZoom: CGFloat = 5.0 + private var imageSize: NSSize = .zero + private var imageWidthConstraint: NSLayoutConstraint? + private var imageHeightConstraint: NSLayoutConstraint? + private var gestureStartZoom: CGFloat = 1.0 + + init(imageURL: URL, caption: String?) { + let window = NSWindow( + contentRect: NSScreen.main?.frame ?? NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: [.titled, .closable, .fullSizeContentView, .resizable], + backing: .buffered, + defer: false + ) + window.title = caption ?? imageURL.lastPathComponent + window.titlebarAppearsTransparent = false + window.backgroundColor = .black + window.isOpaque = true + window.hasShadow = true + + super.init(window: window) + + self.imageURL = imageURL + setupContentView() + setupImageView() + setupCloseButton() + setupEscapeHandler() + loadImage() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + if let monitor = localMonitor { + NSEvent.removeMonitor(monitor) + } + if let monitor = scrollMonitor { + NSEvent.removeMonitor(monitor) + } + } + + private func setupContentView() { + guard let window = window else { return } + + scrollView = NSScrollView(frame: window.contentView?.bounds ?? .zero) + scrollView.autoresizingMask = [.width, .height] + scrollView.hasVerticalScroller = false + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + scrollView.allowsMagnification = false + scrollView.backgroundColor = .black + scrollView.drawsBackground = true + + window.contentView = scrollView + } + + private func setupImageView() { + imageContainerView.wantsLayer = true + imageContainerView.layer?.backgroundColor = NSColor.black.cgColor + imageContainerView.frame = scrollView.bounds + + imageView.imageScaling = .scaleAxesIndependently + imageView.imageAlignment = .alignCenter + imageView.wantsLayer = true + imageView.layer?.backgroundColor = NSColor.clear.cgColor + imageView.translatesAutoresizingMaskIntoConstraints = false + + imageContainerView.addSubview(imageView) + imageWidthConstraint = imageView.widthAnchor.constraint(equalToConstant: 100) + imageHeightConstraint = imageView.heightAnchor.constraint(equalToConstant: 100) + + NSLayoutConstraint.activate([ + imageView.centerXAnchor.constraint(equalTo: imageContainerView.centerXAnchor), + imageView.centerYAnchor.constraint(equalTo: imageContainerView.centerYAnchor), + imageWidthConstraint!, + imageHeightConstraint! + ]) + + scrollView.documentView = imageContainerView + + setupGestureRecognizers() + setupScrollWheelZoom() + } + + private func setupScrollWheelZoom() { + scrollMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] event in + guard let self = self, let window = self.window else { return event } + + if NSApp.keyWindow == window && event.modifierFlags.contains(.control) { + let delta = event.scrollingDeltaY + let zoomFactor = pow(1.01, delta * 0.35) + let newZoom = self.currentZoom * zoomFactor + self.setZoom(newZoom, animated: false) + return nil + } + return event + } + } + + private func setupGestureRecognizers() { + let doubleClickGesture = NSClickGestureRecognizer(target: self, action: #selector(handleDoubleClick)) + doubleClickGesture.numberOfClicksRequired = 2 + imageView.addGestureRecognizer(doubleClickGesture) + + let magnifyGesture = NSMagnificationGestureRecognizer(target: self, action: #selector(handleMagnify(_:))) + imageView.addGestureRecognizer(magnifyGesture) + } + + @objc private func handleDoubleClick() { + // Toggle between fit-to-screen and 100% zoom + if currentZoom != 1.0 { + setZoom(1.0, animated: true) + } else { + fitImageToScreen() + } + } + + @objc private func handleMagnify(_ gesture: NSMagnificationGestureRecognizer) { + switch gesture.state { + case .began: + gestureStartZoom = currentZoom + case .changed: + let newZoom = gestureStartZoom * (1 + gesture.magnification) + setZoom(newZoom, animated: false) + default: + break + } + } + + private func setZoom(_ zoom: CGFloat, animated: Bool) { + let clampedZoom = max(minZoom, min(maxZoom, zoom)) + currentZoom = clampedZoom + + let applyLayout = { self.layoutImage(centerViewport: true) } + if animated { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.15 + applyLayout() + } + } else { + applyLayout() + } + } + + private func fitImageToScreen() { + guard imageSize != .zero, let window = window else { return } + + let visibleFrame = window.contentView?.bounds ?? window.frame + let titleBarHeight: CGFloat = 28 + let availableHeight = visibleFrame.height - titleBarHeight - 40 + let availableWidth = visibleFrame.width - 40 + + let widthRatio = availableWidth / imageSize.width + let heightRatio = availableHeight / imageSize.height + currentZoom = min(widthRatio, heightRatio, 1.0) + layoutImage(centerViewport: true) + } + + private func layoutImage(centerViewport: Bool) { + guard imageSize != .zero else { return } + + let visibleSize = scrollView.contentView.bounds.size + let scaledSize = NSSize(width: imageSize.width * currentZoom, height: imageSize.height * currentZoom) + let containerSize = NSSize( + width: max(visibleSize.width, scaledSize.width), + height: max(visibleSize.height, scaledSize.height) + ) + + imageContainerView.frame = NSRect(origin: .zero, size: containerSize) + imageWidthConstraint?.constant = scaledSize.width + imageHeightConstraint?.constant = scaledSize.height + imageContainerView.layoutSubtreeIfNeeded() + + if centerViewport { + let centeredOrigin = NSPoint( + x: max(0, (containerSize.width - visibleSize.width) / 2), + y: max(0, (containerSize.height - visibleSize.height) / 2) + ) + scrollView.contentView.scroll(to: centeredOrigin) + scrollView.reflectScrolledClipView(scrollView.contentView) + } + } + + private func setupCloseButton() { + // Native window close button (traffic light) is sufficient + } + + private func setupEscapeHandler() { + localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard let self = self, let window = self.window else { return event } + + if NSApp.keyWindow == window && event.keyCode == 53 { + self.closeWindow() + return nil + } + return event + } + } + + private func loadImage() { + guard let imageURL = imageURL else { return } + + // Handle remote URLs (http/https) + if imageURL.scheme?.lowercased() == "http" || imageURL.scheme?.lowercased() == "https" { + downloadRemoteImage(from: imageURL) + return + } + + // Handle local file URLs + let fileManager = FileManager.default + if !fileManager.fileExists(atPath: imageURL.path) { + print("Image file does not exist at: \(imageURL.path)") + return + } + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let image = NSImage(contentsOf: imageURL) else { + print("Failed to load image from: \(imageURL.path)") + return + } + + DispatchQueue.main.async { + self?.imageSize = image.size + self?.imageView.image = image + self?.updateImageViewSize() + self?.fitImageToScreen() + print("Image loaded successfully: \(image.size.width)x\(image.size.height)") + } + } + } + + private func downloadRemoteImage(from url: URL) { + print("Downloading remote image from: \(url.absoluteString)") + + let task = URLSession.shared.dataTask(with: url) { [weak self] data, response, error in + if let error = error { + print("Failed to download image: \(error.localizedDescription)") + return + } + + guard let data = data, let image = NSImage(data: data) else { + print("Failed to create image from downloaded data") + return + } + + DispatchQueue.main.async { + self?.imageSize = image.size + self?.imageView.image = image + self?.updateImageViewSize() + self?.fitImageToScreen() + print("Remote image loaded successfully: \(image.size.width)x\(image.size.height)") + } + } + + task.resume() + } + + private func updateImageViewSize() { + layoutImage(centerViewport: true) + } + + @objc private func closeWindow() { + window?.close() + } + + func showFullScreen() { + window?.center() + window?.makeKeyAndOrderFront(nil) + + // Make it nearly full screen but keep title bar + if let screen = NSScreen.main { + let screenFrame = screen.visibleFrame + let padding: CGFloat = 40 + let newFrame = NSRect( + x: screenFrame.origin.x + padding, + y: screenFrame.origin.y + padding, + width: screenFrame.width - (padding * 2), + height: screenFrame.height - (padding * 2) + ) + window?.setFrame(newFrame, display: true, animate: true) + } + } +} + +final class YouTubePreviewView: NSView { + private let thumbnailView = NSImageView() + private let overlay = NSView() + private let playIcon = NSImageView() + private let titleField = NSTextField(labelWithString: "") + private let subtitleField = NSTextField(labelWithString: "") + private let actionButton = NSButton() + private var targetURL: URL? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setup() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + override func layout() { + super.layout() + wantsLayer = true + layer?.cornerRadius = 4 + layer?.masksToBounds = true + layer?.borderWidth = 1 + layer?.borderColor = NSColor(SynapseTheme.border).cgColor + layer?.backgroundColor = SynapseTheme.editorCodeBackground.cgColor + + thumbnailView.frame = bounds + overlay.frame = bounds + actionButton.frame = bounds + + let iconSize: CGFloat = 54 + playIcon.frame = NSRect(x: 20, y: bounds.midY - iconSize / 2, width: iconSize, height: iconSize) + + let textX = playIcon.frame.maxX + 18 + let textWidth = max(160, bounds.width - textX - 20) + titleField.frame = NSRect(x: textX, y: bounds.midY + 2, width: textWidth, height: 28) + subtitleField.frame = NSRect(x: textX, y: bounds.midY - 28, width: textWidth, height: 44) + } + + @objc private func openVideo() { + guard let targetURL else { return } + NSWorkspace.shared.open(targetURL) + } + + private func setup() { + thumbnailView.imageScaling = .scaleProportionallyUpOrDown + thumbnailView.imageAlignment = .alignCenter + thumbnailView.autoresizingMask = [.width, .height] + addSubview(thumbnailView) + + overlay.wantsLayer = true + overlay.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.35).cgColor + overlay.autoresizingMask = [.width, .height] + addSubview(overlay) + + if let image = NSImage(systemSymbolName: "play.circle.fill", accessibilityDescription: nil) { + playIcon.image = image + } + playIcon.contentTintColor = .white + addSubview(playIcon) + + titleField.font = .systemFont(ofSize: 20, weight: .bold) + titleField.textColor = NSColor(SynapseTheme.textPrimary) + titleField.lineBreakMode = .byTruncatingTail + addSubview(titleField) + + subtitleField.font = .systemFont(ofSize: 12, weight: .medium) + subtitleField.textColor = NSColor(SynapseTheme.textSecondary) + subtitleField.lineBreakMode = .byTruncatingMiddle + addSubview(subtitleField) + + actionButton.isBordered = false + actionButton.title = "" + actionButton.target = self + actionButton.action = #selector(openVideo) + actionButton.autoresizingMask = [.width, .height] + addSubview(actionButton) + } +} diff --git a/macOS/SynapseNotes/EditorView.swift b/macOS/SynapseNotes/EditorView.swift index 03e234e..550af48 100644 --- a/macOS/SynapseNotes/EditorView.swift +++ b/macOS/SynapseNotes/EditorView.swift @@ -4273,1849 +4273,3 @@ class LinkAwareTextView: NSTextView { return structural.contains { lower.contains($0) } } } - -// MARK: - HTML to Markdown Converter - -/// Converts HTML content to Markdown using NSAttributedString for correct parsing, -/// then walks the attribute runs to emit Markdown syntax. -struct HTMLToMarkdownConverter { - - static func convert(_ html: String) -> String { - let trimmed = html.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return "" } - - // Wrap in a minimal HTML document so NSAttributedString's HTML renderer - // uses the correct charset and a neutral sans-serif stylesheet. Without - // the wrapper the renderer can misdetect encoding and apply a monospace / - // code-block stylesheet to the entire content. - let wrapped: String - if trimmed.lowercased().hasPrefix(" - - - \(trimmed) - """ - } - - guard let data = wrapped.data(using: .utf8) else { return trimmed } - - let opts: [NSAttributedString.DocumentReadingOptionKey: Any] = [ - .documentType: NSAttributedString.DocumentType.html, - .characterEncoding: String.Encoding.utf8.rawValue, - ] - - guard let attrStr = try? NSAttributedString(data: data, options: opts, documentAttributes: nil) else { - return trimmed - } - - return markdownFromAttributedString(attrStr) - } - - // MARK: - Attributed string → Markdown - - private static func markdownFromAttributedString(_ attrStr: NSAttributedString) -> String { - let fullString = attrStr.string - var output = "" - - let nsString = fullString as NSString - var paraStart = 0 - - while paraStart < nsString.length { - var paraEnd = 0 - var contentsEnd = 0 - nsString.getParagraphStart(nil, end: ¶End, contentsEnd: &contentsEnd, - for: NSRange(location: paraStart, length: 0)) - - let contentsRange = NSRange(location: paraStart, length: contentsEnd - paraStart) - - // Grab first-character attributes to classify the paragraph. - let attrs = (paraEnd > paraStart) - ? attrStr.attributes(at: paraStart, effectiveRange: nil) - : [:] - - let paraStyle = attrs[.paragraphStyle] as? NSParagraphStyle - let font = attrs[.font] as? NSFont - let fontSize = font?.pointSize ?? 12 - let headingLevel = headingLevelForFontSize(fontSize) - let isListItem = isListItemParagraph(paraStyle) - let isOrdered = isOrderedListItem(attrStr, range: contentsRange) - - // Build inline content, suppressing bold on headings (NSAttributedString - // makes heading text bold by default — that would double-format it). - let inlineContent = inlineMarkdown(attrStr, range: contentsRange, - suppressBold: headingLevel > 0) - .trimmingCharacters(in: .whitespacesAndNewlines) - - if inlineContent.isEmpty { - if !output.isEmpty { output += "\n" } - } else if headingLevel > 0 { - let hashes = String(repeating: "#", count: headingLevel) - output += "\(hashes) \(inlineContent)\n\n" - } else if isListItem { - let marker = isOrdered ? "1." : "-" - let indent = indentForParagraphStyle(paraStyle) - output += "\(indent)\(marker) \(inlineContent)\n" - } else { - output += "\(inlineContent)\n\n" - } - - paraStart = paraEnd - } - - return output - .replacingOccurrences(of: "\n{3,}", with: "\n\n", options: .regularExpression) - .trimmingCharacters(in: .whitespacesAndNewlines) - } - - // MARK: - Inline span rendering - - private static func inlineMarkdown(_ attrStr: NSAttributedString, range: NSRange, - suppressBold: Bool = false) -> String { - guard range.length > 0 else { return "" } - - var output = "" - - attrStr.enumerateAttributes(in: range, options: []) { attrs, spanRange, _ in - let text = (attrStr.string as NSString).substring(with: spanRange) - - // Strip tabs (list marker column) and Unicode bullets NSAttributedString - // inserts for
    items (U+2022 •, U+25E6 ◦, U+25AA ▪, etc.) - var cleaned = text.replacingOccurrences(of: "\t", with: "") - cleaned = cleaned.unicodeScalars.filter { scalar in - // Drop Unicode list-marker bullet characters - ![0x2022, 0x25E6, 0x25AA, 0x25AB, 0x2023, 0x2043].contains(scalar.value) - }.reduce("") { $0 + String($1) } - - guard !cleaned.isEmpty else { return } - - let font = attrs[.font] as? NSFont - let isBold = !suppressBold && (font?.fontDescriptor.symbolicTraits.contains(.bold) ?? false) - let isItalic = font?.fontDescriptor.symbolicTraits.contains(.italic) ?? false - let isMono = font?.fontDescriptor.symbolicTraits.contains(.monoSpace) ?? false - let link = attrs[.link] as? URL - ?? (attrs[.link] as? String).flatMap { URL(string: $0) } - - var span = cleaned - - if isMono { - span = "`\(span)`" - } else { - if isBold && isItalic { span = "***\(span)***" } - else if isBold { span = "**\(span)**" } - else if isItalic { span = "_\(span)_" } - } - - if let url = link, !isMono { - span = "[\(cleaned)](\(url.absoluteString))" - } - - output += span - } - - return output - } - - // MARK: - Helpers - - /// Map NSAttributedString's rendered font sizes back to heading levels. - /// Empirical values on macOS 14/15 with default system HTML stylesheet: - /// h1 → ~24pt, h2 → ~18pt, h3 → ~14pt bold, h4-h6 → 12pt bold - private static func headingLevelForFontSize(_ size: CGFloat) -> Int { - switch size { - case 22...: return 1 - case 17..<22: return 2 - case 14..<17: return 3 - default: return 0 - } - } - - private static func isListItemParagraph(_ style: NSParagraphStyle?) -> Bool { - guard let style else { return false } - return style.headIndent > 0 && !style.tabStops.isEmpty - } - - private static func isOrderedListItem(_ attrStr: NSAttributedString, range: NSRange) -> Bool { - guard range.length > 0 else { return false } - let raw = (attrStr.string as NSString).substring(with: range) - // Ordered items start with a tab followed by a digit and a period. - return raw.hasPrefix("\t") && raw.dropFirst().first?.isNumber == true - } - - private static func indentForParagraphStyle(_ style: NSParagraphStyle?) -> String { - guard let style, style.headIndent > 36 else { return "" } - let extraLevels = Int((style.headIndent - 18) / 18) - return String(repeating: " ", count: max(0, extraLevels)) - } -} - -struct InlineImageMatch { - let id: String - let range: NSRange - let paragraphRange: NSRange - let source: String - let caption: String -} - -struct InlineEmbedMatch { - let id: String - let range: NSRange - let paragraphRange: NSRange - let noteName: String - let content: String? - let noteURL: URL? -} - -// MARK: - Embedded Notes Data Model - -/// Information about an embedded note for the side panel -struct EmbeddedNoteInfo: Identifiable, Equatable { - let id: String - let noteName: String - let content: String? - let noteURL: URL? - let isUnresolved: Bool - - static func == (lhs: EmbeddedNoteInfo, rhs: EmbeddedNoteInfo) -> Bool { - lhs.id == rhs.id && - lhs.noteName == rhs.noteName && - lhs.content == rhs.content && - lhs.noteURL == rhs.noteURL && - lhs.isUnresolved == rhs.isUnresolved - } -} - -// MARK: - Unified Sidebar Embed Model - -/// The type of content embedded in the sidebar -enum SidebarEmbedType { - case note - case image -} - -/// Unified information about any embed (note or image) for the sidebar -struct SidebarEmbedInfo: Identifiable, Equatable { - let id: String - let type: SidebarEmbedType - let title: String? // For notes (note name) - let caption: String? // For images (caption text) - let content: String? // For notes (note content) - let source: String? // For images (URL/path string) - let resolvedURL: URL? // Resolved URL for both notes and images - let isUnresolved: Bool - let range: NSRange // Position in document for sorting - - /// Creates a SidebarEmbedInfo from an InlineEmbedMatch (note embed) - static func fromEmbedMatch(_ match: InlineEmbedMatch) -> SidebarEmbedInfo { - SidebarEmbedInfo( - id: match.id, - type: .note, - title: match.noteName, - caption: nil, - content: match.content, - source: nil, - resolvedURL: match.noteURL, - isUnresolved: match.noteURL == nil, - range: match.range - ) - } - - /// Creates a SidebarEmbedInfo from an InlineImageMatch (image embed) - static func fromImageMatch(_ match: InlineImageMatch, relativeTo noteURL: URL?) -> SidebarEmbedInfo { - let resolved = resolvedSidebarImageURL(for: match.source, relativeTo: noteURL) - return SidebarEmbedInfo( - id: match.id, - type: .image, - title: nil, - caption: match.caption.isEmpty ? nil : match.caption, - content: nil, - source: match.source, - resolvedURL: resolved, - isUnresolved: resolved == nil, - range: match.range - ) - } -} - -/// Resolves an image source string to a URL for sidebar display -func resolvedSidebarImageURL(for source: String, relativeTo noteURL: URL?) -> URL? { - let cleanedSource = source.trimmingCharacters(in: .whitespacesAndNewlines) - guard !cleanedSource.isEmpty else { return nil } - - // Handle web URLs - if cleanedSource.hasPrefix("http://") || cleanedSource.hasPrefix("https://") { - return URL(string: cleanedSource) - } - - // Handle file:// URLs - if cleanedSource.hasPrefix("file://") { - return URL(string: cleanedSource) - } - - // Handle absolute paths - if cleanedSource.hasPrefix("/") { - return URL(fileURLWithPath: cleanedSource) - } - - // Handle relative paths - guard let noteURL = noteURL else { return nil } - let baseURL = noteURL.deletingLastPathComponent() - return URL(fileURLWithPath: cleanedSource, relativeTo: baseURL).standardizedFileURL -} - -// MARK: - Embedded Notes Side Panel - -struct EmbeddedNotesPanel: NSViewRepresentable { - let notes: [SidebarEmbedInfo] - let allFiles: [URL] - let selectedEmbedID: String? - let onOpenFile: (URL, Bool) -> Void // (url, openInNewTab) - let onScrollToEmbed: ((NSRange) -> Void)? - - func makeNSView(context: Context) -> NSScrollView { - let scrollView = NSScrollView() - scrollView.hasVerticalScroller = true - scrollView.autohidesScrollers = true - scrollView.borderType = .noBorder - scrollView.drawsBackground = true - scrollView.backgroundColor = SynapseTheme.editorBackground - - let documentView = FlippedNSView() - documentView.autoresizingMask = [.width] - documentView.wantsLayer = true - documentView.layer?.backgroundColor = SynapseTheme.editorBackground.cgColor - scrollView.documentView = documentView - - return scrollView - } - - func updateNSView(_ scrollView: NSScrollView, context: Context) { - guard let documentView = scrollView.documentView else { return } - - scrollView.drawsBackground = true - scrollView.backgroundColor = SynapseTheme.editorBackground - scrollView.contentView.backgroundColor = SynapseTheme.editorBackground - scrollView.documentView?.wantsLayer = true - scrollView.documentView?.layer?.backgroundColor = SynapseTheme.editorBackground.cgColor - - let width: CGFloat = 304 // 320 - 16 padding - let spacing: CGFloat = 12 - var currentY: CGFloat = 8 - var selectedView: NSView? - var selectedViewY: CGFloat = 0 - - // Track which embed IDs we've processed - var processedIDs = Set() - - for embed in notes { - processedIDs.insert(embed.id) - let isSelected = embed.id == selectedEmbedID - - // Find existing view for this embed ID - let existingView = documentView.subviews.first { $0.identifier?.rawValue == embed.id } - - switch embed.type { - case .note: - let embedView: EmbeddedNoteView - if let existing = existingView as? EmbeddedNoteView { - embedView = existing - } else { - embedView = EmbeddedNoteView() - embedView.identifier = NSUserInterfaceItemIdentifier(embed.id) - embedView.onOpenNote = { url, openInNewTab in - onOpenFile(url, openInNewTab) - } - documentView.addSubview(embedView) - } - - embedView.configure( - noteName: embed.title ?? "Note", - content: embed.content, - noteURL: embed.resolvedURL, - isUnresolved: embed.isUnresolved - ) - - // Calculate height - let preferredSize = embedView.preferredSize(for: embed.content) - let height = min(preferredSize.height, 400) - - embedView.frame = NSRect(x: 0, y: currentY, width: width, height: height) - - if isSelected { - selectedView = embedView - selectedViewY = currentY - } - - currentY += height + spacing - - case .image: - let imageView: EmbeddedImageView - if let existing = existingView as? EmbeddedImageView { - imageView = existing - } else { - imageView = EmbeddedImageView() - imageView.identifier = NSUserInterfaceItemIdentifier(embed.id) - imageView.onScrollToMarkdown = { [range = embed.range] in - onScrollToEmbed?(range) - } - documentView.addSubview(imageView) - } - - imageView.configure( - caption: embed.caption, - imageURL: embed.resolvedURL, - isUnresolved: embed.isUnresolved, - isSelected: isSelected - ) - - let height: CGFloat = embed.caption != nil ? 246 : 228 - imageView.frame = NSRect(x: 0, y: currentY, width: width, height: height) - - if isSelected { - selectedView = imageView - selectedViewY = currentY - } - - currentY += height + spacing - } - } - - // Remove views that are no longer needed - documentView.subviews.forEach { view in - if let id = view.identifier?.rawValue, !processedIDs.contains(id) { - view.removeFromSuperview() - } - } - - // Set document view size - let totalHeight = max(currentY - spacing + 8, scrollView.bounds.height) - documentView.frame = NSRect(x: 0, y: 0, width: width, height: totalHeight) - - // Scroll selected view into view - if let selectedView = selectedView { - let visibleRect = NSRect( - x: 0, - y: selectedViewY, - width: width, - height: selectedView.frame.height - ) - scrollView.contentView.scrollToVisible(visibleRect) - } - } -} - -// NSView subclass with flipped coordinate system so (0,0) is at top-left -final class FlippedNSView: NSView { - override var isFlipped: Bool { true } -} - -final class EmbeddedNoteView: NSView { - private let contentScrollView = NSScrollView() - private let contentTextView = NSTextView() - private let titleField = NSTextField(labelWithString: "") - private let borderView = NSView() - private let openButton = NSButton() - private var targetURL: URL? - var onOpenNote: ((URL, Bool) -> Void)? // (url, openInNewTab) - - // Fixed dimensions for the right-aligned panel - private let panelWidth: CGFloat = 280 - private let maxPanelHeight: CGFloat = 400 - private let minPanelHeight: CGFloat = 120 - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - setup() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - setup() - } - - func configure(noteName: String, content: String?, noteURL: URL?, isUnresolved: Bool) { - targetURL = noteURL - titleField.stringValue = isUnresolved ? "Note not found: \(noteName)" : noteName - - if isUnresolved { - contentTextView.string = "" - contentScrollView.isHidden = true - borderView.layer?.backgroundColor = SynapseTheme.nsPanelElevated.cgColor - borderView.layer?.borderColor = SynapseTheme.nsError.cgColor - } else if let content = content { - let styledContent = styleMarkdownContent(content, fontSize: 11) - contentTextView.textStorage?.setAttributedString(styledContent) - contentScrollView.isHidden = false - borderView.layer?.backgroundColor = SynapseTheme.nsPanelElevated.cgColor - borderView.layer?.borderColor = SynapseTheme.nsBorder.cgColor - } - - openButton.isHidden = (noteURL == nil) - updateColors() - } - - /// Re-applies all theme-dependent colors. Safe to call any time the theme changes. - func updateColors() { - borderView.layer?.backgroundColor = SynapseTheme.nsPanelElevated.cgColor - titleField.textColor = SynapseTheme.nsTextPrimary - contentTextView.backgroundColor = SynapseTheme.editorCodeBackground - contentTextView.textColor = SynapseTheme.nsTextSecondary - contentScrollView.backgroundColor = SynapseTheme.editorCodeBackground - // Re-style markdown content with the new theme colors - if let text = contentTextView.string.isEmpty ? nil : contentTextView.string { - let styledContent = styleMarkdownContent(text, fontSize: 11) - contentTextView.textStorage?.setAttributedString(styledContent) - } - } - - override func layout() { - super.layout() - wantsLayer = true - layer?.cornerRadius = 6 - layer?.masksToBounds = true - - let padding: CGFloat = 12 - let buttonHeight: CGFloat = 28 - let titleHeight: CGFloat = 20 - let spacing: CGFloat = 8 - - // Border view fills the entire frame - borderView.frame = bounds - - // Title at top - titleField.frame = NSRect( - x: padding, - y: bounds.height - padding - titleHeight, - width: bounds.width - padding * 2, - height: titleHeight - ) - - // Open button at bottom - openButton.frame = NSRect( - x: bounds.width - padding - 80, - y: padding, - width: 80, - height: buttonHeight - ) - - // Content scroll view fills the middle area - if !contentScrollView.isHidden { - let contentY = buttonHeight + padding + spacing - let contentHeight = bounds.height - contentY - titleHeight - spacing * 2 - contentScrollView.frame = NSRect( - x: padding, - y: contentY, - width: bounds.width - padding * 2, - height: max(0, contentHeight) - ) - } - } - - @objc private func openNote() { - guard let url = targetURL else { return } - // Check if Command key is held (for opening in new tab) - let openInNewTab = NSEvent.modifierFlags.contains(.command) - onOpenNote?(url, openInNewTab) - } - - private func setup() { - // Border view - borderView.wantsLayer = true - borderView.layer?.cornerRadius = 6 - borderView.layer?.masksToBounds = true - borderView.layer?.borderWidth = 1 - borderView.layer?.backgroundColor = SynapseTheme.nsPanelElevated.cgColor - borderView.layer?.borderColor = SynapseTheme.nsBorder.cgColor - borderView.autoresizingMask = [.width, .height] - addSubview(borderView) - - // Title field - titleField.font = .systemFont(ofSize: 13, weight: .semibold) - titleField.textColor = SynapseTheme.nsTextPrimary - titleField.lineBreakMode = .byTruncatingTail - addSubview(titleField) - - // Content text view (read-only) - contentTextView.isEditable = false - contentTextView.isSelectable = true - contentTextView.isRichText = false - contentTextView.backgroundColor = SynapseTheme.editorCodeBackground - contentTextView.textContainerInset = NSSize(width: 8, height: 8) - contentTextView.font = .systemFont(ofSize: 11) - contentTextView.textColor = SynapseTheme.nsTextSecondary - - // Content scroll view - contentScrollView.documentView = contentTextView - contentScrollView.hasVerticalScroller = true - contentScrollView.autohidesScrollers = true - contentScrollView.borderType = .bezelBorder - contentScrollView.backgroundColor = SynapseTheme.editorCodeBackground - contentScrollView.isHidden = true - addSubview(contentScrollView) - - // Open button - openButton.title = "Open" - openButton.target = self - openButton.action = #selector(openNote) - openButton.bezelStyle = .rounded - openButton.font = .systemFont(ofSize: 11, weight: .medium) - addSubview(openButton) - } - - // Return the preferred size for this panel - func preferredSize(for content: String?) -> NSSize { - let padding: CGFloat = 12 - let buttonHeight: CGFloat = 28 - let titleHeight: CGFloat = 20 - let spacing: CGFloat = 8 - - if content == nil { - // Unresolved: just title + button - return NSSize(width: panelWidth, height: minPanelHeight) - } - - // Calculate content height based on text - let textStorage = NSTextStorage(string: content!) - let layoutManager = NSLayoutManager() - let textContainer = NSTextContainer(containerSize: NSSize(width: panelWidth - padding * 2 - 20, height: .greatestFiniteMagnitude)) - textContainer.lineFragmentPadding = 0 - - layoutManager.addTextContainer(textContainer) - textStorage.addLayoutManager(layoutManager) - layoutManager.ensureLayout(for: textContainer) - - let contentHeight = layoutManager.usedRect(for: textContainer).height + 16 // +16 for insets - let totalHeight = padding + buttonHeight + spacing + min(contentHeight, 300) + spacing + titleHeight + padding - - return NSSize(width: panelWidth, height: min(max(totalHeight, minPanelHeight), maxPanelHeight)) - } -} - -// MARK: - Embedded Image View - -final class EmbeddedImageView: NSView { - private let imageView = NSImageView() - private let captionField = NSTextField(labelWithString: "") - private let borderView = NSView() - private let previewBackgroundView = NSView() - private let openButton = NSButton() - private var targetURL: URL? - private var isSelected: Bool = false - var onScrollToMarkdown: (() -> Void)? - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - setup() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - setup() - } - - func configure(caption: String?, imageURL: URL?, isUnresolved: Bool, isSelected: Bool = false) { - targetURL = imageURL - self.isSelected = isSelected - openButton.isHidden = imageURL == nil || isUnresolved - - if isUnresolved { - captionField.stringValue = caption ?? "Image not found" - imageView.image = nil - } else { - captionField.stringValue = caption ?? "" - // Load image asynchronously - if let imageURL = imageURL { - loadImage(from: imageURL) - } - } - - captionField.isHidden = (caption == nil || caption?.isEmpty == true) - - // Update border color based on selection state - updateBorderAppearance() - updateColors() - } - - private func updateBorderAppearance() { - borderView.layer?.borderWidth = isSelected ? 3 : 1 - borderView.layer?.borderColor = isSelected - ? NSColor(SynapseTheme.accent).cgColor - : NSColor(SynapseTheme.border).cgColor - } - - /// Re-applies all theme-dependent colors. Safe to call any time the theme changes. - func updateColors() { - borderView.layer?.backgroundColor = SynapseTheme.nsPanelElevated.cgColor - previewBackgroundView.layer?.backgroundColor = SynapseTheme.editorCodeBackground.cgColor - updateBorderAppearance() - captionField.textColor = NSColor(SynapseTheme.textSecondary) - } - - private func loadImage(from url: URL) { - // Load image in background - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - guard let image = NSImage(contentsOf: url) else { return } - DispatchQueue.main.async { - self?.imageView.image = image - self?.needsLayout = true - } - } - } - - override func layout() { - super.layout() - - wantsLayer = true - layer?.cornerRadius = 6 - layer?.masksToBounds = true - - // Update border frame and appearance - borderView.frame = bounds - updateBorderAppearance() - - let padding: CGFloat = 12 - let spacing: CGFloat = 10 - let buttonHeight: CGFloat = openButton.isHidden ? 0 : 24 - let captionHeight: CGFloat = captionField.isHidden ? 0 : 20 - - let buttonY = padding - let previewBottom = buttonY + buttonHeight + (openButton.isHidden ? 0 : spacing) - let previewTop = bounds.height - padding - captionHeight - (captionField.isHidden ? 0 : spacing) - let previewRect = NSRect( - x: padding, - y: previewBottom, - width: bounds.width - padding * 2, - height: max(120, previewTop - previewBottom) - ) - - previewBackgroundView.frame = previewRect - - let contentRect = previewRect.insetBy(dx: 8, dy: 8) - if let image = imageView.image, image.size.width > 0, image.size.height > 0 { - let widthRatio = contentRect.width / image.size.width - let heightRatio = contentRect.height / image.size.height - let scale = min(widthRatio, heightRatio) - let drawSize = NSSize(width: image.size.width * scale, height: image.size.height * scale) - imageView.frame = NSRect( - x: round(contentRect.midX - drawSize.width / 2), - y: round(contentRect.midY - drawSize.height / 2), - width: round(drawSize.width), - height: round(drawSize.height) - ) - } else { - imageView.frame = contentRect - } - imageView.imageScaling = .scaleProportionallyUpOrDown - imageView.contentTintColor = nil - - // Caption label - if !captionField.isHidden { - captionField.frame = NSRect( - x: padding, - y: bounds.height - padding - captionHeight, - width: bounds.width - padding * 2, - height: captionHeight - ) - captionField.font = .monospacedSystemFont(ofSize: 12, weight: .semibold) - captionField.textColor = NSColor(SynapseTheme.textSecondary) - captionField.lineBreakMode = .byTruncatingMiddle - captionField.alignment = .left - } - - let buttonWidth = min(124, bounds.width - padding * 2) - openButton.frame = NSRect( - x: round((bounds.width - buttonWidth) / 2), - y: padding, - width: buttonWidth, - height: buttonHeight - ) - openButton.bezelStyle = .rounded - openButton.font = .systemFont(ofSize: 11, weight: .semibold) - } - - private var imageViewerController: ImageViewerWindowController? - - @objc private func openImage() { - guard let targetURL = targetURL else { return } - - let viewer = ImageViewerWindowController(imageURL: targetURL, caption: captionField.stringValue.isEmpty ? nil : captionField.stringValue) - imageViewerController = viewer // retain strongly so it isn't deallocated before image loads - viewer.showFullScreen() - } - - private func setup() { - // Setup border view layer properties - borderView.wantsLayer = true - borderView.layer?.cornerRadius = 6 - borderView.layer?.masksToBounds = true - borderView.layer?.backgroundColor = SynapseTheme.nsPanelElevated.cgColor - - addSubview(borderView) - previewBackgroundView.wantsLayer = true - previewBackgroundView.layer?.cornerRadius = 8 - previewBackgroundView.layer?.masksToBounds = true - previewBackgroundView.layer?.backgroundColor = SynapseTheme.editorCodeBackground.cgColor - previewBackgroundView.layer?.borderWidth = 0 - addSubview(previewBackgroundView) - addSubview(imageView) - addSubview(captionField) - - openButton.title = "Open" - openButton.bezelStyle = .rounded - openButton.target = self - openButton.action = #selector(openImage) - addSubview(openButton) - - // Click on image thumbnail scrolls editor to the markdown - let click = NSClickGestureRecognizer(target: self, action: #selector(thumbnailClicked)) - imageView.addGestureRecognizer(click) - - // Initial border appearance - updateBorderAppearance() - } - - @objc private func thumbnailClicked() { - onScrollToMarkdown?() - } -} - -// MARK: - Full Screen Image Viewer - -/// A full-screen window for viewing images with zoom and pan support -final class ImageViewerWindowController: NSWindowController { - private let imageView = NSImageView() - private let imageContainerView = NSView() - private var imageURL: URL? - private var localMonitor: Any? - private var scrollMonitor: Any? - private var scrollView: NSScrollView! - private var currentZoom: CGFloat = 1.0 - private var minZoom: CGFloat = 0.1 - private var maxZoom: CGFloat = 5.0 - private var imageSize: NSSize = .zero - private var imageWidthConstraint: NSLayoutConstraint? - private var imageHeightConstraint: NSLayoutConstraint? - private var gestureStartZoom: CGFloat = 1.0 - - init(imageURL: URL, caption: String?) { - let window = NSWindow( - contentRect: NSScreen.main?.frame ?? NSRect(x: 0, y: 0, width: 800, height: 600), - styleMask: [.titled, .closable, .fullSizeContentView, .resizable], - backing: .buffered, - defer: false - ) - window.title = caption ?? imageURL.lastPathComponent - window.titlebarAppearsTransparent = false - window.backgroundColor = .black - window.isOpaque = true - window.hasShadow = true - - super.init(window: window) - - self.imageURL = imageURL - setupContentView() - setupImageView() - setupCloseButton() - setupEscapeHandler() - loadImage() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - if let monitor = localMonitor { - NSEvent.removeMonitor(monitor) - } - if let monitor = scrollMonitor { - NSEvent.removeMonitor(monitor) - } - } - - private func setupContentView() { - guard let window = window else { return } - - scrollView = NSScrollView(frame: window.contentView?.bounds ?? .zero) - scrollView.autoresizingMask = [.width, .height] - scrollView.hasVerticalScroller = false - scrollView.hasHorizontalScroller = false - scrollView.autohidesScrollers = true - scrollView.allowsMagnification = false - scrollView.backgroundColor = .black - scrollView.drawsBackground = true - - window.contentView = scrollView - } - - private func setupImageView() { - imageContainerView.wantsLayer = true - imageContainerView.layer?.backgroundColor = NSColor.black.cgColor - imageContainerView.frame = scrollView.bounds - - imageView.imageScaling = .scaleAxesIndependently - imageView.imageAlignment = .alignCenter - imageView.wantsLayer = true - imageView.layer?.backgroundColor = NSColor.clear.cgColor - imageView.translatesAutoresizingMaskIntoConstraints = false - - imageContainerView.addSubview(imageView) - imageWidthConstraint = imageView.widthAnchor.constraint(equalToConstant: 100) - imageHeightConstraint = imageView.heightAnchor.constraint(equalToConstant: 100) - - NSLayoutConstraint.activate([ - imageView.centerXAnchor.constraint(equalTo: imageContainerView.centerXAnchor), - imageView.centerYAnchor.constraint(equalTo: imageContainerView.centerYAnchor), - imageWidthConstraint!, - imageHeightConstraint! - ]) - - scrollView.documentView = imageContainerView - - setupGestureRecognizers() - setupScrollWheelZoom() - } - - private func setupScrollWheelZoom() { - scrollMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] event in - guard let self = self, let window = self.window else { return event } - - if NSApp.keyWindow == window && event.modifierFlags.contains(.control) { - let delta = event.scrollingDeltaY - let zoomFactor = pow(1.01, delta * 0.35) - let newZoom = self.currentZoom * zoomFactor - self.setZoom(newZoom, animated: false) - return nil - } - return event - } - } - - private func setupGestureRecognizers() { - let doubleClickGesture = NSClickGestureRecognizer(target: self, action: #selector(handleDoubleClick)) - doubleClickGesture.numberOfClicksRequired = 2 - imageView.addGestureRecognizer(doubleClickGesture) - - let magnifyGesture = NSMagnificationGestureRecognizer(target: self, action: #selector(handleMagnify(_:))) - imageView.addGestureRecognizer(magnifyGesture) - } - - @objc private func handleDoubleClick() { - // Toggle between fit-to-screen and 100% zoom - if currentZoom != 1.0 { - setZoom(1.0, animated: true) - } else { - fitImageToScreen() - } - } - - @objc private func handleMagnify(_ gesture: NSMagnificationGestureRecognizer) { - switch gesture.state { - case .began: - gestureStartZoom = currentZoom - case .changed: - let newZoom = gestureStartZoom * (1 + gesture.magnification) - setZoom(newZoom, animated: false) - default: - break - } - } - - private func setZoom(_ zoom: CGFloat, animated: Bool) { - let clampedZoom = max(minZoom, min(maxZoom, zoom)) - currentZoom = clampedZoom - - let applyLayout = { self.layoutImage(centerViewport: true) } - if animated { - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.15 - applyLayout() - } - } else { - applyLayout() - } - } - - private func fitImageToScreen() { - guard imageSize != .zero, let window = window else { return } - - let visibleFrame = window.contentView?.bounds ?? window.frame - let titleBarHeight: CGFloat = 28 - let availableHeight = visibleFrame.height - titleBarHeight - 40 - let availableWidth = visibleFrame.width - 40 - - let widthRatio = availableWidth / imageSize.width - let heightRatio = availableHeight / imageSize.height - currentZoom = min(widthRatio, heightRatio, 1.0) - layoutImage(centerViewport: true) - } - - private func layoutImage(centerViewport: Bool) { - guard imageSize != .zero else { return } - - let visibleSize = scrollView.contentView.bounds.size - let scaledSize = NSSize(width: imageSize.width * currentZoom, height: imageSize.height * currentZoom) - let containerSize = NSSize( - width: max(visibleSize.width, scaledSize.width), - height: max(visibleSize.height, scaledSize.height) - ) - - imageContainerView.frame = NSRect(origin: .zero, size: containerSize) - imageWidthConstraint?.constant = scaledSize.width - imageHeightConstraint?.constant = scaledSize.height - imageContainerView.layoutSubtreeIfNeeded() - - if centerViewport { - let centeredOrigin = NSPoint( - x: max(0, (containerSize.width - visibleSize.width) / 2), - y: max(0, (containerSize.height - visibleSize.height) / 2) - ) - scrollView.contentView.scroll(to: centeredOrigin) - scrollView.reflectScrolledClipView(scrollView.contentView) - } - } - - private func setupCloseButton() { - // Native window close button (traffic light) is sufficient - } - - private func setupEscapeHandler() { - localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in - guard let self = self, let window = self.window else { return event } - - if NSApp.keyWindow == window && event.keyCode == 53 { - self.closeWindow() - return nil - } - return event - } - } - - private func loadImage() { - guard let imageURL = imageURL else { return } - - // Handle remote URLs (http/https) - if imageURL.scheme?.lowercased() == "http" || imageURL.scheme?.lowercased() == "https" { - downloadRemoteImage(from: imageURL) - return - } - - // Handle local file URLs - let fileManager = FileManager.default - if !fileManager.fileExists(atPath: imageURL.path) { - print("Image file does not exist at: \(imageURL.path)") - return - } - - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - guard let image = NSImage(contentsOf: imageURL) else { - print("Failed to load image from: \(imageURL.path)") - return - } - - DispatchQueue.main.async { - self?.imageSize = image.size - self?.imageView.image = image - self?.updateImageViewSize() - self?.fitImageToScreen() - print("Image loaded successfully: \(image.size.width)x\(image.size.height)") - } - } - } - - private func downloadRemoteImage(from url: URL) { - print("Downloading remote image from: \(url.absoluteString)") - - let task = URLSession.shared.dataTask(with: url) { [weak self] data, response, error in - if let error = error { - print("Failed to download image: \(error.localizedDescription)") - return - } - - guard let data = data, let image = NSImage(data: data) else { - print("Failed to create image from downloaded data") - return - } - - DispatchQueue.main.async { - self?.imageSize = image.size - self?.imageView.image = image - self?.updateImageViewSize() - self?.fitImageToScreen() - print("Remote image loaded successfully: \(image.size.width)x\(image.size.height)") - } - } - - task.resume() - } - - private func updateImageViewSize() { - layoutImage(centerViewport: true) - } - - @objc private func closeWindow() { - window?.close() - } - - func showFullScreen() { - window?.center() - window?.makeKeyAndOrderFront(nil) - - // Make it nearly full screen but keep title bar - if let screen = NSScreen.main { - let screenFrame = screen.visibleFrame - let padding: CGFloat = 40 - let newFrame = NSRect( - x: screenFrame.origin.x + padding, - y: screenFrame.origin.y + padding, - width: screenFrame.width - (padding * 2), - height: screenFrame.height - (padding * 2) - ) - window?.setFrame(newFrame, display: true, animate: true) - } - } -} - -final class YouTubePreviewView: NSView { - private let thumbnailView = NSImageView() - private let overlay = NSView() - private let playIcon = NSImageView() - private let titleField = NSTextField(labelWithString: "") - private let subtitleField = NSTextField(labelWithString: "") - private let actionButton = NSButton() - private var targetURL: URL? - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - setup() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - setup() - } - - override func layout() { - super.layout() - wantsLayer = true - layer?.cornerRadius = 4 - layer?.masksToBounds = true - layer?.borderWidth = 1 - layer?.borderColor = NSColor(SynapseTheme.border).cgColor - layer?.backgroundColor = SynapseTheme.editorCodeBackground.cgColor - - thumbnailView.frame = bounds - overlay.frame = bounds - actionButton.frame = bounds - - let iconSize: CGFloat = 54 - playIcon.frame = NSRect(x: 20, y: bounds.midY - iconSize / 2, width: iconSize, height: iconSize) - - let textX = playIcon.frame.maxX + 18 - let textWidth = max(160, bounds.width - textX - 20) - titleField.frame = NSRect(x: textX, y: bounds.midY + 2, width: textWidth, height: 28) - subtitleField.frame = NSRect(x: textX, y: bounds.midY - 28, width: textWidth, height: 44) - } - - @objc private func openVideo() { - guard let targetURL else { return } - NSWorkspace.shared.open(targetURL) - } - - private func setup() { - thumbnailView.imageScaling = .scaleProportionallyUpOrDown - thumbnailView.imageAlignment = .alignCenter - thumbnailView.autoresizingMask = [.width, .height] - addSubview(thumbnailView) - - overlay.wantsLayer = true - overlay.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.35).cgColor - overlay.autoresizingMask = [.width, .height] - addSubview(overlay) - - if let image = NSImage(systemSymbolName: "play.circle.fill", accessibilityDescription: nil) { - playIcon.image = image - } - playIcon.contentTintColor = .white - addSubview(playIcon) - - titleField.font = .systemFont(ofSize: 20, weight: .bold) - titleField.textColor = NSColor(SynapseTheme.textPrimary) - titleField.lineBreakMode = .byTruncatingTail - addSubview(titleField) - - subtitleField.font = .systemFont(ofSize: 12, weight: .medium) - subtitleField.textColor = NSColor(SynapseTheme.textSecondary) - subtitleField.lineBreakMode = .byTruncatingMiddle - addSubview(subtitleField) - - actionButton.isBordered = false - actionButton.title = "" - actionButton.target = self - actionButton.action = #selector(openVideo) - actionButton.autoresizingMask = [.width, .height] - addSubview(actionButton) - } -} - -// MARK: - Completion popover - -class CompletionViewController: NSViewController { - var onSelect: ((URL) -> Void)? - private let searchField = NSSearchField() - private let tableView = NSTableView() - private let scrollView = NSScrollView() - private var allFiles: [URL] = [] - private var filteredFiles: [URL] = [] - - override func loadView() { - view = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 260)) - - searchField.placeholderString = "Search files..." - searchField.sendsSearchStringImmediately = true - searchField.target = self - searchField.action = #selector(searchChanged) - searchField.delegate = self - searchField.font = .systemFont(ofSize: 12) - - let col = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("name")) - col.isEditable = false - tableView.addTableColumn(col) - tableView.headerView = nil - tableView.rowHeight = 26 - tableView.dataSource = self - tableView.delegate = self - tableView.doubleAction = #selector(selectItem) - tableView.target = self - tableView.allowsEmptySelection = false - - scrollView.documentView = tableView - scrollView.hasVerticalScroller = true - scrollView.autohidesScrollers = true - - let container = NSView() - container.translatesAutoresizingMaskIntoConstraints = false - searchField.translatesAutoresizingMaskIntoConstraints = false - scrollView.translatesAutoresizingMaskIntoConstraints = false - container.addSubview(searchField) - container.addSubview(scrollView) - - NSLayoutConstraint.activate([ - searchField.topAnchor.constraint(equalTo: container.topAnchor, constant: 8), - searchField.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 8), - searchField.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -8), - scrollView.topAnchor.constraint(equalTo: searchField.bottomAnchor, constant: 6), - scrollView.leadingAnchor.constraint(equalTo: container.leadingAnchor), - scrollView.trailingAnchor.constraint(equalTo: container.trailingAnchor), - scrollView.bottomAnchor.constraint(equalTo: container.bottomAnchor), - ]) - view.addSubview(container) - NSLayoutConstraint.activate([ - container.topAnchor.constraint(equalTo: view.topAnchor), - container.leadingAnchor.constraint(equalTo: view.leadingAnchor), - container.trailingAnchor.constraint(equalTo: view.trailingAnchor), - container.bottomAnchor.constraint(equalTo: view.bottomAnchor), - scrollView.heightAnchor.constraint(greaterThanOrEqualToConstant: 160), - ]) - } - - @objc private func searchChanged() { - applyFilter() - } - - private func normalize(_ value: String) -> String { - value - .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) - .components(separatedBy: .newlines).joined() - .lowercased() - .trimmingCharacters(in: .whitespacesAndNewlines) - } - - private func scoreFile(_ url: URL, query: String) -> Int? { - if query.isEmpty { return 1 } - let name = normalize(url.deletingPathExtension().lastPathComponent) - let path = normalize(url.path) - if let range = name.range(of: query) { - let offset = name.distance(from: name.startIndex, to: range.lowerBound) - return 400 - min(offset, 300) - } - if let range = path.range(of: query) { - let offset = path.distance(from: path.startIndex, to: range.lowerBound) - return 200 - min(offset, 180) - } - return nil - } - - private func applyFilter() { - let query = normalize(searchField.stringValue) - filteredFiles = allFiles - .compactMap { url -> (URL, Int)? in - guard let score = scoreFile(url, query: query) else { return nil } - return (url, score) - } - .sorted { $0.1 > $1.1 } - .map { $0.0 } - tableView.reloadData() - if !filteredFiles.isEmpty { - tableView.selectRowIndexes([0], byExtendingSelection: false) - } - } - - @objc func selectItem() { - let row = tableView.selectedRow - guard row >= 0, row < filteredFiles.count else { return } - onSelect?(filteredFiles[row]) - } - - func selectCurrentItem() { selectItem() } - - func moveSelection(by delta: Int) { - guard !filteredFiles.isEmpty else { return } - let current = max(0, tableView.selectedRow) - let next = max(0, min(filteredFiles.count - 1, current + delta)) - tableView.selectRowIndexes([next], byExtendingSelection: false) - tableView.scrollRowToVisible(next) - } -} - -extension CompletionViewController: NSTableViewDataSource, NSTableViewDelegate { - func numberOfRows(in tableView: NSTableView) -> Int { filteredFiles.count } - - func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { - let name = filteredFiles[row].deletingPathExtension().lastPathComponent - let cell = NSTextField(labelWithString: name) - cell.font = .systemFont(ofSize: 13) - cell.lineBreakMode = .byTruncatingMiddle - return cell - } - - func tableViewSelectionDidChange(_ notification: Notification) {} -} - -extension CompletionViewController: NSSearchFieldDelegate, NSControlTextEditingDelegate { - func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { - switch commandSelector { - case #selector(NSResponder.moveUp(_:)): - moveSelection(by: -1) - return true - case #selector(NSResponder.moveDown(_:)): - moveSelection(by: 1) - return true - case #selector(NSResponder.insertNewline(_:)): - selectCurrentItem() - return true - default: - return false - } - } -} - -// MARK: - Code Block Copy Button - -private enum CodeBlockCopyButtonAssociatedKeys { - static var buttons: UInt8 = 0 -} - -final class CodeBlockCopyButton: NSButton { - var codeContent: String = "" - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - isBordered = false - bezelStyle = .inline - wantsLayer = true - layer?.cornerRadius = 4 - layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.8).cgColor - image = NSImage(systemSymbolName: "doc.on.doc", accessibilityDescription: "Copy code") - imageScaling = .scaleProportionallyDown - contentTintColor = NSColor.secondaryLabelColor - toolTip = "Copy code" - target = self - action = #selector(handleClick) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func resetCursorRects() { - discardCursorRects() - addCursorRect(bounds, cursor: .pointingHand) - } - - override func acceptsFirstMouse(for event: NSEvent?) -> Bool { - true - } - - @objc private func handleClick() { - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(codeContent, forType: .string) - - contentTintColor = NSColor.systemGreen - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - self?.contentTintColor = NSColor.secondaryLabelColor - } - } -} - -/// Represents a detected code block in markdown -struct CodeBlockMatch: Equatable { - let id: String - let range: NSRange - let content: String - let language: String? - - static func == (lhs: CodeBlockMatch, rhs: CodeBlockMatch) -> Bool { - lhs.id == rhs.id && - lhs.range == rhs.range && - lhs.content == rhs.content && - lhs.language == rhs.language - } -} - -extension LinkAwareTextView { - - var codeBlockCopyButtons: [String: NSButton] { - get { - (objc_getAssociatedObject(self, &CodeBlockCopyButtonAssociatedKeys.buttons) as? [String: NSButton]) ?? [:] - } - set { - objc_setAssociatedObject(self, &CodeBlockCopyButtonAssociatedKeys.buttons, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - } - } - - /// Regex pattern to detect code blocks: ```optional_language\ncode\n``` - /// Only matches opening ``` at the start of a line or string - private static let codeBlockRegex = try? NSRegularExpression( - pattern: "^[ \\t]{0,3}```([a-zA-Z0-9+-]*)[ \\t]*$", - options: [.anchorsMatchLines] - ) - - /// Find all code blocks in the current text - func codeBlockMatches() -> [CodeBlockMatch] { - guard let regex = Self.codeBlockRegex else { return [] } - let nsText = string as NSString - let fullRange = NSRange(location: 0, length: nsText.length) - - let fenceMatches = regex.matches(in: string, options: [], range: fullRange) - var matches: [CodeBlockMatch] = [] - var index = 0 - - while index + 1 < fenceMatches.count { - let openingMatch = fenceMatches[index] - let closingMatch = fenceMatches[index + 1] - let openingRange = openingMatch.range(at: 0) - let languageRange = openingMatch.range(at: 1) - let closingRange = closingMatch.range(at: 0) - - let contentStart = openingRange.location + openingRange.length - let contentLength = closingRange.location - contentStart - guard contentLength >= 0 else { - index += 2 - continue - } - - let contentRange = NSRange(location: contentStart, length: contentLength) - var content = nsText.substring(with: contentRange) - if content.hasPrefix("\r\n") { - content.removeFirst(2) - } else if content.hasPrefix("\n") { - content.removeFirst() - } - if content.hasSuffix("\r\n") { - content.removeLast(2) - } else if content.hasSuffix("\n") { - content.removeLast() - } - - let language = languageRange.length > 0 ? nsText.substring(with: languageRange) : nil - let fullRange = NSRange(location: openingRange.location, length: closingRange.location + closingRange.length - openingRange.location) - let id = "\(openingRange.location)-\(openingRange.length)" - - matches.append(CodeBlockMatch( - id: id, - range: fullRange, - content: content, - language: language - )) - - index += 2 - } - - return matches - } - - /// Create and position copy buttons for all code blocks - func refreshCodeBlockCopyButtons() { - guard let layoutManager = layoutManager, - let textContainer = textContainer else { return } - - layoutManager.ensureLayout(for: textContainer) - - let matches = codeBlockMatches() - let activeKeys = Set(matches.map(\.id)) - - // Remove stale buttons - for key in Array(codeBlockCopyButtons.keys) where !activeKeys.contains(key) { - codeBlockCopyButtons[key]?.removeFromSuperview() - codeBlockCopyButtons.removeValue(forKey: key) - } - let buttonSize: CGFloat = 24 - let buttonMargin: CGFloat = 8 - let minBlockHeight = buttonSize + buttonMargin * 2 - - for match in matches { - // Get the rect of the code block - let glyphRange = layoutManager.glyphRange(forCharacterRange: match.range, actualCharacterRange: nil) - var codeBlockRect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) - codeBlockRect.origin.x += textContainerOrigin.x - codeBlockRect.origin.y += textContainerOrigin.y - // Guarantee enough height for the button even on very short blocks - if codeBlockRect.height < minBlockHeight { - codeBlockRect.size.height = minBlockHeight - } - - // Position button at top-right corner - let buttonX = codeBlockRect.maxX - buttonSize - buttonMargin - let buttonY = codeBlockRect.minY + buttonMargin - - let button: CodeBlockCopyButton - if let existing = codeBlockCopyButtons[match.id] { - guard let existingButton = existing as? CodeBlockCopyButton else { - existing.removeFromSuperview() - let replacementButton = createCopyButton(for: match) - addSubview(replacementButton, positioned: .above, relativeTo: nil) - codeBlockCopyButtons[match.id] = replacementButton - replacementButton.frame = NSRect(x: buttonX, y: buttonY, width: buttonSize, height: buttonSize) - continue - } - button = existingButton - } else { - button = createCopyButton(for: match) - addSubview(button, positioned: .above, relativeTo: nil) - codeBlockCopyButtons[match.id] = button - } - - button.codeContent = match.content - button.frame = NSRect(x: buttonX, y: buttonY, width: buttonSize, height: buttonSize) - } - } - - /// Create a copy button for a specific code block - private func createCopyButton(for match: CodeBlockMatch) -> CodeBlockCopyButton { - let button = CodeBlockCopyButton(frame: .zero) - button.identifier = NSUserInterfaceItemIdentifier(match.id) - button.codeContent = match.content - return button - } - - /// Remove all code block copy buttons - func clearCodeBlockCopyButtons() { - for (_, button) in codeBlockCopyButtons { - button.removeFromSuperview() - } - codeBlockCopyButtons.removeAll() - } -} - -// MARK: - Markdown Preview with WKWebView - -struct MarkdownPreviewView: NSViewRepresentable { - let markdownContent: String - let isDarkMode: Bool - let bodyFontFamily: String - let monoFontFamily: String - let fontSize: Int - let lineHeight: Double - var currentFileURL: URL? = nil - var onResolveWikilink: ((String) -> Void)? = nil - var onToggleCheckbox: ((Int) -> Void)? = nil - - class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { - var parent: MarkdownPreviewView - var lastMarkdown: String? - var lastIsDarkMode: Bool? - var lastBodyFontFamily: String? - var lastMonoFontFamily: String? - var lastFontSize: Int? - var lastLineHeight: Double? - var lastFileURL: URL? - var pendingScrollY: CGFloat = 0 - - init(_ parent: MarkdownPreviewView) { self.parent = parent } - - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - guard pendingScrollY > 0 else { return } - let y = pendingScrollY - pendingScrollY = 0 - webView.evaluateJavaScript("window.scrollTo(0, \(y))") { _, _ in } - } - - func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { - guard let url = navigationAction.request.url else { decisionHandler(.allow); return } - if url.scheme == "wikilink" { - let destination = (url.host ?? url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))).lowercased() - parent.onResolveWikilink?(destination) - decisionHandler(.cancel) - return - } - if url.scheme == "http" || url.scheme == "https" { - NSWorkspace.shared.open(url) - decisionHandler(.cancel) - return - } - // Allow file:// and about: (initial HTML load) - decisionHandler(.allow) - } - - func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { - if message.name == "toggleCheckbox", let offset = message.body as? Int { - parent.onToggleCheckbox?(offset) - } - } - - } - - func makeCoordinator() -> Coordinator { Coordinator(self) } - - func makeNSView(context: Context) -> WKWebView { - let config = WKWebViewConfiguration() - config.preferences.javaScriptEnabled = true - config.userContentController.add(context.coordinator, name: "toggleCheckbox") - let webView = WKWebView(frame: .zero, configuration: config) - webView.setValue(false, forKey: "drawsBackground") - webView.navigationDelegate = context.coordinator - return webView - } - - func updateNSView(_ webView: WKWebView, context: Context) { - guard markdownContent != context.coordinator.lastMarkdown || - isDarkMode != context.coordinator.lastIsDarkMode || - bodyFontFamily != context.coordinator.lastBodyFontFamily || - monoFontFamily != context.coordinator.lastMonoFontFamily || - fontSize != context.coordinator.lastFontSize || - lineHeight != context.coordinator.lastLineHeight || - currentFileURL != context.coordinator.lastFileURL else { return } - context.coordinator.parent = self - context.coordinator.lastMarkdown = markdownContent - context.coordinator.lastIsDarkMode = isDarkMode - context.coordinator.lastBodyFontFamily = bodyFontFamily - context.coordinator.lastMonoFontFamily = monoFontFamily - context.coordinator.lastFontSize = fontSize - context.coordinator.lastLineHeight = lineHeight - context.coordinator.lastFileURL = currentFileURL - let baseDir = currentFileURL?.deletingLastPathComponent() - let html = generateHTML(from: markdownContent, isDarkMode: isDarkMode, baseDir: baseDir) - // Save scroll position before reload, restore after load finishes - webView.evaluateJavaScript("window.scrollY") { scrollY, _ in - if let y = scrollY as? CGFloat, y > 0 { - context.coordinator.pendingScrollY = y - } - } - webView.loadHTMLString(html, baseURL: baseDir) - } - - private func generateHTML(from markdown: String, isDarkMode: Bool, baseDir: URL? = nil) -> String { - var html = MarkdownPreviewRenderer().renderBody(from: markdown) - // Inline local images as data URIs so they render without file:// access - if let baseDir { - let imgRegex = try? NSRegularExpression(pattern: #" 1 else { return } - let srcRange = match.range(at: 1) - let src = nsHTML.substring(with: srcRange) - // Skip already-inlined or remote URLs - guard !src.hasPrefix("data:"), !src.hasPrefix("http://"), !src.hasPrefix("https://") else { return } - let imageURL = baseDir.appendingPathComponent(src) - guard let data = try? Data(contentsOf: imageURL) else { return } - let ext = imageURL.pathExtension.lowercased() - let mime: String - switch ext { - case "png": mime = "image/png" - case "jpg", "jpeg": mime = "image/jpeg" - case "gif": mime = "image/gif" - case "svg": mime = "image/svg+xml" - case "webp": mime = "image/webp" - default: mime = "application/octet-stream" - } - let dataURI = "data:\(mime);base64,\(data.base64EncodedString())" - replacements.append((srcRange, dataURI)) - } - // Apply replacements in reverse order to preserve ranges - for (range, replacement) in replacements.reversed() { - html = (html as NSString).replacingCharacters(in: range, with: replacement) - } - } - - let textColor = isDarkMode ? "#E0E0E0" : "#333333" - let backgroundColor = isDarkMode ? "#1E1E1E" : "#FFFFFF" - let borderColor = isDarkMode ? "#444444" : "#CCCCCC" - let headerBgColor = isDarkMode ? "#2D2D2D" : "#F5F5F5" - let bodyFontStack = MarkdownPreviewCSS.bodyFontStack(for: bodyFontFamily) - let monoFontStack = MarkdownPreviewCSS.monoFontStack(for: monoFontFamily) - let bodyFontSize = MarkdownPreviewCSS.bodyFontSize(for: fontSize) - let tableFontSize = MarkdownPreviewCSS.tableFontSize(for: fontSize) - let codeFontSize = MarkdownPreviewCSS.codeFontSize(for: fontSize) - let bodyLineHeight = MarkdownPreviewCSS.lineHeight(for: lineHeight) - let h1Size = MarkdownPreviewCSS.headingFontSize(level: 1, baseSize: fontSize) - let h2Size = MarkdownPreviewCSS.headingFontSize(level: 2, baseSize: fontSize) - let h3Size = MarkdownPreviewCSS.headingFontSize(level: 3, baseSize: fontSize) - let h4Size = MarkdownPreviewCSS.headingFontSize(level: 4, baseSize: fontSize) - let h5Size = MarkdownPreviewCSS.headingFontSize(level: 5, baseSize: fontSize) - let h6Size = MarkdownPreviewCSS.headingFontSize(level: 6, baseSize: fontSize) - - return """ - - - - - - - - \(html) - - - """ - } -} diff --git a/macOS/SynapseNotes/HTMLToMarkdownConverter.swift b/macOS/SynapseNotes/HTMLToMarkdownConverter.swift new file mode 100644 index 0000000..6ab6e92 --- /dev/null +++ b/macOS/SynapseNotes/HTMLToMarkdownConverter.swift @@ -0,0 +1,178 @@ +import AppKit + +// MARK: - HTML to Markdown Converter + +/// Converts HTML content to Markdown using NSAttributedString for correct parsing, +/// then walks the attribute runs to emit Markdown syntax. +struct HTMLToMarkdownConverter { + + static func convert(_ html: String) -> String { + let trimmed = html.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + + // Wrap in a minimal HTML document so NSAttributedString's HTML renderer + // uses the correct charset and a neutral sans-serif stylesheet. Without + // the wrapper the renderer can misdetect encoding and apply a monospace / + // code-block stylesheet to the entire content. + let wrapped: String + if trimmed.lowercased().hasPrefix(" + + + \(trimmed) + """ + } + + guard let data = wrapped.data(using: .utf8) else { return trimmed } + + let opts: [NSAttributedString.DocumentReadingOptionKey: Any] = [ + .documentType: NSAttributedString.DocumentType.html, + .characterEncoding: String.Encoding.utf8.rawValue, + ] + + guard let attrStr = try? NSAttributedString(data: data, options: opts, documentAttributes: nil) else { + return trimmed + } + + return markdownFromAttributedString(attrStr) + } + + // MARK: - Attributed string → Markdown + + private static func markdownFromAttributedString(_ attrStr: NSAttributedString) -> String { + let fullString = attrStr.string + var output = "" + + let nsString = fullString as NSString + var paraStart = 0 + + while paraStart < nsString.length { + var paraEnd = 0 + var contentsEnd = 0 + nsString.getParagraphStart(nil, end: ¶End, contentsEnd: &contentsEnd, + for: NSRange(location: paraStart, length: 0)) + + let contentsRange = NSRange(location: paraStart, length: contentsEnd - paraStart) + + // Grab first-character attributes to classify the paragraph. + let attrs = (paraEnd > paraStart) + ? attrStr.attributes(at: paraStart, effectiveRange: nil) + : [:] + + let paraStyle = attrs[.paragraphStyle] as? NSParagraphStyle + let font = attrs[.font] as? NSFont + let fontSize = font?.pointSize ?? 12 + let headingLevel = headingLevelForFontSize(fontSize) + let isListItem = isListItemParagraph(paraStyle) + let isOrdered = isOrderedListItem(attrStr, range: contentsRange) + + // Build inline content, suppressing bold on headings (NSAttributedString + // makes heading text bold by default — that would double-format it). + let inlineContent = inlineMarkdown(attrStr, range: contentsRange, + suppressBold: headingLevel > 0) + .trimmingCharacters(in: .whitespacesAndNewlines) + + if inlineContent.isEmpty { + if !output.isEmpty { output += "\n" } + } else if headingLevel > 0 { + let hashes = String(repeating: "#", count: headingLevel) + output += "\(hashes) \(inlineContent)\n\n" + } else if isListItem { + let marker = isOrdered ? "1." : "-" + let indent = indentForParagraphStyle(paraStyle) + output += "\(indent)\(marker) \(inlineContent)\n" + } else { + output += "\(inlineContent)\n\n" + } + + paraStart = paraEnd + } + + return output + .replacingOccurrences(of: "\n{3,}", with: "\n\n", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + // MARK: - Inline span rendering + + private static func inlineMarkdown(_ attrStr: NSAttributedString, range: NSRange, + suppressBold: Bool = false) -> String { + guard range.length > 0 else { return "" } + + var output = "" + + attrStr.enumerateAttributes(in: range, options: []) { attrs, spanRange, _ in + let text = (attrStr.string as NSString).substring(with: spanRange) + + // Strip tabs (list marker column) and Unicode bullets NSAttributedString + // inserts for
      items (U+2022 •, U+25E6 ◦, U+25AA ▪, etc.) + var cleaned = text.replacingOccurrences(of: "\t", with: "") + cleaned = cleaned.unicodeScalars.filter { scalar in + // Drop Unicode list-marker bullet characters + ![0x2022, 0x25E6, 0x25AA, 0x25AB, 0x2023, 0x2043].contains(scalar.value) + }.reduce("") { $0 + String($1) } + + guard !cleaned.isEmpty else { return } + + let font = attrs[.font] as? NSFont + let isBold = !suppressBold && (font?.fontDescriptor.symbolicTraits.contains(.bold) ?? false) + let isItalic = font?.fontDescriptor.symbolicTraits.contains(.italic) ?? false + let isMono = font?.fontDescriptor.symbolicTraits.contains(.monoSpace) ?? false + let link = attrs[.link] as? URL + ?? (attrs[.link] as? String).flatMap { URL(string: $0) } + + var span = cleaned + + if isMono { + span = "`\(span)`" + } else { + if isBold && isItalic { span = "***\(span)***" } + else if isBold { span = "**\(span)**" } + else if isItalic { span = "_\(span)_" } + } + + if let url = link, !isMono { + span = "[\(cleaned)](\(url.absoluteString))" + } + + output += span + } + + return output + } + + // MARK: - Helpers + + /// Map NSAttributedString's rendered font sizes back to heading levels. + /// Empirical values on macOS 14/15 with default system HTML stylesheet: + /// h1 → ~24pt, h2 → ~18pt, h3 → ~14pt bold, h4-h6 → 12pt bold + private static func headingLevelForFontSize(_ size: CGFloat) -> Int { + switch size { + case 22...: return 1 + case 17..<22: return 2 + case 14..<17: return 3 + default: return 0 + } + } + + private static func isListItemParagraph(_ style: NSParagraphStyle?) -> Bool { + guard let style else { return false } + return style.headIndent > 0 && !style.tabStops.isEmpty + } + + private static func isOrderedListItem(_ attrStr: NSAttributedString, range: NSRange) -> Bool { + guard range.length > 0 else { return false } + let raw = (attrStr.string as NSString).substring(with: range) + // Ordered items start with a tab followed by a digit and a period. + return raw.hasPrefix("\t") && raw.dropFirst().first?.isNumber == true + } + + private static func indentForParagraphStyle(_ style: NSParagraphStyle?) -> String { + guard let style, style.headIndent > 36 else { return "" } + let extraLevels = Int((style.headIndent - 18) / 18) + return String(repeating: " ", count: max(0, extraLevels)) + } +} diff --git a/macOS/SynapseNotes/LinkAwareTextView+CodeBlocks.swift b/macOS/SynapseNotes/LinkAwareTextView+CodeBlocks.swift new file mode 100644 index 0000000..72d3d72 --- /dev/null +++ b/macOS/SynapseNotes/LinkAwareTextView+CodeBlocks.swift @@ -0,0 +1,211 @@ +import AppKit + +// MARK: - Code Block Copy Button + +private enum CodeBlockCopyButtonAssociatedKeys { + static var buttons: UInt8 = 0 +} + +final class CodeBlockCopyButton: NSButton { + var codeContent: String = "" + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + isBordered = false + bezelStyle = .inline + wantsLayer = true + layer?.cornerRadius = 4 + layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.8).cgColor + image = NSImage(systemSymbolName: "doc.on.doc", accessibilityDescription: "Copy code") + imageScaling = .scaleProportionallyDown + contentTintColor = NSColor.secondaryLabelColor + toolTip = "Copy code" + target = self + action = #selector(handleClick) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func resetCursorRects() { + discardCursorRects() + addCursorRect(bounds, cursor: .pointingHand) + } + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + true + } + + @objc private func handleClick() { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(codeContent, forType: .string) + + contentTintColor = NSColor.systemGreen + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.contentTintColor = NSColor.secondaryLabelColor + } + } +} + +/// Represents a detected code block in markdown +struct CodeBlockMatch: Equatable { + let id: String + let range: NSRange + let content: String + let language: String? + + static func == (lhs: CodeBlockMatch, rhs: CodeBlockMatch) -> Bool { + lhs.id == rhs.id && + lhs.range == rhs.range && + lhs.content == rhs.content && + lhs.language == rhs.language + } +} + +extension LinkAwareTextView { + + var codeBlockCopyButtons: [String: NSButton] { + get { + (objc_getAssociatedObject(self, &CodeBlockCopyButtonAssociatedKeys.buttons) as? [String: NSButton]) ?? [:] + } + set { + objc_setAssociatedObject(self, &CodeBlockCopyButtonAssociatedKeys.buttons, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + /// Regex pattern to detect code blocks: ```optional_language\ncode\n``` + /// Only matches opening ``` at the start of a line or string + private static let codeBlockRegex = try? NSRegularExpression( + pattern: "^[ \\t]{0,3}```([a-zA-Z0-9+-]*)[ \\t]*$", + options: [.anchorsMatchLines] + ) + + /// Find all code blocks in the current text + func codeBlockMatches() -> [CodeBlockMatch] { + guard let regex = Self.codeBlockRegex else { return [] } + let nsText = string as NSString + let fullRange = NSRange(location: 0, length: nsText.length) + + let fenceMatches = regex.matches(in: string, options: [], range: fullRange) + var matches: [CodeBlockMatch] = [] + var index = 0 + + while index + 1 < fenceMatches.count { + let openingMatch = fenceMatches[index] + let closingMatch = fenceMatches[index + 1] + let openingRange = openingMatch.range(at: 0) + let languageRange = openingMatch.range(at: 1) + let closingRange = closingMatch.range(at: 0) + + let contentStart = openingRange.location + openingRange.length + let contentLength = closingRange.location - contentStart + guard contentLength >= 0 else { + index += 2 + continue + } + + let contentRange = NSRange(location: contentStart, length: contentLength) + var content = nsText.substring(with: contentRange) + if content.hasPrefix("\r\n") { + content.removeFirst(2) + } else if content.hasPrefix("\n") { + content.removeFirst() + } + if content.hasSuffix("\r\n") { + content.removeLast(2) + } else if content.hasSuffix("\n") { + content.removeLast() + } + + let language = languageRange.length > 0 ? nsText.substring(with: languageRange) : nil + let fullRange = NSRange(location: openingRange.location, length: closingRange.location + closingRange.length - openingRange.location) + let id = "\(openingRange.location)-\(openingRange.length)" + + matches.append(CodeBlockMatch( + id: id, + range: fullRange, + content: content, + language: language + )) + + index += 2 + } + + return matches + } + + /// Create and position copy buttons for all code blocks + func refreshCodeBlockCopyButtons() { + guard let layoutManager = layoutManager, + let textContainer = textContainer else { return } + + layoutManager.ensureLayout(for: textContainer) + + let matches = codeBlockMatches() + let activeKeys = Set(matches.map(\.id)) + + // Remove stale buttons + for key in Array(codeBlockCopyButtons.keys) where !activeKeys.contains(key) { + codeBlockCopyButtons[key]?.removeFromSuperview() + codeBlockCopyButtons.removeValue(forKey: key) + } + let buttonSize: CGFloat = 24 + let buttonMargin: CGFloat = 8 + let minBlockHeight = buttonSize + buttonMargin * 2 + + for match in matches { + // Get the rect of the code block + let glyphRange = layoutManager.glyphRange(forCharacterRange: match.range, actualCharacterRange: nil) + var codeBlockRect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) + codeBlockRect.origin.x += textContainerOrigin.x + codeBlockRect.origin.y += textContainerOrigin.y + // Guarantee enough height for the button even on very short blocks + if codeBlockRect.height < minBlockHeight { + codeBlockRect.size.height = minBlockHeight + } + + // Position button at top-right corner + let buttonX = codeBlockRect.maxX - buttonSize - buttonMargin + let buttonY = codeBlockRect.minY + buttonMargin + + let button: CodeBlockCopyButton + if let existing = codeBlockCopyButtons[match.id] { + guard let existingButton = existing as? CodeBlockCopyButton else { + existing.removeFromSuperview() + let replacementButton = createCopyButton(for: match) + addSubview(replacementButton, positioned: .above, relativeTo: nil) + codeBlockCopyButtons[match.id] = replacementButton + replacementButton.frame = NSRect(x: buttonX, y: buttonY, width: buttonSize, height: buttonSize) + continue + } + button = existingButton + } else { + button = createCopyButton(for: match) + addSubview(button, positioned: .above, relativeTo: nil) + codeBlockCopyButtons[match.id] = button + } + + button.codeContent = match.content + button.frame = NSRect(x: buttonX, y: buttonY, width: buttonSize, height: buttonSize) + } + } + + /// Create a copy button for a specific code block + private func createCopyButton(for match: CodeBlockMatch) -> CodeBlockCopyButton { + let button = CodeBlockCopyButton(frame: .zero) + button.identifier = NSUserInterfaceItemIdentifier(match.id) + button.codeContent = match.content + return button + } + + /// Remove all code block copy buttons + func clearCodeBlockCopyButtons() { + for (_, button) in codeBlockCopyButtons { + button.removeFromSuperview() + } + codeBlockCopyButtons.removeAll() + } +} diff --git a/macOS/SynapseNotes/MarkdownPreviewView.swift b/macOS/SynapseNotes/MarkdownPreviewView.swift new file mode 100644 index 0000000..04866c0 --- /dev/null +++ b/macOS/SynapseNotes/MarkdownPreviewView.swift @@ -0,0 +1,301 @@ +import SwiftUI +import AppKit +import WebKit + +// MARK: - Markdown Preview with WKWebView + +struct MarkdownPreviewView: NSViewRepresentable { + let markdownContent: String + let isDarkMode: Bool + let bodyFontFamily: String + let monoFontFamily: String + let fontSize: Int + let lineHeight: Double + var currentFileURL: URL? = nil + var onResolveWikilink: ((String) -> Void)? = nil + var onToggleCheckbox: ((Int) -> Void)? = nil + + class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { + var parent: MarkdownPreviewView + var lastMarkdown: String? + var lastIsDarkMode: Bool? + var lastBodyFontFamily: String? + var lastMonoFontFamily: String? + var lastFontSize: Int? + var lastLineHeight: Double? + var lastFileURL: URL? + var pendingScrollY: CGFloat = 0 + + init(_ parent: MarkdownPreviewView) { self.parent = parent } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + guard pendingScrollY > 0 else { return } + let y = pendingScrollY + pendingScrollY = 0 + webView.evaluateJavaScript("window.scrollTo(0, \(y))") { _, _ in } + } + + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + guard let url = navigationAction.request.url else { decisionHandler(.allow); return } + if url.scheme == "wikilink" { + let destination = (url.host ?? url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))).lowercased() + parent.onResolveWikilink?(destination) + decisionHandler(.cancel) + return + } + if url.scheme == "http" || url.scheme == "https" { + NSWorkspace.shared.open(url) + decisionHandler(.cancel) + return + } + // Allow file:// and about: (initial HTML load) + decisionHandler(.allow) + } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + if message.name == "toggleCheckbox", let offset = message.body as? Int { + parent.onToggleCheckbox?(offset) + } + } + + } + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + func makeNSView(context: Context) -> WKWebView { + let config = WKWebViewConfiguration() + config.preferences.javaScriptEnabled = true + config.userContentController.add(context.coordinator, name: "toggleCheckbox") + let webView = WKWebView(frame: .zero, configuration: config) + webView.setValue(false, forKey: "drawsBackground") + webView.navigationDelegate = context.coordinator + return webView + } + + func updateNSView(_ webView: WKWebView, context: Context) { + guard markdownContent != context.coordinator.lastMarkdown || + isDarkMode != context.coordinator.lastIsDarkMode || + bodyFontFamily != context.coordinator.lastBodyFontFamily || + monoFontFamily != context.coordinator.lastMonoFontFamily || + fontSize != context.coordinator.lastFontSize || + lineHeight != context.coordinator.lastLineHeight || + currentFileURL != context.coordinator.lastFileURL else { return } + context.coordinator.parent = self + context.coordinator.lastMarkdown = markdownContent + context.coordinator.lastIsDarkMode = isDarkMode + context.coordinator.lastBodyFontFamily = bodyFontFamily + context.coordinator.lastMonoFontFamily = monoFontFamily + context.coordinator.lastFontSize = fontSize + context.coordinator.lastLineHeight = lineHeight + context.coordinator.lastFileURL = currentFileURL + let baseDir = currentFileURL?.deletingLastPathComponent() + let html = generateHTML(from: markdownContent, isDarkMode: isDarkMode, baseDir: baseDir) + // Save scroll position before reload, restore after load finishes + webView.evaluateJavaScript("window.scrollY") { scrollY, _ in + if let y = scrollY as? CGFloat, y > 0 { + context.coordinator.pendingScrollY = y + } + } + webView.loadHTMLString(html, baseURL: baseDir) + } + + private func generateHTML(from markdown: String, isDarkMode: Bool, baseDir: URL? = nil) -> String { + var html = MarkdownPreviewRenderer().renderBody(from: markdown) + // Inline local images as data URIs so they render without file:// access + if let baseDir { + let imgRegex = try? NSRegularExpression(pattern: #" 1 else { return } + let srcRange = match.range(at: 1) + let src = nsHTML.substring(with: srcRange) + // Skip already-inlined or remote URLs + guard !src.hasPrefix("data:"), !src.hasPrefix("http://"), !src.hasPrefix("https://") else { return } + let imageURL = baseDir.appendingPathComponent(src) + guard let data = try? Data(contentsOf: imageURL) else { return } + let ext = imageURL.pathExtension.lowercased() + let mime: String + switch ext { + case "png": mime = "image/png" + case "jpg", "jpeg": mime = "image/jpeg" + case "gif": mime = "image/gif" + case "svg": mime = "image/svg+xml" + case "webp": mime = "image/webp" + default: mime = "application/octet-stream" + } + let dataURI = "data:\(mime);base64,\(data.base64EncodedString())" + replacements.append((srcRange, dataURI)) + } + // Apply replacements in reverse order to preserve ranges + for (range, replacement) in replacements.reversed() { + html = (html as NSString).replacingCharacters(in: range, with: replacement) + } + } + + let textColor = isDarkMode ? "#E0E0E0" : "#333333" + let backgroundColor = isDarkMode ? "#1E1E1E" : "#FFFFFF" + let borderColor = isDarkMode ? "#444444" : "#CCCCCC" + let headerBgColor = isDarkMode ? "#2D2D2D" : "#F5F5F5" + let bodyFontStack = MarkdownPreviewCSS.bodyFontStack(for: bodyFontFamily) + let monoFontStack = MarkdownPreviewCSS.monoFontStack(for: monoFontFamily) + let bodyFontSize = MarkdownPreviewCSS.bodyFontSize(for: fontSize) + let tableFontSize = MarkdownPreviewCSS.tableFontSize(for: fontSize) + let codeFontSize = MarkdownPreviewCSS.codeFontSize(for: fontSize) + let bodyLineHeight = MarkdownPreviewCSS.lineHeight(for: lineHeight) + let h1Size = MarkdownPreviewCSS.headingFontSize(level: 1, baseSize: fontSize) + let h2Size = MarkdownPreviewCSS.headingFontSize(level: 2, baseSize: fontSize) + let h3Size = MarkdownPreviewCSS.headingFontSize(level: 3, baseSize: fontSize) + let h4Size = MarkdownPreviewCSS.headingFontSize(level: 4, baseSize: fontSize) + let h5Size = MarkdownPreviewCSS.headingFontSize(level: 5, baseSize: fontSize) + let h6Size = MarkdownPreviewCSS.headingFontSize(level: 6, baseSize: fontSize) + + return """ + + + + + + + + \(html) + + + """ + } +} From 290e76aa37ec7b19bdf6b82eac987485c3c48ed9 Mon Sep 17 00:00:00 2001 From: dep Date: Wed, 10 Jun 2026 09:36:55 -0400 Subject: [PATCH 09/11] refactor(editor): split EditorView.swift into focused files (#253) Move LinkAwareTextView, its markdown-styling extension, and MarkdownTheme out of EditorView.swift as pure relocations. Combined with the prior checkpoint commit, EditorView.swift drops from 6,121 to 1,196 lines and now contains only EditorView, RawEditor (+Coordinator), the pending-signal forwarders, and small editor helpers. New files: - LinkAwareTextView.swift (class + debugLog helper) - LinkAwareTextView+MarkdownStyling.swift (styling/preview engine, collapsible toggles, inline AI bar presentation, regex cache, styleMarkdownContent, custom NSAttributedString keys) - MarkdownTheme.swift (theme + private NSFont.withWeight helper) Access-level bumps required by the file split (single module, no behavior change): EditorFontSignature private->internal; aiSparkleTapped, shouldSkipIncrementalMarkdownRestyle and 21 LinkAwareTextView stored properties fileprivate/private->internal. Trailing whitespace stripped from moved lines per repo rules. Full suite: 2043 tests, 0 failures, 1 skipped (baseline match). Co-Authored-By: Claude Fable 5 --- macOS/Synapse Notes.xcodeproj/project.pbxproj | 12 + macOS/SynapseNotes/EditorView.swift | 3081 +---------------- .../LinkAwareTextView+MarkdownStyling.swift | 1359 ++++++++ macOS/SynapseNotes/LinkAwareTextView.swift | 1585 +++++++++ macOS/SynapseNotes/MarkdownPreviewView.swift | 4 +- macOS/SynapseNotes/MarkdownTheme.swift | 141 + 6 files changed, 3100 insertions(+), 3082 deletions(-) create mode 100644 macOS/SynapseNotes/LinkAwareTextView+MarkdownStyling.swift create mode 100644 macOS/SynapseNotes/LinkAwareTextView.swift create mode 100644 macOS/SynapseNotes/MarkdownTheme.swift diff --git a/macOS/Synapse Notes.xcodeproj/project.pbxproj b/macOS/Synapse Notes.xcodeproj/project.pbxproj index b166905..b81560a 100644 --- a/macOS/Synapse Notes.xcodeproj/project.pbxproj +++ b/macOS/Synapse Notes.xcodeproj/project.pbxproj @@ -102,6 +102,7 @@ 600C600F4CB62126C350DB56 /* AppStateDateTabTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90830AEF65F3A92E3870CDAE /* AppStateDateTabTests.swift */; }; 643060B4D34764E6A5EF36F4 /* SidebarNotePaneEditorStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66D7AFDF5F8B6A7B014EDB1C /* SidebarNotePaneEditorStateTests.swift */; }; 65B288A90414EF883B6B740F /* AppStateTagTabsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AACD74F412D8DE67726A83A6 /* AppStateTagTabsTests.swift */; }; + 66AEBEDD9E0C79C207DB6A09 /* LinkAwareTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A46AA240505017B6DDEF908E /* LinkAwareTextView.swift */; }; 6719AB603FF34697FCB03C34 /* CloneRepositoryValidationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9DD0269C2F999ECBD40462C /* CloneRepositoryValidationTests.swift */; }; 67C6CA3A7398776DFF7CEBF4 /* FileTreeSortingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E60A5A7B35E4BA0A0789846F /* FileTreeSortingTests.swift */; }; 68EC4447B84E0939710645C5 /* SettingsManagerPaneHeightsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 229E65CDC839E51F300D87D0 /* SettingsManagerPaneHeightsTests.swift */; }; @@ -261,6 +262,8 @@ FB020284E27C7A52271D56B6 /* SettingsManagerAIModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9015970EA57DF288EB5311E1 /* SettingsManagerAIModelTests.swift */; }; FB84B6C8F183243641D2590E /* KeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77494AFD9121ABE56BF45594 /* KeychainStore.swift */; }; FBDE6F50B234E8BEEC81D70B /* GitServiceConflictsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 995602FCC2094AD94768D147 /* GitServiceConflictsTests.swift */; }; + FC39F3B033EE507099154FDB /* LinkAwareTextView+MarkdownStyling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A6D2AB432BD789945D61A68 /* LinkAwareTextView+MarkdownStyling.swift */; }; + FC8757D2192ABD5D3029DD67 /* MarkdownTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6995B26D21E06099F1CC8B71 /* MarkdownTheme.swift */; }; FD832B5AFBA2E311DEB0EF87 /* CommandPaletteScoringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CFDA7432187B97B46732652 /* CommandPaletteScoringTests.swift */; }; FEDF6C1421F4515A506A0F6B /* WikiLinkClickTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ABD9B6E3A3ED294575CBF65 /* WikiLinkClickTests.swift */; }; FFB6DAEB709AE113A8039AC1 /* CommandPaletteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DA805991148562D94A36617 /* CommandPaletteView.swift */; }; @@ -285,6 +288,7 @@ 06B93FD12194D192DEFA2411 /* KeyCodeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyCodeTests.swift; sourceTree = ""; }; 0A0ACEC175A51BDC23B61E0A /* CommandPaletteWikiLinkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteWikiLinkTests.swift; sourceTree = ""; }; 0A3AAAB162484316A09E4F98 /* AppStateRelativePathTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateRelativePathTests.swift; sourceTree = ""; }; + 0A6D2AB432BD789945D61A68 /* LinkAwareTextView+MarkdownStyling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LinkAwareTextView+MarkdownStyling.swift"; sourceTree = ""; }; 0D7FB547E67BCAD2587FF1D9 /* AppStateSetupGitAsyncTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateSetupGitAsyncTests.swift; sourceTree = ""; }; 0EB202F7E2555B3DFF17CF27 /* GitErrorHostnameExtractionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitErrorHostnameExtractionTests.swift; sourceTree = ""; }; 0EFF1A55AEF13CEE5C86DD71 /* MarkdownEditorInlineSemanticStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownEditorInlineSemanticStyles.swift; sourceTree = ""; }; @@ -368,6 +372,7 @@ 66D7AFDF5F8B6A7B014EDB1C /* SidebarNotePaneEditorStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarNotePaneEditorStateTests.swift; sourceTree = ""; }; 678B0A533C1C1E8C3A597FF5 /* GitServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitServiceTests.swift; sourceTree = ""; }; 6923334E0E98F9AAC62B9FB5 /* GitServiceAskpassTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitServiceAskpassTests.swift; sourceTree = ""; }; + 6995B26D21E06099F1CC8B71 /* MarkdownTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownTheme.swift; sourceTree = ""; }; 6B45F2701C6F1A6E9FBE3B75 /* SettingsManagerBareExtensionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManagerBareExtensionsTests.swift; sourceTree = ""; }; 6C28BA5BF6AF2A285C81A84C /* AIModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIModel.swift; sourceTree = ""; }; 6DEF85FC5E955D1C3AB40872 /* NavigationStateActivePaneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationStateActivePaneTests.swift; sourceTree = ""; }; @@ -443,6 +448,7 @@ A282B9D2CF2C38CADF29E187 /* AppStateRecentFilesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateRecentFilesTests.swift; sourceTree = ""; }; A2DB880CFF70B48C35A059EB /* SidebarDragDropTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarDragDropTests.swift; sourceTree = ""; }; A2E1F80ED8D1F091735B2F4E /* AppStateSearchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateSearchTests.swift; sourceTree = ""; }; + A46AA240505017B6DDEF908E /* LinkAwareTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkAwareTextView.swift; sourceTree = ""; }; A508264581710F478987F4CA /* AppStateCloneRepositoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateCloneRepositoryTests.swift; sourceTree = ""; }; A61F2346098B568FB3D701E1 /* MarkdownTaskCheckboxHitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownTaskCheckboxHitTests.swift; sourceTree = ""; }; A6A7A1C35AB0F5E61361B8CC /* SynapseNotesThemeLayoutConstantsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SynapseNotesThemeLayoutConstantsTests.swift; sourceTree = ""; }; @@ -785,7 +791,9 @@ D0C6708A91911EC9011CB953 /* InlineAIController.swift */, 9B54D87558101D4D0356C986 /* InlineAIView.swift */, 77494AFD9121ABE56BF45594 /* KeychainStore.swift */, + A46AA240505017B6DDEF908E /* LinkAwareTextView.swift */, D0F46700D49A2DD8455C9A8A /* LinkAwareTextView+CodeBlocks.swift */, + 0A6D2AB432BD789945D61A68 /* LinkAwareTextView+MarkdownStyling.swift */, E1E3D357F071ACBF2016AEA7 /* MarkdownCallout.swift */, 47E58E5951953EF520E265CE /* MarkdownDocument.swift */, 0EFF1A55AEF13CEE5C86DD71 /* MarkdownEditorInlineSemanticStyles.swift */, @@ -799,6 +807,7 @@ F58C010C3BC248AEA35CE843 /* MarkdownPreviewView.swift */, 1F8CCC8DE0551D8C09D24609 /* MarkdownTablePrettifier.swift */, 899E05E166DE4C34A16EF3A1 /* MarkdownTaskCheckboxInteraction.swift */, + 6995B26D21E06099F1CC8B71 /* MarkdownTheme.swift */, C5EB1051089CA28E0244725C /* MiniBrowserPaneView.swift */, A08CB0284C3C5D4EE5C720C7 /* MiniBrowserURLNormalizer.swift */, CC9A2CCFC8D76F6FA9B12305 /* NavigationState.swift */, @@ -974,6 +983,8 @@ CC260DA9055067DAF97068CA /* InlineAIView.swift in Sources */, FB84B6C8F183243641D2590E /* KeychainStore.swift in Sources */, 98C2A8CA9BCEDDDB24509D4A /* LinkAwareTextView+CodeBlocks.swift in Sources */, + FC39F3B033EE507099154FDB /* LinkAwareTextView+MarkdownStyling.swift in Sources */, + 66AEBEDD9E0C79C207DB6A09 /* LinkAwareTextView.swift in Sources */, A6D4D7C24F4ABD2CAA465097 /* MarkdownCallout.swift in Sources */, CDD840A586D7F0EC12F9A75A /* MarkdownDocument.swift in Sources */, 3ADA57036C249201F69BFD3E /* MarkdownEditorInlineSemanticStyles.swift in Sources */, @@ -987,6 +998,7 @@ C74F6D9935DBBEB345A2F094 /* MarkdownPreviewView.swift in Sources */, 47E834CD48727E2DA9D1C147 /* MarkdownTablePrettifier.swift in Sources */, 237E41D444BB8BFDEFF9BA9E /* MarkdownTaskCheckboxInteraction.swift in Sources */, + FC8757D2192ABD5D3029DD67 /* MarkdownTheme.swift in Sources */, 8067187FE928ABEC436ACEF5 /* MiniBrowserPaneView.swift in Sources */, F228B8C0174B187AF8E91BB3 /* MiniBrowserURLNormalizer.swift in Sources */, E2A129B673EF7B61C48D5BC3 /* NavigationState.swift in Sources */, diff --git a/macOS/SynapseNotes/EditorView.swift b/macOS/SynapseNotes/EditorView.swift index 550af48..37da9eb 100644 --- a/macOS/SynapseNotes/EditorView.swift +++ b/macOS/SynapseNotes/EditorView.swift @@ -1181,147 +1181,7 @@ func refreshActiveEditorForHideMarkdownToggle(hideMarkdown: Bool) { refreshEditorForHideMarkdownToggle(textView, hideMarkdown: hideMarkdown) } -// MARK: - Markdown styling theme - -struct MarkdownTheme { - // MARK: - Font functions based on SettingsManager - - static func bodyFont(for settings: SettingsManager) -> NSFont { - let size = CGFloat(settings.editorFontSize) - if settings.editorBodyFontFamily.isEmpty || settings.editorBodyFontFamily == "System" { - return NSFont.systemFont(ofSize: size) - } - return NSFont(name: settings.editorBodyFontFamily, size: size) ?? NSFont.systemFont(ofSize: size) - } - - static func monoFont(for settings: SettingsManager) -> NSFont { - let baseSize = CGFloat(settings.editorFontSize) - let size = max(10, baseSize / SynapseTheme.Layout.phi) - if settings.editorMonospaceFontFamily.isEmpty || settings.editorMonospaceFontFamily == "System Monospace" { - return NSFont.monospacedSystemFont(ofSize: size, weight: .regular) - } - return NSFont(name: settings.editorMonospaceFontFamily, size: size) - ?? NSFont.monospacedSystemFont(ofSize: size, weight: .regular) - } - - static func h1Font(for settings: SettingsManager) -> NSFont { - let size = round(CGFloat(settings.editorFontSize) * SynapseTheme.Editor.headingH1Multiplier) - if settings.editorBodyFontFamily.isEmpty || settings.editorBodyFontFamily == "System" { - return NSFont.systemFont(ofSize: size, weight: .bold) - } - return NSFont(name: settings.editorBodyFontFamily, size: size)?.withWeight(.bold) - ?? NSFont.systemFont(ofSize: size, weight: .bold) - } - - static func h2Font(for settings: SettingsManager) -> NSFont { - let size = round(CGFloat(settings.editorFontSize) * SynapseTheme.Editor.headingH2Multiplier) - if settings.editorBodyFontFamily.isEmpty || settings.editorBodyFontFamily == "System" { - return NSFont.systemFont(ofSize: size, weight: .bold) - } - return NSFont(name: settings.editorBodyFontFamily, size: size)?.withWeight(.bold) - ?? NSFont.systemFont(ofSize: size, weight: .bold) - } - - static func h3Font(for settings: SettingsManager) -> NSFont { - let size = round(CGFloat(settings.editorFontSize) * SynapseTheme.Editor.headingH3Multiplier) - if settings.editorBodyFontFamily.isEmpty || settings.editorBodyFontFamily == "System" { - return NSFont.systemFont(ofSize: size, weight: .semibold) - } - return NSFont(name: settings.editorBodyFontFamily, size: size)?.withWeight(.semibold) - ?? NSFont.systemFont(ofSize: size, weight: .semibold) - } - - static func h4Font(for settings: SettingsManager) -> NSFont { - let size = round(CGFloat(settings.editorFontSize) * SynapseTheme.Editor.headingH4Multiplier) - if settings.editorBodyFontFamily.isEmpty || settings.editorBodyFontFamily == "System" { - return NSFont.systemFont(ofSize: size, weight: .semibold) - } - return NSFont(name: settings.editorBodyFontFamily, size: size)?.withWeight(.semibold) - ?? NSFont.systemFont(ofSize: size, weight: .semibold) - } - - static func boldFont(for settings: SettingsManager) -> NSFont { - let size = CGFloat(settings.editorFontSize) - if settings.editorBodyFontFamily.isEmpty || settings.editorBodyFontFamily == "System" { - return NSFont.systemFont(ofSize: size, weight: .bold) - } - return NSFont(name: settings.editorBodyFontFamily, size: size)?.withWeight(.bold) - ?? NSFont.systemFont(ofSize: size, weight: .bold) - } - - static func italicFont(for settings: SettingsManager) -> NSFont { - let size = CGFloat(settings.editorFontSize) - if settings.editorBodyFontFamily.isEmpty || settings.editorBodyFontFamily == "System" { - let descriptor = NSFont.systemFont(ofSize: size).fontDescriptor.withSymbolicTraits(.italic) - return NSFont(descriptor: descriptor, size: size) ?? NSFont.systemFont(ofSize: size) - } - let baseFont = NSFont(name: settings.editorBodyFontFamily, size: size) ?? NSFont.systemFont(ofSize: size) - let descriptor = baseFont.fontDescriptor.withSymbolicTraits(.italic) - return NSFont(descriptor: descriptor, size: size) ?? baseFont - } - - static func boldItalicFont(for settings: SettingsManager) -> NSFont { - let size = CGFloat(settings.editorFontSize) - let bold = boldFont(for: settings) - let descriptor = bold.fontDescriptor.withSymbolicTraits([.bold, .italic]) - return NSFont(descriptor: descriptor, size: size) ?? bold - } - - static func lineHeightMultiple(for settings: SettingsManager) -> CGFloat { - max(0.8, min(3.0, CGFloat(settings.editorLineHeight))) - } - - /// Paragraph style whose line box matches CSS `line-height: multiple` — i.e. a - /// multiple of the FONT SIZE, not of the font's natural line height. NSTextView's - /// natural line height already bakes in the font's intrinsic leading (~1.18× for - /// SF), so multiplying that by the user's multiple over-spaced lines versus the - /// HTML preview. We set the line box directly to `fontSize * multiple`, floored at - /// the natural height so small multiples never clip glyphs (CSS overlaps instead of - /// cropping; a hard maximumLineHeight below natural would crop). - static func paragraphStyle(font: NSFont, lineHeightMultiple multiple: CGFloat) -> NSMutableParagraphStyle { - let naturalLineHeight = font.ascender - font.descender + font.leading - let targetLineHeight = font.pointSize * multiple - let style = NSMutableParagraphStyle() - style.minimumLineHeight = targetLineHeight - style.maximumLineHeight = max(targetLineHeight, naturalLineHeight) - style.lineSpacing = 0 - return style - } - - // MARK: - Legacy static constants (for backward compatibility) - - static let body = NSFont.systemFont(ofSize: 15) - static let mono = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular) - static let h1 = NSFont.systemFont(ofSize: round(SynapseTheme.Editor.h1FontSize), weight: .bold) - static let h2 = NSFont.systemFont(ofSize: round(SynapseTheme.Editor.h2FontSize), weight: .bold) - static let h3 = NSFont.systemFont(ofSize: round(SynapseTheme.Editor.h3FontSize), weight: .semibold) - static let h4 = NSFont.systemFont(ofSize: round(SynapseTheme.Editor.h4FontSize), weight: .semibold) - // Use static var so these read from ThemeEnvironment.shared at each call-site - // rather than being frozen at class-load time. - static var dimColor: NSColor { SynapseTheme.editorMuted } - static var tagColor: NSColor { SynapseTheme.editorLink } - static var linkColor: NSColor { SynapseTheme.editorLink } - static var unresolvedLinkColor: NSColor { SynapseTheme.editorUnresolvedLink } - static var codeBackground: NSColor { SynapseTheme.editorCodeBackground } -} - -// Helper extension to apply font weight -private extension NSFont { - func withWeight(_ weight: NSFont.Weight) -> NSFont { - // Create a new font descriptor with the desired weight trait - var traits = fontDescriptor.symbolicTraits - // Map NSFont.Weight to NSFontDescriptor.SymbolicTraits - if weight == .bold || weight.rawValue >= NSFont.Weight.bold.rawValue { - traits.insert(.bold) - } else if weight == .semibold || weight.rawValue >= NSFont.Weight.semibold.rawValue { - traits.insert(.bold) - } - let descriptor = fontDescriptor.withSymbolicTraits(traits) - return NSFont(descriptor: descriptor, size: pointSize) ?? self - } -} - -private struct EditorFontSignature: Equatable { +struct EditorFontSignature: Equatable { let bodyFontFamily: String let monospaceFontFamily: String let fontSize: Int @@ -1334,2942 +1194,3 @@ private struct EditorFontSignature: Equatable { lineHeight = settings?.editorLineHeight ?? 1.6 } } - -/// Custom attribute key for wiki links — avoids NSTextView overriding our foreground color via linkTextAttributes. -extension NSAttributedString.Key { - static let wikilinkTarget = NSAttributedString.Key("Synapse.wikilinkTarget") - static let tagTarget = NSAttributedString.Key("Synapse.tagTarget") - /// Marks a character range so `LinkAwareTextView.drawBackground(in:)` draws - /// its background color across the full container width, not just the glyph bounds. - /// The value must be an `NSColor`. - static let codeBlockFullWidthBackground = NSAttributedString.Key("Synapse.codeBlockFullWidthBackground") - /// Marks a character range as belonging to a blockquote so - /// `LinkAwareTextView.drawBackground(in:)` can paint a decorative accent bar - /// along the leading edge of every line in the range. Value must be an `NSColor`. - static let blockquoteLeftBorder = NSAttributedString.Key("Synapse.blockquoteLeftBorder") -} - -/// Thread-safe regex cache for markdown styling outside of LinkAwareTextView. -private var sharedRegexCache: [String: NSRegularExpression] = [:] - -private func cachedRegex(_ pattern: String, options: NSRegularExpression.Options = []) -> NSRegularExpression? { - let key = "\(pattern)|\(options.rawValue)" - if let cached = sharedRegexCache[key] { return cached } - guard let compiled = try? NSRegularExpression(pattern: pattern, options: options) else { return nil } - sharedRegexCache[key] = compiled - return compiled -} - -/// Styles markdown text and returns an attributed string for display -func styleMarkdownContent(_ content: String, fontSize: CGFloat = 12) -> NSAttributedString { - let storage = NSTextStorage(string: content) - let text = content as NSString - let fullRange = NSRange(location: 0, length: text.length) - - let baseFont = NSFont.systemFont(ofSize: fontSize) - storage.addAttributes([ - .font: baseFont, - .foregroundColor: SynapseTheme.editorForeground, - ], range: fullRange) - - func applyPattern(_ pattern: String, options: NSRegularExpression.Options = [], apply: (NSRange) -> Void) { - guard let regex = cachedRegex(pattern, options: options) else { return } - regex.enumerateMatches(in: content, options: [], range: fullRange) { match, _, _ in - guard let range = match?.range else { return } - apply(range) - } - } - - func dimDelims(_ range: NSRange, _ delimLen: Int) { - guard range.length >= delimLen * 2 else { return } - storage.addAttribute(.foregroundColor, value: MarkdownTheme.dimColor, range: NSRange(location: range.location, length: delimLen)) - storage.addAttribute(.foregroundColor, value: MarkdownTheme.dimColor, range: NSRange(location: range.location + range.length - delimLen, length: delimLen)) - } - - // Headers - let headerPatterns: [(String, NSFont)] = [ - ("^#{6} .+$", NSFont.systemFont(ofSize: fontSize + 2, weight: .semibold)), - ("^#{5} .+$", NSFont.systemFont(ofSize: fontSize + 2, weight: .semibold)), - ("^#{4} .+$", NSFont.systemFont(ofSize: fontSize + 2, weight: .semibold)), - ("^### .+$", NSFont.systemFont(ofSize: fontSize + 4, weight: .bold)), - ("^## .+$", NSFont.systemFont(ofSize: fontSize + 6, weight: .bold)), - ("^# .+$", NSFont.systemFont(ofSize: fontSize + 8, weight: .bold)), - ] - for (pattern, font) in headerPatterns { - applyPattern(pattern, options: [.anchorsMatchLines]) { range in - storage.addAttributes([.font: font], range: range) - let hashEnd = (text.substring(with: range) as NSString).range(of: "^#{1,6} ", options: .regularExpression) - if hashEnd.location != NSNotFound { - storage.addAttribute(.foregroundColor, value: MarkdownTheme.dimColor, range: NSRange(location: range.location + hashEnd.location, length: hashEnd.length)) - } - } - } - - // Italic — applied first so bold applied afterward wins on **word** spans - applyPattern("(? .+$", options: [.anchorsMatchLines]) { range in - storage.addAttribute(.foregroundColor, value: MarkdownTheme.dimColor, range: range) - } - // Inline tags - AppState.inlineTagMatches(in: content).forEach { match in - storage.addAttribute(.foregroundColor, value: MarkdownTheme.tagColor, range: match.range) - storage.addAttribute(.tagTarget, value: match.normalized, range: match.range) - } - // Wiki links - applyPattern("\\[\\[[^\\]]+\\]\\]") { range in - guard range.length > 4 else { return } - let inner = text.substring(with: NSRange(location: range.location + 2, length: range.length - 4)) - storage.addAttributes([.foregroundColor: MarkdownTheme.linkColor, .underlineStyle: NSUnderlineStyle.single.rawValue, .link: inner], range: range) - } - // Markdown links - applyPattern("(?= 3 else { return } - let full = match.range(at: 0) - let label = match.range(at: 1) - storage.addAttribute(.foregroundColor, value: MarkdownTheme.dimColor, range: full) - storage.addAttributes([.foregroundColor: MarkdownTheme.linkColor, .underlineStyle: NSUnderlineStyle.single.rawValue], range: label) - } - } - // Horizontal rules - applyPattern("^---$", options: [.anchorsMatchLines]) { range in - storage.addAttribute(.foregroundColor, value: MarkdownTheme.dimColor, range: range) - } - - return NSAttributedString(attributedString: storage) -} - -// MARK: - Markdown styling extension - -extension LinkAwareTextView { - func clearPendingWikilinkInsertion() { - pendingWikilinkAlias = nil - pendingWikilinkSelectionRange = nil - } - - func setPlainText(_ plain: String) { - guard let storage = textStorage else { return } - // Tear down any in-flight/pending AI session BEFORE the storage is - // replaced — stale ranges would corrupt the new note or crash on - // accept/reject, and the floating bar would linger over new content. - teardownAISession() - // Stale ranges from a previous file would crash reapplySearchHighlights - lastSearchHighlightRanges = [] - lastSearchFocusIndex = -1 - // New content invalidates the revealed-block gate so the next caret move - // re-evaluates against the new document. - lastRevealedBlockRange = nil - storage.beginEditing() - storage.setAttributedString(NSAttributedString(string: plain)) - storage.endEditing() - applyMarkdownStyling(deferRedraw: !isEditable) - if !isEditable { - applyPreviewStyling(editingSessionOpen: true) - } - // Note: hideMarkdownWhileEditing in editable mode is handled in the - // Coordinator's styling callback and updateNSView, which have access to appState. - } - - /// Called after applyMarkdownStyling() in view/preview mode. - /// Hides markdown syntax tokens (delimiters, sigils, fences) by setting - /// their font size to near-zero and foreground color to clear, so only the - /// styled content is visible. - func applyPreviewStyling(document: MarkdownDocument? = nil, refreshPlan: MarkdownEditorRefreshPlan = .fullDocument, editingSessionOpen: Bool = false) { - guard let storage = textStorage else { return } - let fullRange = NSRange(location: 0, length: storage.length) - guard fullRange.length > 0 else { return } - let text = storage.string - let parsedDocument = document ?? MarkdownDocumentParser().parse(text) - let previewSemanticHiding = MarkdownPreviewSemanticHiding.make(from: parsedDocument, isEditable: isEditable) - let scopeRange = refreshPlan.affectedRange ?? fullRange - let searchRange = (text as NSString).lineRange(for: scopeRange) - let fencedCodeBlockRanges = parsedDocument.blocks.compactMap { block -> NSRange? in - if case .fencedCodeBlock = block.kind { - return block.range - } - return nil - } - - let hiddenAttrs: [NSAttributedString.Key: Any] = [ - .font: NSFont.systemFont(ofSize: 0.001), - .foregroundColor: NSColor.clear, - ] - - func hide(_ pattern: String, options: NSRegularExpression.Options = []) { - guard let regex = cachedRegex(pattern, options: options) else { return } - regex.enumerateMatches(in: text, options: [], range: searchRange) { match, _, _ in - guard let range = match?.range else { return } - storage.addAttributes(hiddenAttrs, range: range) - } - } - - func hideGroup(_ pattern: String, group: Int, options: NSRegularExpression.Options = []) { - guard let regex = cachedRegex(pattern, options: options) else { return } - regex.enumerateMatches(in: text, options: [], range: searchRange) { match, _, _ in - guard let match, match.numberOfRanges > group else { return } - let r = match.range(at: group) - guard r.location != NSNotFound else { return } - storage.addAttributes(hiddenAttrs, range: r) - } - } - - func isInsideFencedCodeBlock(_ range: NSRange) -> Bool { - fencedCodeBlockRanges.contains { blockRange in - NSIntersectionRange(blockRange, range).length > 0 - } - } - - // applyMarkdownStyling() already ran before this and applied all fonts. - // We only need to hide the markdown syntax tokens here. - // Do NOT re-apply base fonts — that would undo the heading sizes set by applyMarkdownStyling. - - if !editingSessionOpen { - storage.beginEditing() - } - - for range in previewSemanticHiding.hiddenRanges where NSIntersectionRange(range, scopeRange).length > 0 { - storage.addAttributes(hiddenAttrs, range: range) - } - - for block in parsedDocument.blocks { - guard case .fencedCodeBlock = block.kind else { continue } - guard NSIntersectionRange(block.range, searchRange).length > 0 else { continue } - - let firstLineRange = (text as NSString).lineRange(for: NSRange(location: block.range.location, length: 0)) - let lastLineLocation = block.range.location + block.range.length - 1 - let lastLineRange = (text as NSString).lineRange(for: NSRange(location: lastLineLocation, length: 0)) - - for lineRange in [firstLineRange, lastLineRange] { - let paragraphStyle = (storage.attribute(.paragraphStyle, at: lineRange.location, effectiveRange: nil) as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? NSMutableParagraphStyle() - paragraphStyle.minimumLineHeight = 0 - paragraphStyle.maximumLineHeight = 0 - paragraphStyle.lineSpacing = 0 - storage.addAttribute(.paragraphStyle, value: paragraphStyle, range: lineRange) - } - } - - // Bold **text** — hide the ** delimiters - hideGroup("(\\*\\*)(.+?)(\\*\\*)", group: 1) - hideGroup("(\\*\\*)(.+?)(\\*\\*)", group: 3) - // Bold __text__ — hide the __ delimiters - hideGroup("(__)(.+?)(__)", group: 1) - hideGroup("(__)(.+?)(__)", group: 3) - - // Italic *text* — hide the * delimiters (not **) - hideGroup("(? 3 else { return } - let openRange = match.range(at: 1) - let closeRange = match.range(at: 3) - guard openRange.location != NSNotFound, closeRange.location != NSNotFound else { return } - if isInsideFencedCodeBlock(match.range(at: 0)) { - return - } - storage.addAttributes(hiddenAttrs, range: openRange) - storage.addAttributes(hiddenAttrs, range: closeRange) - } - } - - // Image embeds ![caption](url) — hide ![ and ](url), keep caption visible. - // Only hide when caption is non-empty; if [] leave the full markdown visible. - hideGroup("(!\\[)([^\\]]+)(\\]\\([^)]+\\))", group: 1) - hideGroup("(!\\[)([^\\]]+)(\\]\\([^)]+\\))", group: 3) - - // Dim caption text for image embeds - let imageCaptionRegex = cachedRegex("!\\[([^\\]]+)\\]\\([^)]+\\)") - imageCaptionRegex?.enumerateMatches(in: text, options: [], range: searchRange) { match, _, _ in - guard let match, match.numberOfRanges > 1 else { return } - let captionRange = match.range(at: 1) - guard captionRange.location != NSNotFound else { return } - storage.addAttributes([ - .foregroundColor: MarkdownTheme.dimColor, - ], range: captionRange) - } - - storage.endEditing() - requestImmediateRedraw(for: scopeRange) - lastAppliedEditorDisplayMode = .preview - refreshTaskCheckboxButtons() - - // After hiding, reveal the wikilink/image embed the cursor is currently inside. - // NOTE: the *block* reveal is intentionally NOT triggered here. applyPreviewStyling - // is a pure "hide everything" pass (also used for read-only rendering and initial - // load, where the caret sits at 0 inside the first block). Block reveal is a - // response to caret movement and is driven from textViewDidChangeSelection instead. - if isEditable { - revealSemanticInlineMarkdownAtCursor() - revealCalloutHeaderAtCursor(document: parsedDocument) - } - } - - private func revealCalloutHeaderAtCursor(document: MarkdownDocument? = nil) { - guard let storage = textStorage else { return } - let cursor = selectedRange().location - guard cursor != NSNotFound else { return } - let parsedDocument = document ?? MarkdownDocumentParser().parse(storage.string) - let callouts = parsedDocument.blocks.compactMap { MarkdownCalloutDetector.detect(in: $0, source: parsedDocument.source) } - guard let callout = callouts.first(where: { NSLocationInRange(cursor, $0.headerRange) }) else { return } - - // Configured body font, not the fixed 15pt legacy constant (see - // revealSemanticInlineMarkdownAtCursor for why). - let visibleAttrs: [NSAttributedString.Key: Any] = [ - .font: settings != nil ? MarkdownTheme.bodyFont(for: settings!) : MarkdownTheme.body, - .foregroundColor: MarkdownTheme.dimColor, - ] - storage.beginEditing() - storage.addAttributes(visibleAttrs, range: callout.headerRange) - storage.endEditing() - } - - func revealSemanticInlineMarkdownAtCursor() { - guard let storage = textStorage else { return } - let reveal = MarkdownPreviewCursorReveal.make( - from: storage.string, - cursorLocation: selectedRange().location, - isEditable: isEditable - ) - guard !reveal.revealedRanges.isEmpty else { return } - - // Use the configured body font, NOT the fixed 15pt legacy MarkdownTheme.body — - // otherwise revealing a token shrinks it whenever the user's editor font ≠ 15. - let visibleAttrs: [NSAttributedString.Key: Any] = [ - .font: settings != nil ? MarkdownTheme.bodyFont(for: settings!) : MarkdownTheme.body, - .foregroundColor: MarkdownTheme.dimColor, - ] - - storage.beginEditing() - for range in reveal.revealedRanges { - storage.addAttributes(visibleAttrs, range: range) - } - storage.endEditing() - } - - /// Reveals the raw markdown syntax (dimmed) for the entire parsed block the caret - /// is in, so editing always shows the syntax for the block being edited. Re-hiding - /// of the block the caret *left* is handled by the next full applyPreviewStyling pass. - /// No-ops when the caret stays within the same block as the previous call. - func revealCurrentBlockMarkdownAtCursor(document: MarkdownDocument? = nil) { - guard isEditable, let storage = textStorage else { return } - let cursor = selectedRange().location - // The optional `document` lets callers avoid an extra parse when they already - // hold one (its `source` is authoritative); otherwise read the live storage. - let source = document?.source ?? storage.string - let reveal = MarkdownPreviewBlockReveal.make(from: source, cursorLocation: cursor, isEditable: isEditable) - - // Block-change gating: if the caret is still in the same block we revealed last - // time, there is nothing new to reveal. - if let last = lastRevealedBlockRange, let current = reveal.blockRange, NSEqualRanges(last, current) { - return - } - lastRevealedBlockRange = reveal.blockRange - - guard !reveal.revealedRanges.isEmpty else { return } - - // The hidden delimiters were zeroed to systemFont(0.001); restore a visible - // body-sized font and dim color. Body font reads cleanly for every delimiter - // kind (**, *, `, [, ]], #, ```); surrounding content keeps its own font from - // applyMarkdownStyling. - let revealFont = settings != nil ? MarkdownTheme.bodyFont(for: settings!) : MarkdownTheme.body - - storage.beginEditing() - for range in reveal.revealedRanges { - let safeLoc = max(0, min(range.location, storage.length)) - let safeLen = min(range.length, storage.length - safeLoc) - guard safeLen > 0 else { continue } - let safeRange = NSRange(location: safeLoc, length: safeLen) - storage.addAttributes([ - .font: revealFont, - .foregroundColor: MarkdownTheme.dimColor, - ], range: safeRange) - } - storage.endEditing() - requestImmediateRedraw(for: reveal.blockRange ?? NSRange(location: cursor, length: 0)) - } - - func applyMarkdownStyling(document: MarkdownDocument? = nil, refreshPlan: MarkdownEditorRefreshPlan = .fullDocument, deferRedraw: Bool = false) { - guard let storage = textStorage else { return } - let fullRange = NSRange(location: 0, length: storage.length) - guard fullRange.length > 0 else { - lastAppliedEditorFontSignature = EditorFontSignature(settings: settings) - lastAppliedEditorDisplayMode = .markdown - clearInlineImagePreviews() - clearTaskCheckboxButtons() - for key in Array(collapsibleToggleButtons.keys) { - collapsibleToggleButtons[key]?.removeFromSuperview() - } - collapsibleToggleButtons.removeAll() - return - } - let text = storage.string as NSString - let parsedDocument = document ?? MarkdownDocumentParser().parse(storage.string) - let scopeRange = refreshPlan.affectedRange ?? fullRange - let searchRange = text.lineRange(for: scopeRange) - lastAppliedEditorDisplayMode = .markdown - clearTaskCheckboxButtons() - let semanticStyles = MarkdownEditorSemanticStyles.make(from: parsedDocument) - let inlineSemanticStyles = MarkdownEditorInlineSemanticStyles.make(from: parsedDocument) - - storage.beginEditing() - - // Use settings-based fonts if available, otherwise fall back to defaults - let bodyFont = settings != nil ? MarkdownTheme.bodyFont(for: settings!) : MarkdownTheme.body - let monoFont = settings != nil ? MarkdownTheme.monoFont(for: settings!) : MarkdownTheme.mono - let h1Font = settings != nil ? MarkdownTheme.h1Font(for: settings!) : MarkdownTheme.h1 - let h2Font = settings != nil ? MarkdownTheme.h2Font(for: settings!) : MarkdownTheme.h2 - let h3Font = settings != nil ? MarkdownTheme.h3Font(for: settings!) : MarkdownTheme.h3 - let h4Font = settings != nil ? MarkdownTheme.h4Font(for: settings!) : MarkdownTheme.h4 - let boldFont = settings != nil ? MarkdownTheme.boldFont(for: settings!) : NSFont.systemFont(ofSize: 15, weight: .bold) - let italicFont = settings != nil ? MarkdownTheme.italicFont(for: settings!) : { - let desc = MarkdownTheme.body.fontDescriptor.withSymbolicTraits(.italic) - return NSFont(descriptor: desc, size: 15) ?? MarkdownTheme.body - }() - let lineHeightMultiple = settings != nil ? MarkdownTheme.lineHeightMultiple(for: settings!) : 1.6 - let baseParagraphStyle = MarkdownTheme.paragraphStyle(font: bodyFont, lineHeightMultiple: lineHeightMultiple) - - storage.setAttributes([ - .font: bodyFont, - .foregroundColor: SynapseTheme.editorForeground, - .paragraphStyle: baseParagraphStyle, - ], range: scopeRange) - - for heading in semanticStyles.headings { - guard NSIntersectionRange(heading.range, scopeRange).length > 0 else { continue } - let font: NSFont - switch heading.level { - case 1: font = h1Font - case 2: font = h2Font - case 3: font = h3Font - default: font = h4Font - } - let headingParaStyle = MarkdownTheme.paragraphStyle(font: font, lineHeightMultiple: lineHeightMultiple) - storage.addAttributes([.font: font, .paragraphStyle: headingParaStyle], range: heading.range) - storage.addAttribute(.foregroundColor, value: MarkdownTheme.dimColor, range: heading.markerRange) - } - - // Italic first, bold second — bold must win on **word** spans. - // The single-star italic regex would otherwise match the inner *word* of **word** - // and overwrite the bold font after it was applied. - applyRegex("(? 0 else { continue } - - storage.addAttributes([ - .font: monoFont, - .backgroundColor: MarkdownTheme.codeBackground, - .foregroundColor: SynapseTheme.editorForeground, - // Marker read by drawBackground(in:) to extend the fill to full width. - .codeBlockFullWidthBackground: MarkdownTheme.codeBackground, - ], range: block.range) - - if SyntaxHighlighter.isSupportedLanguage(infoString) { - SyntaxHighlighter.apply( - to: storage, - codeRange: block.contentRange, - language: infoString, - baseFont: monoFont, - isDarkMode: isDarkMode - ) - } - - // Add bottom padding to the closing fence line so the code block has breathing room - // and the copy button has space to sit in. - let nsStr = text as NSString - let firstLineRange = nsStr.lineRange(for: NSRange(location: block.range.location, length: 0)) - let firstParaStyle = (storage.attribute(.paragraphStyle, at: firstLineRange.location, effectiveRange: nil) as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? NSMutableParagraphStyle() - firstParaStyle.paragraphSpacingBefore = 0 - storage.addAttribute(.paragraphStyle, value: firstParaStyle, range: firstLineRange) - // Last line of block → paragraphSpacing (after) and full-width background - let lastLineRange = nsStr.lineRange(for: NSRange(location: block.range.location + block.range.length - 1, length: 0)) - let lastParaStyle = (storage.attribute(.paragraphStyle, at: lastLineRange.location, effectiveRange: nil) as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? NSMutableParagraphStyle() - lastParaStyle.paragraphSpacing = codePad - lastParaStyle.tailIndent = 0 - lastParaStyle.lineBreakMode = .byWordWrapping - storage.addAttribute(.paragraphStyle, value: lastParaStyle, range: lastLineRange) - } - for block in parsedDocument.blocks { - guard case .table = block.kind else { continue } - guard NSIntersectionRange(block.range, scopeRange).length > 0 else { continue } - storage.addAttribute(.font, value: monoFont, range: block.range) - } - let calloutRanges = Set(semanticStyles.callouts.map { "\($0.range.location):\($0.range.length)" }) - for range in semanticStyles.blockquotes { - guard !calloutRanges.contains("\(range.location):\(range.length)") else { continue } - guard NSIntersectionRange(range, scopeRange).length > 0 else { continue } - storage.addAttribute(.foregroundColor, value: MarkdownTheme.dimColor, range: range) - // Indent the text so a colored accent bar can live in the gutter without - // overlapping the glyphs. drawBackground(in:) paints the bar. - let existing = storage.attribute(.paragraphStyle, at: range.location, effectiveRange: nil) as? NSParagraphStyle - let paraStyle = (existing?.mutableCopy() as? NSMutableParagraphStyle) ?? NSMutableParagraphStyle() - paraStyle.firstLineHeadIndent = 16 - paraStyle.headIndent = 16 - storage.addAttribute(.paragraphStyle, value: paraStyle, range: range) - storage.addAttribute(.blockquoteLeftBorder, value: MarkdownTheme.linkColor, range: range) - } - for callout in semanticStyles.callouts { - guard NSIntersectionRange(callout.range, scopeRange).length > 0 else { continue } - let background = MarkdownTheme.codeBackground.blended(withFraction: 0.2, of: MarkdownTheme.linkColor) ?? MarkdownTheme.codeBackground - storage.addAttributes([ - .backgroundColor: background, - .foregroundColor: SynapseTheme.editorForeground, - ], range: callout.range) - storage.addAttribute(.foregroundColor, value: MarkdownTheme.dimColor, range: callout.markerRange) - if let titleRange = callout.titleRange { - storage.addAttributes([ - .font: boldFont, - .foregroundColor: MarkdownTheme.linkColor, - ], range: titleRange) - } - } - 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 frontmatterParagraphStyle = MarkdownTheme.paragraphStyle(font: frontmatterFont, lineHeightMultiple: 1.2) - storage.addAttributes([ - .font: frontmatterFont, - .foregroundColor: SynapseTheme.editorMuted, - .paragraphStyle: frontmatterParagraphStyle, - ], range: frontmatter.contentRange) - } - let openingFence = NSRange(location: frontmatter.range.location, length: min(3, frontmatter.range.length)) - let closingFence = NSRange(location: frontmatter.range.location + frontmatter.range.length - 3, length: min(3, frontmatter.range.length)) - if NSIntersectionRange(openingFence, scopeRange).length > 0 { - storage.addAttribute(.foregroundColor, value: MarkdownTheme.dimColor, range: openingFence) - } - if NSIntersectionRange(closingFence, scopeRange).length > 0 { - storage.addAttribute(.foregroundColor, value: MarkdownTheme.dimColor, range: closingFence) - } - } - AppState.inlineTagMatches(in: storage.string).forEach { match in - guard NSIntersectionRange(match.range, scopeRange).length > 0 else { return } - storage.addAttribute(.foregroundColor, value: MarkdownTheme.tagColor, range: match.range) - storage.addAttribute(.tagTarget, value: match.normalized, range: match.range) - } - let noteNames = Set(allFiles.map { $0.deletingPathExtension().lastPathComponent.lowercased() }) - for entry in inlineSemanticStyles.entries { - guard NSIntersectionRange(entry.range, scopeRange).length > 0 || NSIntersectionRange(entry.contentRange, scopeRange).length > 0 else { continue } - switch entry.kind { - case let .embed(rawTarget): - storage.addAttributes([ - .foregroundColor: MarkdownTheme.dimColor, - .link: rawTarget, - ], range: entry.range) - case let .wikiLink(rawTarget, destination, _): - let baseName = destination - .components(separatedBy: "#").first? - .trimmingCharacters(in: .whitespaces) ?? destination - let resolved = !noteNames.isEmpty && noteNames.contains(baseName.lowercased()) - storage.addAttributes([ - .foregroundColor: resolved ? MarkdownTheme.linkColor : MarkdownTheme.unresolvedLinkColor, - .underlineStyle: NSUnderlineStyle.single.rawValue, - .wikilinkTarget: rawTarget, - ], range: entry.range) - case let .markdownLink(destination): - storage.addAttribute(.foregroundColor, value: MarkdownTheme.dimColor, range: entry.range) - storage.addAttributes([ - .foregroundColor: MarkdownTheme.linkColor, - .underlineStyle: NSUnderlineStyle.single.rawValue, - ], range: entry.contentRange) - - if let url = URL(string: destination), url.scheme != nil { - storage.addAttribute(.link, value: url, range: entry.range) - } - case .highlight: - storage.addAttribute(.backgroundColor, value: NSColor.systemYellow.withAlphaComponent(0.3), range: entry.contentRange) - } - } - - if let bareURLRegex = LinkAwareTextView.bareURLRegex { - bareURLRegex.enumerateMatches(in: storage.string, options: [], range: searchRange) { match, _, _ in - guard let match else { return } - let range = match.range - guard range.location != NSNotFound, range.length > 0 else { return } - - if storage.attribute(.link, at: range.location, effectiveRange: nil) != nil { - return - } - - let rawURL = text.substring(with: range) - guard let url = URL(string: rawURL) else { return } - - storage.addAttributes([ - .foregroundColor: MarkdownTheme.linkColor, - .underlineStyle: NSUnderlineStyle.single.rawValue, - .link: url, - ], range: range) - } - } - for range in semanticStyles.thematicBreaks { - guard NSIntersectionRange(range, scopeRange).length > 0 else { continue } - storage.addAttribute(.foregroundColor, value: MarkdownTheme.dimColor, range: range) - } - - // Image embeds are now shown only in sidebar, not inline - // Skip adding paragraph spacing for inline image previews - /* - for match in self.visibleInlineImageMatches() { - let paragraphStyle = (storage.attribute(.paragraphStyle, at: match.paragraphRange.location, effectiveRange: nil) as? NSMutableParagraphStyle) - ?? NSMutableParagraphStyle() - let updatedStyle = paragraphStyle.mutableCopy() as? NSMutableParagraphStyle ?? NSMutableParagraphStyle() - updatedStyle.paragraphSpacing = max(updatedStyle.paragraphSpacing, self.inlinePreviewHeight(for: match.source)) - storage.addAttribute(.paragraphStyle, value: updatedStyle, range: match.paragraphRange) - storage.addAttribute(.foregroundColor, value: MarkdownTheme.dimColor, range: match.range) - } - */ - - // Restore Apple Color Emoji on emoji characters after ALL font-setting passes. - // Moving this here (rather than immediately after the blanket setAttributes reset) - // prevents heading/bold/italic styling passes from overwriting the emoji font, - // which was the root cause of the emoji flicker during typing. - restoreEmojiFonts(in: storage, range: scopeRange, bodyFont: bodyFont) - - applyCollapsibleStyling(storage: storage) - if !deferRedraw { - storage.endEditing() - } - lastAppliedEditorFontSignature = EditorFontSignature(settings: settings) - if !deferRedraw { - requestImmediateRedraw(for: scopeRange) - } - reapplySearchHighlights() - DispatchQueue.main.async { [weak self] in - self?.refreshInlineImagePreviews() - self?.refreshCollapsibleToggles() - self?.refreshCodeBlockCopyButtons() - self?.refreshAISparkle() - } - } - - // Compiled-once regex cache keyed by "pattern|options.rawValue" - private static var regexCache: [String: NSRegularExpression] = [:] - private static let bareURLRegex = try? NSRegularExpression(pattern: #"https?://[^"]+?(?=[\s)\]>]|$)"#) - - private func applyRegex(_ pattern: String, to text: NSString, storage _: NSTextStorage, options: NSRegularExpression.Options = [], searchRange: NSRange? = nil, apply: (NSRange) -> Void) { - let cacheKey = "\(pattern)|\(options.rawValue)" - let regex: NSRegularExpression - if let cached = LinkAwareTextView.regexCache[cacheKey] { - regex = cached - } else if let compiled = try? NSRegularExpression(pattern: pattern, options: options) { - LinkAwareTextView.regexCache[cacheKey] = compiled - regex = compiled - } else { - return - } - let range = searchRange ?? NSRange(location: 0, length: text.length) - regex.enumerateMatches(in: text as String, options: [], range: range) { match, _, _ in - guard let range = match?.range else { return } - apply(range) - } - } - - /// Re-apply Apple Color Emoji to emoji characters after a blanket font reset. - /// `NSTextStorage.setAttributes` replaces the font on every character, - /// including emoji — which need the Apple Color Emoji font to render. - /// Without this pass emoji momentarily show a fallback glyph (`` ` ``) until - /// Core Text resolves the substitution, causing visible flicker. - private func restoreEmojiFonts(in storage: NSTextStorage, range: NSRange, bodyFont: NSFont) { - let text = storage.string - let nsRange = Range(range, in: text) - guard let nsRange else { return } - let emojiFont = NSFont(name: "Apple Color Emoji", size: bodyFont.pointSize) - ?? NSFont.systemFont(ofSize: bodyFont.pointSize) - - // Walk composed character sequences; only touch those containing emoji scalars. - var idx = nsRange.lowerBound - while idx < nsRange.upperBound { - let next = text.index(after: idx) - // rangeOfComposedCharacterSequence gives us the full cluster - let cluster = text[idx.. 0x23F // skip small ASCII-range symbols like #, *, 0-9 - } - if isEmoji { - let charRange = NSRange(idx..= delimLen * 2 else { return } - storage.addAttribute(.foregroundColor, value: MarkdownTheme.dimColor, range: NSRange(location: outerRange.location, length: delimLen)) - storage.addAttribute(.foregroundColor, value: MarkdownTheme.dimColor, range: NSRange(location: outerRange.location + outerRange.length - delimLen, length: delimLen)) - } - - private func requestImmediateRedraw(for range: NSRange) { - guard range.length > 0 else { return } - if let layoutManager, let textContainer { - layoutManager.invalidateDisplay(forCharacterRange: range) - layoutManager.ensureLayout(for: textContainer) - var redrawRect = layoutManager.boundingRect(forGlyphRange: layoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil), in: textContainer) - redrawRect.origin.x += textContainerOrigin.x - redrawRect.origin.y += textContainerOrigin.y - if !redrawRect.isEmpty { - setNeedsDisplay(redrawRect.insetBy(dx: -24, dy: -24)) - } - } - needsDisplay = true - if range.length == (textStorage?.length ?? 0) { - setNeedsDisplay(bounds) - } - } - - fileprivate func shouldSkipIncrementalMarkdownRestyle( - document: MarkdownDocument, - refreshPlan: MarkdownEditorRefreshPlan, - editedRange: NSRange - ) -> Bool { - guard case let .blockRange(blockRange) = refreshPlan.kind else { return false } - guard let block = document.blocks.first(where: { NSEqualRanges($0.range, blockRange) }) else { return false } - guard case .paragraph = block.kind, block.inlineTokens.isEmpty else { return false } - - let nsText = string as NSString - guard nsText.length > 0 else { return false } - - let probeLocation = min(max(0, editedRange.location), max(0, nsText.length - 1)) - let probeLength = min(max(1, editedRange.length), nsText.length - probeLocation) - let probeRange = NSRange(location: probeLocation, length: probeLength) - let probeText = nsText.substring(with: probeRange) - - return !containsMarkdownTrigger(in: probeText) - } - - private func containsMarkdownTrigger(in text: String) -> Bool { - let triggerCharacters = CharacterSet(charactersIn: "*_`[]!~#>|-:/") - return text.rangeOfCharacter(from: triggerCharacters) != nil - } - - // MARK: - Collapsible section toggle buttons - - /// Applies collapsed-content hiding to the text storage and positions toggle arrow buttons. - /// Must be called from within or after `applyMarkdownStyling` once layout is ready. - func applyCollapsibleStyling(storage: NSTextStorage) { - guard storage.length > 0 else { return } - - let text = storage.string - let sections = collapsibleParser.parse(text) - let fileURL = currentFileURL ?? AppConstants.unsavedFileURL - - // When the file has no session state yet, auto-initialise each section: - // collapse it if it has >= 10 lines, expand it otherwise. - if !collapsibleStateManager.hasSessionState(for: fileURL) { - for section in sections { - guard section.contentRange.length > 0 else { continue } - let shouldCollapse = section.contentLineCount(in: text) >= 10 - collapsibleStateManager.setCollapsed(shouldCollapse, - for: section.getIdentifier(), - in: fileURL) - } - } - - for section in sections { - let sectionId = section.getIdentifier() - let isCollapsed = collapsibleStateManager.isCollapsed(sectionId, in: fileURL) - - guard section.contentRange.length > 0 else { continue } - let contentRange = section.contentRange - - // Safety: clamp to storage length - let safeLocation = min(contentRange.location, storage.length) - let safeLength = min(contentRange.length, storage.length - safeLocation) - guard safeLength > 0 else { continue } - let safeRange = NSRange(location: safeLocation, length: safeLength) - - if isCollapsed { - // Hide content: make it invisible and zero-height - let hiddenStyle = NSMutableParagraphStyle() - hiddenStyle.maximumLineHeight = 0.001 - hiddenStyle.minimumLineHeight = 0.001 - storage.addAttributes([ - .foregroundColor: NSColor.clear, - .font: NSFont.systemFont(ofSize: 0.001), - .paragraphStyle: hiddenStyle, - ], range: safeRange) - } - } - } - - /// Positions (or creates) a small arrow toggle button in the left margin of each - /// collapsible section header line, and removes buttons for sections that no longer exist. - func refreshCollapsibleToggles() { - guard let layoutManager, let textContainer else { return } - layoutManager.ensureLayout(for: textContainer) - - let text = string - let sections = collapsibleParser.parse(text) - let fileURL = currentFileURL ?? AppConstants.unsavedFileURL - - let activeKeys = Set(sections.map { $0.getIdentifier() }) - - // Remove stale buttons - for key in Array(collapsibleToggleButtons.keys) where !activeKeys.contains(key) { - collapsibleToggleButtons[key]?.removeFromSuperview() - collapsibleToggleButtons.removeValue(forKey: key) - } - - for section in sections { - guard section.contentRange.length > 0 else { - // No indented content — remove button if present - let key = section.getIdentifier() - collapsibleToggleButtons[key]?.removeFromSuperview() - collapsibleToggleButtons.removeValue(forKey: key) - continue - } - - let sectionId = section.getIdentifier() - let isCollapsed = collapsibleStateManager.isCollapsed(sectionId, in: fileURL) - - // Anchor the disclosure control to the list marker itself so it aligns - // with the first visible line rather than the broader header range. - let markerRange = NSRange(location: section.headerRange.location, length: 1) - let markerGlyphRange = layoutManager.glyphRange(forCharacterRange: markerRange, actualCharacterRange: nil) - var markerRect = layoutManager.boundingRect(forGlyphRange: markerGlyphRange, in: textContainer) - markerRect.origin.x += textContainerOrigin.x - markerRect.origin.y += textContainerOrigin.y - - let buttonSize: CGFloat = 28 - let buttonFrame = collapsibleToggleFrame( - forMarkerRect: markerRect, - textContainerOrigin: textContainerOrigin, - buttonSize: buttonSize - ) - - let button: CollapsibleToggleButton - if let existing = collapsibleToggleButtons[sectionId] { - button = existing - } else { - button = CollapsibleToggleButton(frame: buttonFrame) - addSubview(button) - collapsibleToggleButtons[sectionId] = button - } - - button.isCollapsed = isCollapsed - button.frame = buttonFrame - button.toolTip = isCollapsed ? "Expand section" : "Collapse section" - - // Use target/action — capture the identifier by value - let capturedId = sectionId - button.target = self - button.action = #selector(collapsibleToggleTapped(_:)) - button.identifier = NSUserInterfaceItemIdentifier(capturedId) - } - } - - // MARK: - Inline AI editing - - /// Positions a single reused ✨ button just past the end of the caret's line content - /// (or past the selection when text is selected). Anchors to the *used* width of the - /// caret's line fragment, so on an empty line it sits next to the caret rather than - /// at the far right of the text container. Cheap: one layout lookup, no parsing. - func refreshAISparkle() { - guard let layoutManager, let textContainer else { return } - // Respect the user's show/hide preference (default on). - guard settings?.showAISparkle ?? true else { - aiSparkleButton?.isHidden = true - return - } - let sel = selectedRange() - let ns = string as NSString - - // The character index whose line we anchor to: selection end, or the caret. - let anchorIndex = max(0, min(sel.length > 0 ? sel.location + sel.length : sel.location, ns.length)) - - let fallbackLineHeight = layoutManager.defaultLineHeight(for: font ?? NSFont.systemFont(ofSize: NSFont.systemFontSize)) - var lineRect: NSRect - if sel.length > 0 { - // Non-empty selection: anchor just past the trailing edge of its glyphs. - let selGlyphs = layoutManager.glyphRange(forCharacterRange: sel, actualCharacterRange: nil) - lineRect = layoutManager.boundingRect(forGlyphRange: selGlyphs, in: textContainer) - } else if anchorIndex == ns.length - && (ns.length == 0 || ns.substring(with: NSRange(location: ns.length - 1, length: 1)).rangeOfCharacter(from: .newlines) != nil) { - // Caret on the final empty line (empty doc, or after a trailing newline). The - // layout manager tracks this as the "extra line fragment". Its used rect is - // the caret position on that empty line. - let extra = layoutManager.extraLineFragmentUsedRect - if extra.height > 0 { - lineRect = extra - } else { - lineRect = NSRect(x: 0, y: 0, width: 0, height: fallbackLineHeight) - } - } else { - // Caret on a non-trailing (possibly empty) line: use that line fragment's USED - // rect, whose width reflects the actual typeset content (≈ the caret x on an - // empty line), not the full container width — which is what made the ✨ fly - // off to the right. - let glyphIndex = min(layoutManager.glyphIndexForCharacter(at: anchorIndex), max(0, layoutManager.numberOfGlyphs - 1)) - lineRect = layoutManager.lineFragmentUsedRect(forGlyphAt: glyphIndex, effectiveRange: nil) - } - - var rect = lineRect - rect.origin.x += textContainerOrigin.x - rect.origin.y += textContainerOrigin.y - - let size: CGFloat = 18 - let frame = NSRect(x: rect.maxX + 6, y: rect.minY + (rect.height - size) / 2, width: size, height: size) - - let button: AISparkleButton - if let existing = aiSparkleButton { - button = existing - } else { - button = AISparkleButton(frame: frame) - button.target = self - button.action = #selector(aiSparkleTapped) - addSubview(button) - aiSparkleButton = button - } - // This runs on every caret move; avoid needless invalidation when nothing moved. - if button.frame != frame { button.frame = frame } - button.isHidden = (aiBarHostingView != nil) // hide while the bar is open - } - - @objc private func aiSparkleTapped() { - let sel = selectedRange() - let mode: InlineAIBarMode = sel.length > 0 ? .rewrite : .generate - presentAIBar(mode: mode, at: sel) - } - - private func presentAIBar(mode: InlineAIBarMode, at sel: NSRange) { - dismissAIBar() - aiBarOriginalSelection = sel - aiBarUserMoved = false - aiBarDragStartOrigin = nil - - let defaultModel = AIModel(apiID: settings?.aiDefaultModel ?? AIModel.default.apiID) - let model = InlineAIBarModel(mode: mode, model: defaultModel) - model.allFiles = aiAppState?.allFiles ?? [] - model.allFolders = aiAppState?.allFolders() ?? [] - - model.onSubmit = { [weak self] prompt, chosen in - self?.startAIStream(prompt: prompt, model: chosen, mode: mode, selection: sel) - } - model.onRetry = { [weak self] prompt, chosen in - // Discard the previous output so the re-run replaces it instead of - // appending, then stream fresh from the original anchor/selection. - self?.inlineAIController.discardOutput() - self?.clearAIDiffColors() - self?.aiBarModel?.awaitingAcceptReject = false - self?.startAIStream(prompt: prompt, model: chosen, mode: mode, selection: sel) - } - model.onStop = { [weak self] in self?.stopAIStream() } - model.onAccept = { [weak self] in self?.acceptAI() } - model.onReject = { [weak self] in self?.rejectAI() } - model.onCancel = { [weak self] in self?.dismissAIBar() } - model.onDrag = { [weak self] translation in self?.dragAIBar(by: translation) } - model.onDragEnded = { [weak self] in self?.aiBarDragStartOrigin = nil } - model.onContentSizeMayHaveChanged = { [weak self] in self?.resizeAIBarToFit() } - aiBarModel = model - - let host = NSHostingView(rootView: InlineAIBarView(model: model)) - host.frame = aiBarFrame(below: sel) - addSubview(host) - aiBarHostingView = host - refreshAISparkle() - } - - /// Frame for the AI bar. Anchored just below the bottom of the affected region so it - /// never overlaps the streamed text/diff (the region is the end of the streamed - /// `newRange`/`originalRange` once streaming starts, else the selection/cursor). If - /// placing it below would push it past the bottom of the visible viewport (a long - /// diff), it is placed ABOVE the top of the affected region instead, so it stays on - /// screen and still clears the diff. - private func aiBarFrame(below sel: NSRange, size: NSSize? = nil) -> NSRect { - guard let layoutManager, let textContainer else { return .zero } - let ns = string as NSString - let barSize = size ?? aiBarFittedSize() - let width = barSize.width - let barHeight = barSize.height - - func yOffset(forCharacterIndex index: Int) -> (top: CGFloat, bottom: CGFloat) { - let safe = max(0, min(index, ns.length)) - let gr = layoutManager.glyphRange(forCharacterRange: NSRange(location: safe, length: 0), actualCharacterRange: nil) - var r = layoutManager.boundingRect(forGlyphRange: gr, in: textContainer) - r.origin.y += textContainerOrigin.y - return (r.minY, r.maxY) - } - - // Bottom of the affected region (prefer streamed text) and top of it (for the - // above-placement fallback). - var bottomAnchor = sel.length > 0 ? sel.location + sel.length : sel.location - if let nr = inlineAIController.newRange { bottomAnchor = max(bottomAnchor, NSMaxRange(nr)) } - if let orig = inlineAIController.originalRange { bottomAnchor = max(bottomAnchor, NSMaxRange(orig)) } - let topAnchor = min(sel.location, inlineAIController.originalRange?.location ?? sel.location) - - let belowY = yOffset(forCharacterIndex: bottomAnchor).bottom + 6 - let visible = enclosingScrollView?.documentVisibleRect ?? visibleRect - - // If the bar placed below would run past the visible area, place it above the region. - if belowY + barHeight > visible.maxY { - let aboveY = yOffset(forCharacterIndex: topAnchor).top - barHeight - 6 - let clampedY = max(visible.minY + 6, aboveY) - return NSRect(x: 12, y: clampedY, width: width, height: barHeight) - } - return NSRect(x: 12, y: belowY, width: width, height: barHeight) - } - - /// The bar's content-fitted size (drag handle + growing prompt + suggestion list), - /// clamped to the editor width and a sane height range. fittingSize needs the - /// target width set on the host first. - private func aiBarFittedSize() -> NSSize { - let width = min(bounds.width - 24, 520) - var height: CGFloat = 80 - if let host = aiBarHostingView { - host.frame.size.width = width - let fitting = host.fittingSize.height - if fitting > 0 { height = max(60, min(fitting, 360)) } - } - return NSSize(width: width, height: height) - } - - /// Re-anchors the bar below the current affected region (called as text streams in - /// and when streaming finishes) so it tracks the growing diff instead of covering it. - /// No-op once the user has dragged the bar to a manual position. - private func repositionAIBar() { - guard let host = aiBarHostingView, !aiBarUserMoved else { return } - // Streaming doesn't change the bar's content, so reuse its current size — - // avoids a full SwiftUI fitting pass per streamed delta. - host.frame = aiBarFrame(below: aiBarOriginalSelection, size: host.frame.size) - } - - /// Resizes the bar to fit its content (prompt growth, suggestion list). Preserves the - /// user-dragged origin if they moved it; otherwise re-anchors below the affected region. - private func resizeAIBarToFit() { - guard let host = aiBarHostingView else { return } - if aiBarUserMoved { - host.frame.size = aiBarFittedSize() - } else { - host.frame = aiBarFrame(below: aiBarOriginalSelection) - } - } - - /// Moves the bar by a drag-handle translation. The bar is a subview of the text view, - /// which is a flipped NSView (y grows downward) — same direction as SwiftUI's global - /// translation — so the y delta is ADDED, not negated. The translation is cumulative - /// from drag start, so we offset the origin captured when the drag began. - private func dragAIBar(by translation: CGSize) { - guard let host = aiBarHostingView else { return } - aiBarUserMoved = true - let start = aiBarDragStartOrigin ?? host.frame.origin - if aiBarDragStartOrigin == nil { aiBarDragStartOrigin = start } - let newOrigin = NSPoint(x: start.x + translation.width, - y: start.y + translation.height) - // Keep the bar within the visible area. - let visible = enclosingScrollView?.documentVisibleRect ?? visibleRect - let clampedX = min(max(newOrigin.x, visible.minX + 4), visible.maxX - host.frame.width - 4) - let clampedY = min(max(newOrigin.y, visible.minY + 4), visible.maxY - host.frame.height - 4) - host.frame.origin = NSPoint(x: clampedX, y: clampedY) - } - - /// Shared teardown core: cancels any in-flight stream, closes the operation's - /// single undo group, and removes the bar. - private func cancelAIStreamAndRemoveBar() { - aiStreamTask?.cancel(); aiStreamTask = nil - endAIUndoGroup() - aiBarHostingView?.removeFromSuperview(); aiBarHostingView = nil - aiBarModel = nil - } - - private func dismissAIBar() { - cancelAIStreamAndRemoveBar() - refreshAISparkle() - } - - /// Tears down any in-flight or pending inline-AI session. Called when the - /// document is swapped (note/tab switch) so stale ranges can't corrupt the - /// new note or crash on accept/reject. Does NOT touch storage: the - /// about-to-run setPlainText replaces the whole attributed string, so old - /// diff colors vanish with it. - func teardownAISession() { - guard aiBarHostingView != nil || inlineAIController.mode != .idle else { return } - cancelAIStreamAndRemoveBar() - inlineAIController.resetWithoutMutating() - } - - /// Re-applies transient AI diff colors after a styling pass, if a session is active. - /// The normal markdown restyle blanket-sets foreground colors, wiping the diff - /// colors; this restores them so they don't flicker mid-stream. - func reapplyAIDiffColorsIfActive() { - guard inlineAIController.mode != .idle else { return } - applyAIDiffColors() - } - - /// Applies an AI text mutation through the standard NSTextView edit path so it - /// registers with the undo manager (Cmd-Z reverts AI insertions/rewrites). Bounds-safe. - /// All edits in one AI operation are coalesced into a single undo group (see - /// `beginAIUndoGroup`/`endAIUndoGroup`) so one Cmd-Z reverts the whole thing. - private func performAIEdit(_ range: NSRange, _ replacement: String) { - guard let storage = textStorage else { return } - guard range.location >= 0, NSMaxRange(range) <= storage.length else { return } - if shouldChangeText(in: range, replacementString: replacement) { - replaceCharacters(in: range, with: replacement) - didChangeText() - } - } - - /// Opens an undo group so every streamed delta + the accept/reject deletion collapse - /// into a single Cmd-Z. Also disables NSTextView's automatic per-keystroke coalescing - /// boundary so the deltas don't split into separate undo steps. - private func beginAIUndoGroup() { - guard !aiUndoGroupOpen else { return } - breakUndoCoalescing() - undoManager?.beginUndoGrouping() - aiUndoGroupOpen = true - } - - /// Closes the AI undo group opened by `beginAIUndoGroup` (idempotent). - private func endAIUndoGroup() { - guard aiUndoGroupOpen else { return } - undoManager?.endUndoGrouping() - breakUndoCoalescing() - aiUndoGroupOpen = false - } - - private func startAIStream(prompt: String, model: AIModel, mode: InlineAIBarMode, selection sel: NSRange) { - guard let storage = textStorage else { return } - guard let key = KeychainStore().get(), !key.isEmpty else { - aiBarModel?.errorMessage = "Add your Anthropic API key in Settings →" - return - } - - // Reuse the vault lists captured when the bar was presented; allFolders() - // walks every file's ancestor chain, so don't recompute it per submit. - let resolver = AIContextResolver( - allFiles: aiBarModel?.allFiles ?? [], - allFolders: aiBarModel?.allFolders ?? [], - readContents: { try? String(contentsOf: $0, encoding: .utf8) }) - let resolved = resolver.resolve(prompt: prompt) - - if mode == .generate { - inlineAIController.beginGenerate(in: storage, at: sel.location) - } else { - inlineAIController.beginRewrite(in: storage, selection: sel) - } - // Route the controller's text mutations through the undo-registering path so the - // whole AI edit is undoable with Cmd-Z (one logical change, not silent storage edits). - inlineAIController.performEdit = { [weak self] range, replacement in - self?.performAIEdit(range, replacement) - } - // Group every edit in this operation (all deltas + the accept/reject deletion) - // into a single undo step. Idempotent, so Retry re-entry keeps the same group. - beginAIUndoGroup() - - let selectionText = mode == .rewrite ? (string as NSString).substring(with: sel) : nil - let body = AIRequestBuilder.build( - mode: mode, - prompt: prompt, noteText: string, - selection: selectionText, context: resolved.blocks, model: model) - - aiBarModel?.isStreaming = true - if resolved.truncated { - aiBarModel?.errorMessage = "Context truncated to fit." - } else if !resolved.missing.isEmpty { - aiBarModel?.errorMessage = "\(resolved.missing.count) reference(s) not found." - } else { - aiBarModel?.errorMessage = nil - } - - let client = AnthropicClient(apiKey: key) - aiStreamTask = Task { [weak self] in - do { - for try await delta in client.stream(body: body) { - await MainActor.run { - // appendDelta routes through performAIEdit, which calls didChangeText(). - self?.inlineAIController.appendDelta(delta) - self?.colorAIDelta(appendedLength: (delta as NSString).length) - self?.repositionAIBar() - } - } - await MainActor.run { self?.finishAIStream(mode: mode) } - } catch { - await MainActor.run { self?.handleAIError(error) } - } - } - } - - private func stopAIStream() { - aiStreamTask?.cancel(); aiStreamTask = nil - // finishAIStream owns the per-mode end-of-session rules (generate: reset + - // dismiss; rewrite: await accept/reject). - finishAIStream(mode: aiBarModel?.mode ?? .generate) - } - - private func finishAIStream(mode: InlineAIBarMode) { - aiBarModel?.isStreaming = false - if mode == .rewrite { - aiBarModel?.awaitingAcceptReject = true - } else { - inlineAIController.cancel() - dismissAIBar() - } - applyAIDiffColors() - repositionAIBar() - } - - private func handleAIError(_ error: Error) { - aiBarModel?.isStreaming = false - if let e = error as? AnthropicClient.ClientError { - switch e { - case .invalidKey: aiBarModel?.errorMessage = "Invalid API key — check Settings." - case .server(let s): aiBarModel?.errorMessage = "Server error (\(s)). Try again." - case .badResponse: aiBarModel?.errorMessage = "Unexpected response. Try again." - } - } else { - aiBarModel?.errorMessage = "Network error. Try again." - } - if aiBarModel?.mode == .generate { - inlineAIController.cancel() // generate: reset to idle so a retry starts clean - } - if aiBarModel?.mode == .rewrite { aiBarModel?.awaitingAcceptReject = true } - } - - /// Shared accept/reject epilogue: resolve the diff via the controller, restore - /// normal styling, sync the final text to the binding, and close the bar. - private func resolveAIRewrite(_ resolve: () -> Void) { - resolve() - clearAIDiffColors() - didChangeText() - dismissAIBar() - } - - private func acceptAI() { resolveAIRewrite(inlineAIController.accept) } - - private func rejectAI() { resolveAIRewrite(inlineAIController.reject) } - - /// Colors only the newly appended streamed delta (green) — O(delta) per chunk - /// instead of re-coloring the whole accumulated diff (O(total), quadratic over a - /// stream). The first delta falls back to the full pass so the original range - /// gets its strikethrough/red at the same moment it always has; later wipes by - /// styling passes are restored by `reapplyAIDiffColorsIfActive`. - private func colorAIDelta(appendedLength: Int) { - guard let storage = textStorage, - let nr = inlineAIController.newRange, appendedLength > 0 else { return } - guard nr.length > appendedLength else { - applyAIDiffColors() - return - } - let sub = NSRange(location: NSMaxRange(nr) - appendedLength, length: appendedLength) - guard sub.location >= 0, NSMaxRange(sub) <= storage.length else { return } - storage.addAttribute(.foregroundColor, value: NSColor.systemGreen, range: sub) - } - - private func applyAIDiffColors() { - guard let storage = textStorage else { return } - if let orig = inlineAIController.originalRange, orig.length > 0, - NSMaxRange(orig) <= storage.length { - storage.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: orig) - storage.addAttribute(.foregroundColor, value: NSColor.systemRed, range: orig) - } - if let nr = inlineAIController.newRange, nr.length > 0, - NSMaxRange(nr) <= storage.length { - storage.addAttribute(.foregroundColor, value: NSColor.systemGreen, range: nr) - } - } - - private func clearAIDiffColors() { - guard let storage = textStorage else { return } - let full = NSRange(location: 0, length: storage.length) - storage.removeAttribute(.strikethroughStyle, range: full) - refreshEditorForCurrentDisplayMode(self) - } - - @objc private func collapsibleToggleTapped(_ sender: NSControl) { - let sectionId = sender.identifier?.rawValue ?? "" - guard !sectionId.isEmpty else { return } - let fileURL = currentFileURL ?? AppConstants.unsavedFileURL - let current = collapsibleStateManager.isCollapsed(sectionId, in: fileURL) - collapsibleStateManager.setCollapsed(!current, for: sectionId, in: fileURL) - refreshEditorForCurrentDisplayMode(self) - } - - private func clearInlineImagePreviews() { - for key in Array(inlineImageViews.keys) { - inlineImageViews[key]?.removeFromSuperview() - inlineImageViews.removeValue(forKey: key) - } - - for key in Array(inlineVideoViews.keys) { - inlineVideoViews[key]?.removeFromSuperview() - inlineVideoViews.removeValue(forKey: key) - } - - clearCodeBlockCopyButtons() - } - -} - -#if DEBUG -private func debugLog(_ msg: String) { - let line = "[Synapse] \(msg)\n" - if let data = line.data(using: .utf8) { - if FileManager.default.fileExists(atPath: "/tmp/Synapse_debug.log") { - if let fh = FileHandle(forWritingAtPath: "/tmp/Synapse_debug.log") { - fh.seekToEndOfFile() - fh.write(data) - fh.closeFile() - } - } else { - FileManager.default.createFile(atPath: "/tmp/Synapse_debug.log", contents: data) - } - } - -} -#else -@inline(__always) private func debugLog(_ msg: String) {} -#endif - -// MARK: - LinkAwareTextView - -class LinkAwareTextView: NSTextView { - enum EditorDisplayMode { - case markdown - case preview - } - - var allFiles: [URL] = [] - var onOpenFile: ((URL, Bool) -> Void)? - var onOpenTag: ((String, Bool) -> Void)? // (tag, openInNewTab) - var onActivatePane: (() -> Void)? - var onCreateNote: ((String, URL?) -> Void)? // name, preferred directory - var onOpenExternalURL: ((URL) -> Void)? // External URL opening (defaults to NSWorkspace) - var onSelectEmbed: ((String) -> Void)? // embed ID when clicking on markdown - var currentFileURL: URL? - var onMatchCountUpdate: ((Int) -> Void)? - /// Only the editor participating in global commands (current focused note) should react to - /// find/replace notifications that mutate text. Mirrors `onMatchCountUpdate` gating. - var participatesInGlobalSearch: Bool = false - var onWikiLinkRequest: (() -> Void)? // Called when [[ is typed - var onWikiLinkComplete: ((URL) -> Void)? // Called when a file is selected for wiki link - var onWikiLinkDismiss: (() -> Void)? // Called when the picker is dismissed via ESC - var slashCommandNowProvider: () -> Date = Date.init - var slashCommandTimeZone: TimeZone = .current - /// Called when CMD-K fires but the editor has no selection, so the normal command palette should open. - var onCommandPaletteFallback: (() -> Void)? - - // Settings manager for font configuration - var settings: SettingsManager? - fileprivate var lastAppliedEditorFontSignature: EditorFontSignature? = nil - - private var completionPopover: NSPopover? - private var completionVC: CompletionViewController? - fileprivate var linkTypingRange: NSRange? - /// Set when the user ESCs the wiki-link picker. Suppresses reopening the picker - /// until the cursor leaves the current [[ token (which calls dismissCompletion). - fileprivate var wikilinkPickerSuppressed = false - /// Selected text captured before the wikilink palette opens; used to produce [[name|alias]]. - fileprivate var pendingWikilinkAlias: String? = nil - /// Original selection captured before the wikilink palette steals focus. - fileprivate var pendingWikilinkSelectionRange: NSRange? = nil - var lastAppliedEditorDisplayMode: EditorDisplayMode? = nil - private var eventMonitor: Any? - private var inlineImageViews: [String: NSImageView] = [:] - private var inlineVideoViews: [String: YouTubePreviewView] = [:] - private var isPrettifyingTable = false - - // MARK: - Collapsible sections - private let collapsibleParser = CollapsibleSectionParser() - private let collapsibleStateManager = CollapsibleStateManager() - /// Toggle buttons keyed by section identifier ("headerOffset-headerLength") - private var collapsibleToggleButtons: [String: CollapsibleToggleButton] = [:] - - // MARK: - Inline AI editing - let inlineAIController = InlineAIController() - /// True while a rewrite diff is on screen awaiting accept/reject — the buffer - /// holds both original and new text, which must not be synced/saved as-is. - var hasPendingAIDiff: Bool { inlineAIController.mode == .rewrite } - private var aiSparkleButton: AISparkleButton? - private var aiBarHostingView: NSHostingView? - private var aiBarModel: InlineAIBarModel? - private var aiStreamTask: Task? - /// The selection/cursor captured when the bar opened — used to re-anchor the bar - /// below the affected region as text streams in. - private var aiBarOriginalSelection: NSRange = NSRange(location: 0, length: 0) - /// The bar's origin captured at the start of a drag (nil when not dragging). - private var aiBarDragStartOrigin: NSPoint? - /// Once the user drags the bar, stop auto-repositioning it below the streamed text. - private var aiBarUserMoved = false - /// True while an AI undo group is open (so the whole operation undoes as one step). - private var aiUndoGroupOpen = false - /// Injected at setup; source of vault files for @-context. - weak var aiAppState: AppState? - - // MARK: - Embedded Notes (for side panel) - private static let embedRegex = try? NSRegularExpression(pattern: #"!\[\[([^\]]+)\]\]"#) - - private static let inlineImageRegex = try? NSRegularExpression(pattern: #"!\[([^\]]*)\]\((.+?)\)"#, options: []) - - /// Extends code-block background fills to the full container width. - /// NSAttributedString's .backgroundColor only covers the glyph bounds for that run. - /// For the closing fence line the background stops at the last visible glyph, - /// leaving a gap on the right. We intercept drawBackground and repaint any run - /// that carries the custom `.codeBlockFullWidthBackground` marker attribute as a - /// full-width band. - override func drawBackground(in rect: NSRect) { - super.drawBackground(in: rect) - - guard let storage = textStorage, - let layoutManager = layoutManager, - let textContainer = textContainer else { return } - - let containerWidth = textContainer.containerSize.width - let insetX = textContainerOrigin.x - let insetY = textContainerOrigin.y - - var charIndex = 0 - let length = storage.length - while charIndex < length { - var effectiveRange = NSRange(location: NSNotFound, length: 0) - guard let color = storage.attribute(.codeBlockFullWidthBackground, at: charIndex, effectiveRange: &effectiveRange) as? NSColor, - effectiveRange.location != NSNotFound else { - charIndex = effectiveRange.location != NSNotFound ? effectiveRange.location + effectiveRange.length : charIndex + 1 - continue - } - - let glyphRange = layoutManager.glyphRange(forCharacterRange: effectiveRange, actualCharacterRange: nil) - var lineStart = glyphRange.location - let glyphEnd = glyphRange.location + glyphRange.length - - while lineStart < glyphEnd { - var lineGlyphRange = NSRange(location: NSNotFound, length: 0) - let lineRect = layoutManager.lineFragmentRect(forGlyphAt: lineStart, effectiveRange: &lineGlyphRange, withoutAdditionalLayout: true) - guard lineGlyphRange.location != NSNotFound else { break } - - let bandY = lineRect.origin.y + insetY - let bandHeight = lineRect.height - guard bandHeight > 0 else { - lineStart = lineGlyphRange.location + lineGlyphRange.length - continue - } - - let bandRect = NSRect(x: insetX, y: bandY, width: containerWidth, height: bandHeight) - if bandRect.intersects(rect) { - color.setFill() - bandRect.fill() - } - lineStart = lineGlyphRange.location + lineGlyphRange.length - } - charIndex = effectiveRange.location + effectiveRange.length - } - - // Decorative accent bar for blockquote ranges. Paragraph style supplies the - // leading indent (16pt); we paint a rounded bar of ~3pt in that gutter. - let barWidth: CGFloat = 3 - let barInset: CGFloat = 4 - charIndex = 0 - while charIndex < length { - var effectiveRange = NSRange(location: NSNotFound, length: 0) - guard let color = storage.attribute(.blockquoteLeftBorder, at: charIndex, effectiveRange: &effectiveRange) as? NSColor, - effectiveRange.location != NSNotFound else { - charIndex = effectiveRange.location != NSNotFound ? effectiveRange.location + effectiveRange.length : charIndex + 1 - continue - } - - let glyphRange = layoutManager.glyphRange(forCharacterRange: effectiveRange, actualCharacterRange: nil) - var lineStart = glyphRange.location - let glyphEnd = glyphRange.location + glyphRange.length - - while lineStart < glyphEnd { - var lineGlyphRange = NSRange(location: NSNotFound, length: 0) - let lineRect = layoutManager.lineFragmentRect(forGlyphAt: lineStart, effectiveRange: &lineGlyphRange, withoutAdditionalLayout: true) - guard lineGlyphRange.location != NSNotFound else { break } - - let bandHeight = lineRect.height - guard bandHeight > 0 else { - lineStart = lineGlyphRange.location + lineGlyphRange.length - continue - } - - let barRect = NSRect( - x: insetX + barInset, - y: lineRect.origin.y + insetY, - width: barWidth, - height: bandHeight - ) - if barRect.intersects(rect) { - color.withAlphaComponent(0.75).setFill() - let path = NSBezierPath(roundedRect: barRect, xRadius: barWidth / 2, yRadius: barWidth / 2) - path.fill() - } - lineStart = lineGlyphRange.location + lineGlyphRange.length - } - charIndex = effectiveRange.location + effectiveRange.length - } - } - - override func mouseDown(with event: NSEvent) { - if activatePaneOnReadOnlyInteraction(isEditable: isEditable, onActivatePane: onActivatePane) { - return - } - let point = convert(event.locationInWindow, from: nil) - - if let hit = taskCheckboxTarget(at: point) { - _ = toggleTaskCheckbox(atCharacterIndex: hit.markerRange.location) - return - } - - // Check if clicking on an image markdown - if let embedID = imageEmbedTarget(at: point) { - onSelectEmbed?(embedID) - return - } - - if let target = wikilinkTarget(at: point) { - if wikilinkMarkdownIsHidden(at: point) { - let openInNewTab = event.modifierFlags.contains(.command) - _ = handleLinkClick(target, openInNewTab: openInNewTab) - return - } - // Markdown is exposed (always-on markdown mode, or the caret's active - // block under hide-while-typing): fall through so the click places the - // caret for editing instead of navigating. - } - - // Check if clicking on a tag - if let tag = tagTarget(at: point) { - let openInNewTab = event.modifierFlags.contains(.command) - _ = handleTagClick(tag, openInNewTab: openInNewTab) - return - } - super.mouseDown(with: event) - } - - private var trackingArea: NSTrackingArea? - - override func updateTrackingAreas() { - super.updateTrackingAreas() - - if let oldTrackingArea = trackingArea { - removeTrackingArea(oldTrackingArea) - } - - let newTrackingArea = NSTrackingArea( - rect: bounds, - options: [.mouseMoved, .activeAlways, .inVisibleRect], - owner: self, - userInfo: nil - ) - addTrackingArea(newTrackingArea) - trackingArea = newTrackingArea - } - - override func mouseMoved(with event: NSEvent) { - let point = convert(event.locationInWindow, from: nil) - - // Check if hovering over an interactive element - if taskCheckboxTarget(at: point) != nil || - imageEmbedTarget(at: point) != nil || - wikilinkTarget(at: point) != nil || - tagTarget(at: point) != nil || - urlTarget(at: point) != nil { - NSCursor.pointingHand.set() - } else { - NSCursor.arrow.set() - } - } - - override func mouseExited(with event: NSEvent) { - NSCursor.arrow.set() - } - - func imageEmbedTarget(at viewPoint: NSPoint) -> String? { - guard let layout = layoutManager, let container = textContainer else { return nil } - - let containerPoint = NSPoint( - x: viewPoint.x - textContainerOrigin.x, - y: viewPoint.y - textContainerOrigin.y - ) - var fraction: CGFloat = 0 - let charIndex = layout.characterIndex( - for: containerPoint, - in: container, - fractionOfDistanceBetweenInsertionPoints: &fraction - ) - guard charIndex < (string as NSString).length else { return nil } - - let glyphIndex = layout.glyphIndexForCharacter(at: charIndex) - let glyphRect = layout.boundingRect(forGlyphRange: NSRange(location: glyphIndex, length: 1), in: container) - guard glyphRect.contains(containerPoint) else { return nil } - - // Check if this character is part of an image markdown - let nsText = string as NSString - let textRange = NSRange(location: 0, length: nsText.length) - - guard let regex = Self.inlineImageRegex else { return nil } - let matches = regex.matches(in: string, range: textRange) - - for match in matches { - let matchRange = match.range(at: 0) - if NSLocationInRange(charIndex, matchRange) { - let source = nsText.substring(with: match.range(at: 2)).trimmingCharacters(in: .whitespacesAndNewlines) - return "\(matchRange.location)-\(source)" - } - } - - return nil - } - - func wikilinkTarget(at viewPoint: NSPoint) -> String? { - guard let layout = layoutManager, let container = textContainer else { return nil } - - let containerPoint = NSPoint( - x: viewPoint.x - textContainerOrigin.x, - y: viewPoint.y - textContainerOrigin.y - ) - var fraction: CGFloat = 0 - let charIndex = layout.characterIndex( - for: containerPoint, - in: container, - fractionOfDistanceBetweenInsertionPoints: &fraction - ) - guard charIndex < (string as NSString).length else { return nil } - - let glyphIndex = layout.glyphIndexForCharacter(at: charIndex) - let glyphRect = layout.boundingRect(forGlyphRange: NSRange(location: glyphIndex, length: 1), in: container) - guard glyphRect.contains(containerPoint) else { return nil } - - return textStorage?.attribute(.wikilinkTarget, at: charIndex, effectiveRange: nil) as? String - } - - /// Whether the `[[...]]` markdown for the link at `viewPoint` is currently hidden, - /// which is the only case a WikiLink click should navigate. The markdown is hidden - /// only when hide-while-typing is on AND the click lands outside the caret's block - /// (the caret's block is revealed/exposed). In always-on markdown mode the syntax is - /// visible everywhere, so this returns false and clicks never navigate. - func wikilinkMarkdownIsHidden(at viewPoint: NSPoint) -> Bool { - guard settings?.hideMarkdownWhileEditing == true else { return false } - guard let layout = layoutManager, let container = textContainer else { return true } - - let containerPoint = NSPoint( - x: viewPoint.x - textContainerOrigin.x, - y: viewPoint.y - textContainerOrigin.y - ) - var fraction: CGFloat = 0 - let charIndex = layout.characterIndex( - for: containerPoint, - in: container, - fractionOfDistanceBetweenInsertionPoints: &fraction - ) - - // The caret's block has its markdown revealed; every other block is collapsed. - // No active block (read-only / unfocused pane) ⇒ all links collapsed ⇒ clickable. - let reveal = MarkdownPreviewBlockReveal.make( - from: textStorage?.string ?? string, - cursorLocation: selectedRange().location, - isEditable: isEditable - ) - guard let blockRange = reveal.blockRange else { return true } - return !NSLocationInRange(charIndex, blockRange) - } - - func tagTarget(at viewPoint: NSPoint) -> String? { - guard let layout = layoutManager, let container = textContainer else { return nil } - - let containerPoint = NSPoint( - x: viewPoint.x - textContainerOrigin.x, - y: viewPoint.y - textContainerOrigin.y - ) - var fraction: CGFloat = 0 - let charIndex = layout.characterIndex( - for: containerPoint, - in: container, - fractionOfDistanceBetweenInsertionPoints: &fraction - ) - guard charIndex < (string as NSString).length else { return nil } - - let glyphIndex = layout.glyphIndexForCharacter(at: charIndex) - let glyphRect = layout.boundingRect(forGlyphRange: NSRange(location: glyphIndex, length: 1), in: container) - guard glyphRect.contains(containerPoint) else { return nil } - - return textStorage?.attribute(.tagTarget, at: charIndex, effectiveRange: nil) as? String - } - - func urlTarget(at viewPoint: NSPoint) -> URL? { - guard let layout = layoutManager, let container = textContainer else { return nil } - - let containerPoint = NSPoint( - x: viewPoint.x - textContainerOrigin.x, - y: viewPoint.y - textContainerOrigin.y - ) - var fraction: CGFloat = 0 - let charIndex = layout.characterIndex( - for: containerPoint, - in: container, - fractionOfDistanceBetweenInsertionPoints: &fraction - ) - guard charIndex < (string as NSString).length else { return nil } - - let glyphIndex = layout.glyphIndexForCharacter(at: charIndex) - let glyphRect = layout.boundingRect(forGlyphRange: NSRange(location: glyphIndex, length: 1), in: container) - guard glyphRect.contains(containerPoint) else { return nil } - - return textStorage?.attribute(.link, at: charIndex, effectiveRange: nil) as? URL - } - - // MARK: - Focus support - - private var focusObserver: Any? - - func installFocusObserver() { - guard focusObserver == nil else { return } - focusObserver = NotificationCenter.default.addObserver( - forName: .focusEditor, - object: nil, - queue: .main - ) { [weak self] _ in - guard let self, self.isEditable else { return } - preserveScrollOffset(for: self) { - self.window?.makeFirstResponder(self) - } - } - } - - private var saveCursorObserver: Any? - - func installSaveCursorObserver(appState: AppState) { - guard saveCursorObserver == nil else { return } - saveCursorObserver = NotificationCenter.default.addObserver( - forName: .saveCursorPosition, - object: nil, - queue: .main - ) { [weak self, weak appState] _ in - guard let self, self.isEditable, let appState else { return } - appState.pendingCursorRange = self.selectedRange() - appState.pendingScrollOffsetY = self.enclosingScrollView?.contentView.bounds.origin.y ?? 0 - } - } - - // MARK: - CMD-K observer - - private var commandKObserver: Any? - - func installCommandKObserver() { - guard commandKObserver == nil else { return } - commandKObserver = NotificationCenter.default.addObserver( - forName: .commandKPressed, - object: nil, - queue: .main - ) { [weak self] _ in - guard let self, self.isEditable else { - self?.onCommandPaletteFallback?() - return - } - let sel = self.selectedRange() - if sel.length > 0, - let selectedText = (self.string as NSString?)?.substring(with: sel), - !selectedText.isEmpty { - self.pendingWikilinkAlias = selectedText - self.pendingWikilinkSelectionRange = sel - self.onWikiLinkRequest?() - } else { - self.onCommandPaletteFallback?() - } - } - } - - // MARK: - Search highlight support - - private var searchObserver: Any? - private var searchClearObserver: Any? - private var replaceCurrentObserver: Any? - private var replaceAllObserver: Any? - private var lastSearchHighlightRanges: [NSRange] = [] - private var lastSearchFocusIndex: Int = -1 - /// The parsed block range whose markdown is currently revealed under the caret. - /// Used to skip re-revealing while the caret stays within one block. - private var lastRevealedBlockRange: NSRange? - - /// Clears the revealed-block gate so the next revealCurrentBlockMarkdownAtCursor() - /// recomputes. Used after a full re-hide sweep that invalidated the visible reveal. - func invalidateRevealedBlock() { - lastRevealedBlockRange = nil - } - - func installSearchObservers() { - guard searchObserver == nil else { return } - searchObserver = NotificationCenter.default.addObserver( - forName: .scrollToSearchMatch, - object: nil, - queue: .main - ) { [weak self] note in - guard let self, - let query = note.userInfo?[SearchMatchKey.query] as? String, - let focusIndex = note.userInfo?[SearchMatchKey.matchIndex] as? Int else { return } - self.applySearchHighlights(query: query, focusIndex: focusIndex) - } - searchClearObserver = NotificationCenter.default.addObserver( - forName: .clearSearchHighlights, - object: nil, - queue: .main - ) { [weak self] _ in - self?.clearSearchHighlights() - } - replaceCurrentObserver = NotificationCenter.default.addObserver( - forName: .replaceCurrentMatch, - object: nil, - queue: .main - ) { [weak self] note in - guard let self, self.participatesInGlobalSearch, self.isEditable, - let query = note.userInfo?[SearchMatchKey.query] as? String, - let focusIndex = note.userInfo?[SearchMatchKey.matchIndex] as? Int, - let replacement = note.userInfo?[SearchMatchKey.replacement] as? String else { return } - let advanceAfter = (note.userInfo?[SearchMatchKey.advanceAfter] as? Bool) ?? false - self.replaceCurrentMatch(query: query, focusIndex: focusIndex, replacement: replacement, advanceAfter: advanceAfter) - } - replaceAllObserver = NotificationCenter.default.addObserver( - forName: .replaceAllMatches, - object: nil, - queue: .main - ) { [weak self] note in - guard let self, self.participatesInGlobalSearch, self.isEditable, - let query = note.userInfo?[SearchMatchKey.query] as? String, - let replacement = note.userInfo?[SearchMatchKey.replacement] as? String else { return } - self.replaceAllMatches(query: query, replacement: replacement) - } - } - - private func applySearchHighlights(query: String, focusIndex: Int) { - guard let storage = textStorage, !query.isEmpty else { - clearSearchHighlights() - return - } - let content = storage.string - let needle = query.lowercased() - var matches: [NSRange] = [] - var searchStart = content.startIndex - while searchStart < content.endIndex, - let range = content.range(of: needle, options: .caseInsensitive, range: searchStart.. 2000 { break } - } - - let dimHighlight = NSColor.yellow.withAlphaComponent(0.30) - let focusHighlight = NSColor.yellow - storage.beginEditing() - let storageLength = storage.length - for range in lastSearchHighlightRanges { - // Ranges may be stale relative to the current storage (e.g. after an - // external edit). Skip any that no longer fit so removeAttribute can't - // throw NSRangeException and abort the rest of the highlight update. - guard NSMaxRange(range) <= storageLength else { continue } - storage.removeAttribute(.backgroundColor, range: range) - } - for (i, range) in matches.enumerated() { - if i == focusIndex { - storage.addAttribute(.backgroundColor, value: focusHighlight, range: range) - storage.addAttribute(.foregroundColor, value: NSColor.black, range: range) - } else { - storage.addAttribute(.backgroundColor, value: dimHighlight, range: range) - } - } - storage.endEditing() - lastSearchHighlightRanges = matches - lastSearchFocusIndex = focusIndex - - // Report match count back to SwiftUI - onMatchCountUpdate?(matches.count) - - // Scroll focused match into view (don't select — selection rendering overwrites highlight attributes) - if matches.indices.contains(focusIndex) { - scrollRangeToVisible(matches[focusIndex]) - } - } - - private func clearSearchHighlights() { - guard let storage = textStorage else { return } - storage.beginEditing() - let storageLength = storage.length - for range in lastSearchHighlightRanges { - guard NSMaxRange(range) <= storageLength else { continue } - storage.removeAttribute(.backgroundColor, range: range) - } - storage.endEditing() - lastSearchHighlightRanges = [] - lastSearchFocusIndex = -1 - applyMarkdownStyling() - } - - private func replaceCurrentMatch(query: String, focusIndex: Int, replacement: String, advanceAfter: Bool) { - guard !query.isEmpty, - lastSearchHighlightRanges.indices.contains(focusIndex) else { return } - let range = lastSearchHighlightRanges[focusIndex] - guard shouldChangeText(in: range, replacementString: replacement) else { return } - textStorage?.replaceCharacters(in: range, with: replacement) - didChangeText() - - // Recompute matches against new text. Anchor on the position of the replacement - // so the next focused match is the one that was after the replaced range. - let newCaret = range.location + (replacement as NSString).length - let newFocus: Int - if advanceAfter { - newFocus = nextMatchIndex(forQuery: query, after: newCaret) - } else { - newFocus = nextMatchIndex(forQuery: query, after: range.location) - } - applySearchHighlights(query: query, focusIndex: newFocus) - } - - private func replaceAllMatches(query: String, replacement: String) { - guard !query.isEmpty, let storage = textStorage else { return } - let content = storage.string - // Use Foundation's single-pass replace instead of collecting every match range. - // A note can contain millions of occurrences of a short query; materializing - // `[NSRange]` for each would spike memory and freeze the main thread. - let mutable = NSMutableString(string: content) - let initialSearchRange = NSRange(location: 0, length: (mutable as NSString).length) - let replacedCount = mutable.replaceOccurrences( - of: query, - with: replacement, - options: .caseInsensitive, - range: initialSearchRange - ) - let resultString = mutable as String - - guard replacedCount > 0 else { - applySearchHighlights(query: query, focusIndex: 0) - return - } - - let fullRange = NSRange(location: 0, length: storage.length) - guard shouldChangeText(in: fullRange, replacementString: resultString) else { return } - // Highlight ranges are about to be invalidated by the full-document replace. - // Drop them now so the debounced restyle (which re-applies highlights via - // reapplySearchHighlights) can't read out-of-bounds NSRanges and crash. - lastSearchHighlightRanges = [] - lastSearchFocusIndex = -1 - storage.replaceCharacters(in: fullRange, with: resultString) - didChangeText() - - applySearchHighlights(query: query, focusIndex: 0) - } - - /// Returns the index of the first match whose range starts at or after `location`, - /// wrapping to 0 if none. Recomputes matches against the live text storage. - private func nextMatchIndex(forQuery query: String, after location: Int) -> Int { - guard let storage = textStorage else { return 0 } - let content = storage.string - var matches: [NSRange] = [] - var searchStart = content.startIndex - while searchStart < content.endIndex, - let r = content.range(of: query, options: .caseInsensitive, range: searchStart.. 2000 { break } - } - if matches.isEmpty { return 0 } - if let idx = matches.firstIndex(where: { $0.location >= location }) { - return idx - } - return 0 - } - - private func reapplySearchHighlights() { - guard !lastSearchHighlightRanges.isEmpty, let storage = textStorage else { return } - let dimHighlight = NSColor.yellow.withAlphaComponent(0.30) - let focusHighlight = NSColor.yellow - storage.beginEditing() - let storageLength = storage.length - for (i, range) in lastSearchHighlightRanges.enumerated() { - guard NSMaxRange(range) <= storageLength else { continue } - if i == lastSearchFocusIndex { - storage.addAttribute(.backgroundColor, value: focusHighlight, range: range) - storage.addAttribute(.foregroundColor, value: NSColor.black, range: range) - } else { - storage.addAttribute(.backgroundColor, value: dimHighlight, range: range) - } - } - storage.endEditing() - } - - override func setFrameSize(_ newSize: NSSize) { - super.setFrameSize(newSize) - DispatchQueue.main.async { [weak self] in - self?.refreshInlineImagePreviews() - self?.refreshCollapsibleToggles() - self?.refreshCodeBlockCopyButtons() - self?.refreshAISparkle() - } - } - - // MARK: - Block indent / dedent - - private static let indentString = " " // 4 spaces - - /// Tab with a multi-line selection → indent every selected line. - /// Tab with a cursor or single-line selection → insert a literal tab (default). - override func insertTab(_ sender: Any?) { - let sel = selectedRange() - let nsText = string as NSString - - // Determine whether the selection spans more than one line. - let selText = sel.length > 0 ? nsText.substring(with: sel) : "" - let spansMultipleLines = selText.contains("\n") - - guard spansMultipleLines else { - super.insertTab(sender) - return - } - - indentSelectedLines(dedent: false) - } - - /// Shift-Tab: dedent every line touched by the selection. - /// Intercept via keyDown so we catch the Shift modifier. - private func indentSelectedLines(dedent: Bool) { - guard let storage = textStorage else { return } - let nsText = string as NSString - let sel = selectedRange() - - // Expand selection to cover full lines. - let linesRange = nsText.lineRange(for: sel) - - let linesText = nsText.substring(with: linesRange) - var lines = linesText.components(separatedBy: "\n") - - // The last component after the trailing newline is always an empty - // string artifact — keep it so we don't drop the terminating newline. - let indent = Self.indentString - - var newLines: [String] = [] - for (i, line) in lines.enumerated() { - // Don't modify the empty artifact at the end. - if i == lines.count - 1 && line.isEmpty { - newLines.append(line) - continue - } - if dedent { - if line.hasPrefix(indent) { - newLines.append(String(line.dropFirst(indent.count))) - } else if line.hasPrefix("\t") { - newLines.append(String(line.dropFirst(1))) - } else { - newLines.append(line) // nothing to dedent - } - } else { - newLines.append(indent + line) - } - } - - let newText = newLines.joined(separator: "\n") - if shouldChangeText(in: linesRange, replacementString: newText) { - storage.beginEditing() - storage.replaceCharacters(in: linesRange, with: newText) - storage.endEditing() - didChangeText() - - // Restore a selection that covers the same lines. - let newLinesRange = NSRange(location: linesRange.location, length: (newText as NSString).length) - setSelectedRange(newLinesRange) - } - } - - override func insertNewline(_ sender: Any?) { - // Preserve the leading whitespace of the current line on the new line, - // and continue bullet lists (- or *) automatically. - let cursor = selectedRange().location - let nsText = string as NSString - guard cursor != NSNotFound else { super.insertNewline(sender); return } - - // Find the start of the current line. - let lineRange = nsText.lineRange(for: NSRange(location: cursor, length: 0)) - let lineText = nsText.substring(with: lineRange) - .replacingOccurrences(of: "\n", with: "") - .replacingOccurrences(of: "\r", with: "") - - // Measure leading whitespace. - var indentEnd = lineText.startIndex - for ch in lineText { - if ch == " " || ch == "\t" { indentEnd = lineText.index(after: indentEnd) } - else { break } - } - let indent = String(lineText[lineText.startIndex..