diff --git a/macOS/Synapse Notes.xcodeproj/project.pbxproj b/macOS/Synapse Notes.xcodeproj/project.pbxproj index 746316e..bcc04ee 100644 --- a/macOS/Synapse Notes.xcodeproj/project.pbxproj +++ b/macOS/Synapse Notes.xcodeproj/project.pbxproj @@ -19,11 +19,13 @@ 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 */; }; 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 */; }; @@ -74,9 +76,12 @@ 44F91CA1A622A0AED48E1736 /* MarkdownDocumentParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A39EFDD3D11DE137614C99D /* MarkdownDocumentParserTests.swift */; }; 456AAB45258170DD5A5FAA74 /* MarkdownEditorRefreshPlan.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF12B4101FE25EBB9EDC841 /* MarkdownEditorRefreshPlan.swift */; }; 462D7CE1E359BC20BB56A07D /* AppStateTagsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75921D668BB1074B9FC3C669 /* AppStateTagsTests.swift */; }; + 468A96A562C82EE831025632 /* MarkdownPreviewRevealMemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 445886016E191A8DE6507C0A /* MarkdownPreviewRevealMemo.swift */; }; 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 */; }; + 487F40967B33106E7547D9F6 /* IncrementalIndexUpdateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F25D8DD3669BC18323177AF /* IncrementalIndexUpdateTests.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 */; }; @@ -99,6 +104,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 */; }; @@ -151,6 +157,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 */; }; @@ -175,9 +182,11 @@ 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 */; }; + B19CA3A0279735694D36E287 /* MarkdownPreviewRevealMemoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24C76F568E5F04C388350B1C /* MarkdownPreviewRevealMemoTests.swift */; }; B282BF72BD8B1103C5BE4BEC /* SparkleUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48827C67F8BE171105280724 /* SparkleUpdater.swift */; }; B4A4597E47E816602F915748 /* SidebarNotePaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14806617EB6E15A4C2739160 /* SidebarNotePaneView.swift */; }; B59DEDF11939100850266EAD /* AppStateSearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2E1F80ED8D1F091735B2F4E /* AppStateSearchTests.swift */; }; @@ -192,12 +201,15 @@ 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 */; }; CC260DA9055067DAF97068CA /* InlineAIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B54D87558101D4D0356C986 /* InlineAIView.swift */; }; CD9FE8D4C38BFEAED26099A4 /* FlatFolderNavigatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0C5541C2A91D24172ABE7B /* FlatFolderNavigatorTests.swift */; }; @@ -216,6 +228,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 */; }; @@ -244,6 +257,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 */; }; @@ -251,6 +265,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 */; }; @@ -275,6 +291,8 @@ 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 = ""; }; 110835FD9934C200B95DBEB3 /* RelatedLinksTitleText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelatedLinksTitleText.swift; sourceTree = ""; }; @@ -289,20 +307,24 @@ 1DCBA2DD744D17B8A93FD3D6 /* FileTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTreeView.swift; sourceTree = ""; }; 1DF12B4101FE25EBB9EDC841 /* MarkdownEditorRefreshPlan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownEditorRefreshPlan.swift; sourceTree = ""; }; 1E5C67C5665B44BEA671BAF9 /* AppStateUnsavedChangesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateUnsavedChangesTests.swift; sourceTree = ""; }; + 1F25D8DD3669BC18323177AF /* IncrementalIndexUpdateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncrementalIndexUpdateTests.swift; sourceTree = ""; }; 1F8CCC8DE0551D8C09D24609 /* MarkdownTablePrettifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownTablePrettifier.swift; sourceTree = ""; }; 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 = ""; }; 24393FE4F4F016F8BB91C453 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; + 24C76F568E5F04C388350B1C /* MarkdownPreviewRevealMemoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownPreviewRevealMemoTests.swift; sourceTree = ""; }; 24C81DC01DE3F2CA6D1DC3F8 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; 258F39D57BAE2B1D4DC8F99C /* AppStateOpenFolderDirtyFileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateOpenFolderDirtyFileTests.swift; sourceTree = ""; }; 26CB9DF00BA64A1F3AEB042E /* PullAndRefreshWIPAutoSaveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PullAndRefreshWIPAutoSaveTests.swift; sourceTree = ""; }; 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 = ""; }; @@ -311,6 +333,7 @@ 41A855494502914F7C204B02 /* CollapsibleSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleSection.swift; sourceTree = ""; }; 41C69F6E45C0225C634AA462 /* AppStateDateFilteringTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateDateFilteringTests.swift; sourceTree = ""; }; 43C17DE1D0FB3FA44308006D /* AppStateExitVaultFullTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateExitVaultFullTests.swift; sourceTree = ""; }; + 445886016E191A8DE6507C0A /* MarkdownPreviewRevealMemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownPreviewRevealMemo.swift; sourceTree = ""; }; 44E32CB13ABBBC6C7CE42178 /* SettingsManagerSidebarArrayEqualityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManagerSidebarArrayEqualityTests.swift; sourceTree = ""; }; 4527D55AB64AAD4B9C095474 /* TabItemTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabItemTests.swift; sourceTree = ""; }; 45941B55E87CD77FB7F4318F /* AppStateTemplatesDirNormalizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateTemplatesDirNormalizationTests.swift; sourceTree = ""; }; @@ -355,6 +378,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 = ""; }; @@ -379,6 +403,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 = ""; }; @@ -429,6 +454,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 = ""; }; @@ -441,6 +467,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 = ""; }; @@ -469,11 +496,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 = ""; }; @@ -489,6 +518,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 = ""; }; @@ -504,6 +534,8 @@ 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 = ""; }; + 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 = ""; }; @@ -554,12 +586,14 @@ 5EEAC9EFE313BC2825BCA71C /* AppStateFolderOperationsTests.swift */, 5DE524E56068B2DF3B0C556A /* AppStateGitDateCacheMergeTests.swift */, D2EC80568C4675330252028C /* AppStateGitDateFilteringTests.swift */, + F2A7E7997B38846604684C1E /* AppStateGitErrorSurfacingTests.swift */, 8F40452ED1B79CCE93DC83ED /* AppStateGitGuardTests.swift */, DFE158B5BC42A50F28250D5C /* AppStateGraphTabTests.swift */, 82590C6BF6E66678171DF8DE /* AppStateGraphTests.swift */, 5060FDAEE02A38EAF4A158AA /* AppStateInactivePaneTests.swift */, 2788169302A62D1CE3240B96 /* AppStateNavigationTests.swift */, 96156DD8690A26583B712F0C /* AppStateNewNoteFlowTests.swift */, + AD46E5B1198C3929CE50A1CE /* AppStateObservationSplitTests.swift */, 258F39D57BAE2B1D4DC8F99C /* AppStateOpenFolderDirtyFileTests.swift */, F638E6858D9C03A15E702690 /* AppStatePendingSearchQueryTests.swift */, 7B55AA96B17F979E4F856BA0 /* AppStatePinnedFolderFocusTests.swift */, @@ -570,6 +604,7 @@ 241B9481E6C74B5E5EEBAB08 /* AppStateSaveTests.swift */, A2E1F80ED8D1F091735B2F4E /* AppStateSearchTests.swift */, 7DAAF8FF8C6EFD3D4FCA4AB9 /* AppStateSettingsPropagationTests.swift */, + 0D7FB547E67BCAD2587FF1D9 /* AppStateSetupGitAsyncTests.swift */, F9DA552722A4F7DEA5F6D66F /* AppStateSplitPaneTests.swift */, B908F451DF8902DE532C1840 /* AppStateSyncToRemoteTests.swift */, 99E86DAF95F24BAB761C81A8 /* AppStateTabsTests.swift */, @@ -629,11 +664,13 @@ 987E1B649BAA6DBF8B062EE1 /* HTMLToMarkdownTests.swift */, 9DE5954CBD5DB19F51E67763 /* ImagePasteTests.swift */, BB6D950DAA29D4324BD4054E /* ImageSidebarEmbedTests.swift */, + 1F25D8DD3669BC18323177AF /* IncrementalIndexUpdateTests.swift */, E889CD671CA99F099DFF13C7 /* InlineAIControllerTests.swift */, 6ECAC2D5AC63076FFAE6E1CF /* InlineTagClickTests.swift */, 5A11E3B1BEF42A7AA9EAAE50 /* InlineTagStylingTests.swift */, A84218FCBC7806F50E6BF682 /* KeychainStoreTests.swift */, 06B93FD12194D192DEFA2411 /* KeyCodeTests.swift */, + E53727C83ADDA52CAE690AA7 /* KeystrokeRenderCountTests.swift */, 83546378C054CB0E3E4999BE /* ListContinuationTests.swift */, A9182F671271FBBF2DE43D1E /* MarkdownCalloutDetectorTests.swift */, 56EEC9EFE247488B9CFFCDED /* MarkdownCalloutStructTests.swift */, @@ -646,10 +683,12 @@ 23FF4B814CF36CBFA3E891F9 /* MarkdownPreviewCSSTests.swift */, 4D03FB34D810A5ADA67402CD /* MarkdownPreviewCursorRevealTests.swift */, 3B5764CC787B08BA6E1D64B4 /* MarkdownPreviewRendererTests.swift */, + 24C76F568E5F04C388350B1C /* MarkdownPreviewRevealMemoTests.swift */, ACD56E61128AEB9396A73181 /* MarkdownPreviewSemanticHidingTests.swift */, 9B3B5E5BEF7F68CFB75BE4AC /* MarkdownTablePrettifierTests.swift */, A61F2346098B568FB3D701E1 /* MarkdownTaskCheckboxHitTests.swift */, E16925836C0D8191A7B15229 /* MarkdownTaskCheckboxMatchesTests.swift */, + 2D809B3131FC64DC7BF49CC6 /* MarkdownTaskCheckboxToggleMarkerTests.swift */, BB009F631091F357BB77945E /* MiniBrowserURLNormalizerTests.swift */, 6DEF85FC5E955D1C3AB40872 /* NavigationStateActivePaneTests.swift */, EFEEF6B9568D25C4A2B615D5 /* NavigationStateTests.swift */, @@ -736,10 +775,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 */, @@ -753,10 +794,14 @@ 7C9A395C004CF88B96255AB4 /* GitService.swift */, 5F447EE62D897ADFD26A3A2D /* GlobalGraphView.swift */, B50320B1919639905251EA20 /* GraphPaneView.swift */, + CCBC3A64E0A563CA932CFD31 /* HTMLToMarkdownConverter.swift */, 658EA3321387C8DF857E0932 /* Info.plist */, 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 */, @@ -766,9 +811,12 @@ 20765639CD137EFA64E5F828 /* MarkdownPreviewCSS.swift */, 822CD34D3501462C7E54EE9C /* MarkdownPreviewCursorReveal.swift */, F9C95EF39655CB3BACA6E413 /* MarkdownPreviewRenderer.swift */, + 445886016E191A8DE6507C0A /* MarkdownPreviewRevealMemo.swift */, 74BC44457745DCE1B0D81D45 /* MarkdownPreviewSemanticHiding.swift */, + F58C010C3BC248AEA35CE843 /* MarkdownPreviewView.swift */, 1F8CCC8DE0551D8C09D24609 /* MarkdownTablePrettifier.swift */, 899E05E166DE4C34A16EF3A1 /* MarkdownTaskCheckboxInteraction.swift */, + 6995B26D21E06099F1CC8B71 /* MarkdownTheme.swift */, C5EB1051089CA28E0244725C /* MiniBrowserPaneView.swift */, A08CB0284C3C5D4EE5C720C7 /* MiniBrowserURLNormalizer.swift */, CC9A2CCFC8D76F6FA9B12305 /* NavigationState.swift */, @@ -861,6 +909,10 @@ BuildIndependentTargetsInParallel = YES; LastUpgradeCheck = 1600; TargetAttributes = { + E540C16C643A385615A9A437 = { + DevelopmentTeam = 299R8V27FZ; + ProvisioningStyle = Manual; + }; }; }; buildConfigurationList = FC17E84115FED15B8E080022 /* Build configuration list for PBXProject "Synapse Notes" */; @@ -916,10 +968,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 */, @@ -933,9 +987,13 @@ 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 */, + 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 */, @@ -945,9 +1003,12 @@ 991801887FAA387CAAC84D8F /* MarkdownPreviewCSS.swift in Sources */, 3CD1E9CA022A6228FB48D629 /* MarkdownPreviewCursorReveal.swift in Sources */, 33CA9313B45341731D44A4E8 /* MarkdownPreviewRenderer.swift in Sources */, + 468A96A562C82EE831025632 /* MarkdownPreviewRevealMemo.swift in Sources */, AAC297094394859DB2403374 /* MarkdownPreviewSemanticHiding.swift in Sources */, + 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 */, @@ -1000,12 +1061,14 @@ 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 */, 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 */, @@ -1016,6 +1079,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 */, @@ -1075,11 +1139,13 @@ AC5C25866472910EC05AC2A0 /* HTMLToMarkdownTests.swift in Sources */, 8CA248B41EEFBB966526F952 /* ImagePasteTests.swift in Sources */, 53F221847CDB4F68E669AF75 /* ImageSidebarEmbedTests.swift in Sources */, + 487F40967B33106E7547D9F6 /* IncrementalIndexUpdateTests.swift in Sources */, 5B07DDBDCE0FAF1375C70C49 /* InlineAIControllerTests.swift in Sources */, 3F40FA004E127110B79AEBE2 /* InlineTagClickTests.swift in Sources */, 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 */, @@ -1092,10 +1158,12 @@ 4B3CF3FB9620D4DD9E2E82E2 /* MarkdownPreviewCSSTests.swift in Sources */, B7F0D4D43C05C377E2E372BB /* MarkdownPreviewCursorRevealTests.swift in Sources */, 1C5F0E08CD3414F6CAC10635 /* MarkdownPreviewRendererTests.swift in Sources */, + B19CA3A0279735694D36E287 /* MarkdownPreviewRevealMemoTests.swift in Sources */, 8A02FEE908D44FB265C897EC /* MarkdownPreviewSemanticHidingTests.swift in Sources */, 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 */, @@ -1252,7 +1320,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)", @@ -1335,7 +1406,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/AppState.swift b/macOS/SynapseNotes/AppState.swift index 438601a..e9b2c0b 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 @@ -301,8 +335,14 @@ class AppState: ObservableObject { /// is only applied when the counter still matches, discarding stale scans. /// `exitVault()` also increments this so in-flight async scans cannot commit after close. private(set) var scanGeneration: Int = 0 + /// Number of full vault-tree scans started. Test probe (Issue #260): asserts the + /// incremental FSEvents path services pure content modifications without re-enumerating. + private(set) var fullScanCount: Int = 0 /// Pending debounce work item for the DispatchSource file watcher. private var scanDebounceWorkItem: DispatchWorkItem? + /// Event paths accumulated across debounced FSEvents bursts (main-thread only). + /// A cancelled debounce must not drop paths the incremental indexer needs to see. + private var pendingEventPaths: [String] = [] // MARK: - Content Cache (Issue #144) /// Per-file content cache keyed by standardized file URL. @@ -388,12 +428,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 @@ -410,7 +449,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(), @@ -605,32 +644,109 @@ class AppState: ObservableObject { DispatchQueue.main.async { [weak self] in guard let self else { return } + // Accumulate paths so a cancelled debounce never drops changes the + // incremental indexer needs to see (Issue #260). + self.pendingEventPaths.append(contentsOf: paths) + // Debounce any additional bursts arriving in quick succession. self.scanDebounceWorkItem?.cancel() let work = DispatchWorkItem { [weak self] in guard let self else { return } - self.rebuildFileLists(reloadSettings: false) - // Only reload the selected file if one of the changed paths matches it. - if let selectedPath = self.selectedFile?.path, - paths.contains(selectedPath), - case .pulling = self.gitSyncStatus { return } - if let selectedPath = self.selectedFile?.path, - paths.contains(selectedPath) { - self.reloadSelectedFileFromDiskIfNeeded(force: true) - } else { - // Even if the exact path wasn't in the event list, check for - // modification-date changes (handles atomic-write style saves). - self.reloadSelectedFileFromDiskIfNeeded() - } + let batch = self.pendingEventPaths + self.pendingEventPaths = [] + self.processVaultEvents(paths: batch) } self.scanDebounceWorkItem = work DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: work) } } + /// Processes a debounced batch of FSEvents paths on the main thread. + /// + /// Fast path (Issue #260): when every changed path is an already-indexed note that + /// still exists on disk (a pure content modification — the dominant case: note saves), + /// only those cache entries are re-parsed. No tree enumeration, no `git ls-files`. + /// Anything ambiguous (creations, deletions, renames, directories, non-indexed files, + /// ignore-rule files, oversized batches) falls back to the full rescan, which also + /// drops the gitignore cache because structural changes can alter the ignored set + /// (e.g. a freshly created `node_modules`). Internal for testing. + func processVaultEvents(paths: [String]) { + if !applyIncrementalIndexUpdate(forEventPaths: paths) { + invalidateIgnoredDirectoriesCache() + rebuildFileLists(reloadSettings: false) + } + + // Only reload the selected file if one of the changed paths matches it. + if let selectedPath = selectedFile?.path, + paths.contains(selectedPath), + case .pulling = gitSyncStatus { return } + if let selectedPath = selectedFile?.path, + paths.contains(selectedPath) { + reloadSelectedFileFromDiskIfNeeded(force: true) + } else { + // Even if the exact path wasn't in the event list, check for + // modification-date changes (handles atomic-write style saves). + reloadSelectedFileFromDiskIfNeeded() + } + } + + /// Upper bound on event paths handled incrementally. Larger bursts (git checkout, + /// mass scripted edits) are cheaper as one full rescan than many main-thread reads. + private static let maxIncrementalEventPaths = 64 + + /// Attempts to service an FSEvents batch by re-indexing only the changed files. + /// Returns false when a full rescan is required instead. + /// + /// Generation safety: this runs synchronously on the main thread and only when no + /// index pass is in flight (`isIndexing == false`), so it cannot race a + /// `rebuildFullCache` commit. If a scan's enumeration phase is in flight, its index + /// pass starts *after* this update commits and re-reads every file from disk, so + /// fresher data always wins — stale data is never resurrected. + private func applyIncrementalIndexUpdate(forEventPaths paths: [String]) -> Bool { + guard let root = rootURL else { return false } + // A full index pass is in flight — the cache may be incomplete or about to be + // replaced wholesale; fall back so the generation counters arbitrate as before. + guard !isIndexing, !noteContentCache.isEmpty else { return false } + guard paths.count <= Self.maxIncrementalEventPaths else { return false } + + let fm = FileManager.default + var changedNotes: Set = [] + for path in paths { + // Touching ignore rules must re-run `git ls-files`, never the fast path. + if path.hasSuffix("/.gitignore") || path.hasSuffix("/.git/info/exclude") { + return false + } + // Unknown path: a new file, directory, attachment, or `.git` internals. + guard let url = cachedNoteURL(forEventPath: path) else { return false } + var isDirectory: ObjCBool = false + // Deleted (or replaced by a directory) — the file list changed. + guard fm.fileExists(atPath: url.path, isDirectory: &isDirectory), + !isDirectory.boolValue else { return false } + guard isWithin(url, parentURL: root) else { return false } + changedNotes.insert(url) + } + guard !changedNotes.isEmpty else { return false } + + updateCacheIncrementally(for: Array(changedNotes)) + lastContentChange = UUID() + return true + } + + /// Maps a raw FSEvents path to its `noteContentCache` key, or nil when the path is + /// not an already-indexed note. FSEvents reports resolved real paths (`/private/var/…`), + /// so the symlink-resolved form is also tried before giving up. + private func cachedNoteURL(forEventPath path: String) -> URL? { + let url = URL(fileURLWithPath: path).standardizedFileURL + if noteContentCache[url] != nil { return url } + let resolved = url.resolvingSymlinksInPath() + if noteContentCache[resolved] != nil { return resolved } + return nil + } + private func stopWatching() { scanDebounceWorkItem?.cancel() scanDebounceWorkItem = nil + pendingEventPaths = [] if let stream = vaultEventStream { FSEventStreamStop(stream) @@ -1692,6 +1808,7 @@ class AppState: ObservableObject { // Clear all files allFiles = [] allProjectFiles = [] + invalidateIgnoredDirectoriesCache() noteContentCache = [:] cachedTagCounts = [:] cachedBacklinks = [:] @@ -1741,12 +1858,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() @@ -1798,10 +1930,44 @@ 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, git.hasRemote() else { return } - guard case .idle = gitSyncStatus else { return } - gitSyncStatus = .pulling + guard let git = gitService 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. + 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 self.canStartGitSync 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 { @@ -1822,7 +1988,7 @@ class AppState: ObservableObject { self.gitSyncStatus = .idle } } catch { - DispatchQueue.main.async { self.gitSyncStatus = .idle } + DispatchQueue.main.async { self.gitSyncStatus = .error(error.localizedDescription) } } } } @@ -1942,7 +2108,7 @@ class AppState: ObservableObject { return } - guard case .idle = gitSyncStatus else { + guard canStartGitSync else { // Already syncing — just refresh the file list refreshAllFiles() return @@ -1952,13 +2118,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 @@ -1991,7 +2166,7 @@ class AppState: ObservableObject { } } catch { DispatchQueue.main.async { - self.gitSyncStatus = .idle + self.gitSyncStatus = .error(error.localizedDescription) self.refreshAllFiles() } } @@ -2023,6 +2198,7 @@ class AppState: ObservableObject { scanGeneration += 1 let generation = scanGeneration + fullScanCount += 1 // Snapshot settings values so the background thread never touches SettingsManager. let respectGitignore = settings.respectGitignore @@ -2038,10 +2214,11 @@ class AppState: ObservableObject { let fm = FileManager.default // Build a set of gitignore-excluded directory paths (via git ls-files). - // This is a single process spawn rather than one per directory. + // The result is cached across scans and only re-fetched when the ignore + // inputs change (Issue #260) — see `cachedIgnoredDirectories`. var ignoredDirectories: Set = [] if respectGitignore, GitService.isGitRepo(at: root), let gitPath = GitService.findGit() { - ignoredDirectories = Self.fetchIgnoredDirectories(gitPath: gitPath, repoRoot: root) + ignoredDirectories = self.cachedIgnoredDirectories(gitPath: gitPath, repoRoot: root) } guard let enumerator = fm.enumerator( @@ -2202,6 +2379,73 @@ class AppState: ObservableObject { } } + // MARK: - Gitignore Cache (Issue #260) + + /// Cached `git ls-files --ignored` output plus the inputs that produced it. + /// Spawning git on every vault scan is wasteful (can be 200–500 ms on complex + /// repos); the ignored-directory set only changes when ignore rules or the + /// worktree's untracked structure change. + /// + /// Invalidation rules: + /// - vault root changed (`rootPath` mismatch) or vault exited + /// - `/.gitignore` or `/.git/info/exclude` mtime / existence changed + /// - any FSEvents batch that falls back to a full rescan (`processVaultEvents`): + /// structural changes can create new ignored directories (e.g. a fresh + /// `node_modules`), and nested `.gitignore` edits/creations always take that path + private struct IgnoredDirectoriesCache { + let rootPath: String + let gitignoreModified: Date? + let excludeModified: Date? + let directories: Set + } + /// Guarded by `ignoredDirectoriesLock`: read/written on `scanQueue` (main under + /// tests), invalidated from the main thread. + private var ignoredDirectoriesCacheStorage: IgnoredDirectoriesCache? + private let ignoredDirectoriesLock = NSLock() + /// Number of times `git ls-files` was actually spawned. Test probe (Issue #260). + private(set) var ignoredDirectoriesFetchCount: Int = 0 + + /// Returns the gitignore-excluded directory set for `repoRoot`, consulting the cache + /// first. Runs on `scanQueue` in production; the lock only synchronises against + /// `invalidateIgnoredDirectoriesCache` on the main thread, and is never held while + /// git runs so invalidation can't block the UI. + private func cachedIgnoredDirectories(gitPath: String, repoRoot: URL) -> Set { + let rootPath = repoRoot.standardizedFileURL.path + let gitignoreModified = fileModificationDate(for: repoRoot.appendingPathComponent(".gitignore")) + let excludeModified = fileModificationDate(for: repoRoot.appendingPathComponent(".git/info/exclude")) + + ignoredDirectoriesLock.lock() + let cached = ignoredDirectoriesCacheStorage + ignoredDirectoriesLock.unlock() + + if let cached, + cached.rootPath == rootPath, + cached.gitignoreModified == gitignoreModified, + cached.excludeModified == excludeModified { + return cached.directories + } + + let directories = Self.fetchIgnoredDirectories(gitPath: gitPath, repoRoot: repoRoot) + + ignoredDirectoriesLock.lock() + ignoredDirectoriesFetchCount += 1 + ignoredDirectoriesCacheStorage = IgnoredDirectoriesCache( + rootPath: rootPath, + gitignoreModified: gitignoreModified, + excludeModified: excludeModified, + directories: directories + ) + ignoredDirectoriesLock.unlock() + return directories + } + + /// Drops the cached ignored-directory set so the next scan re-runs `git ls-files`. + func invalidateIgnoredDirectoriesCache() { + ignoredDirectoriesLock.lock() + ignoredDirectoriesCacheStorage = nil + ignoredDirectoriesLock.unlock() + } + /// Runs `git ls-files --others --ignored --exclude-standard --directory` in the given /// repo to obtain the set of all ignored directory paths. Returns a `Set` of /// absolute paths (with trailing slash) that should be skipped during enumeration. @@ -3115,12 +3359,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/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/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/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/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 36d6403..8c5f36c 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 } @@ -244,15 +235,12 @@ 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 - appState.isDirty = true + guard let toggled = MarkdownTaskCheckboxInteraction.togglingMarker( + in: displayContent, + atUTF16Offset: offset + ) else { return } + activeTextBinding.wrappedValue = toggled + editorState.isDirty = true } ) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -336,10 +324,12 @@ 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 { - appState.isDirty = true + if !editableIsDirty.wrappedValue { editableIsDirty.wrappedValue = true } + } else if !editorState.isDirty { + editorState.isDirty = true } } @@ -493,8 +483,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 @@ -699,6 +689,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) } @@ -903,18 +896,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)) @@ -932,7 +925,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, @@ -1018,6 +1011,11 @@ struct RawEditor: NSViewRepresentable { } func textStorage(_ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorageEditActions, range editedRange: NSRange, changeInLength delta: Int) { + if editedMask.contains(.editedCharacters) { + // Character edits invalidate the caret-move reveal memo even while the + // binding sync is suppressed (programmatic replaces still reflow blocks). + textView?.previewRevealMemo.noteTextChanged() + } guard !suppressSync, editedMask.contains(.editedCharacters) else { return } guard let tv = textView else { return } let oldText = parent.text @@ -1030,7 +1028,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 } } @@ -1186,147 +1186,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 @@ -1339,4788 +1199,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..