diff --git a/macOS/Synapse.xcodeproj/project.pbxproj b/macOS/Synapse.xcodeproj/project.pbxproj index 7c4808d..c84281e 100644 --- a/macOS/Synapse.xcodeproj/project.pbxproj +++ b/macOS/Synapse.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ 149B715AD9DF9829286F386F /* GistPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88EA4153C25D088562FAC1C /* GistPublisher.swift */; }; 1626A8B12BFDAD5B528B6ECA /* SidebarDragDropTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D6B6923B1814AF9178F7B51 /* SidebarDragDropTests.swift */; }; 168BD2D7D140EB2F1BC65CBC /* VaultIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49A381F0D18E754A88B51A80 /* VaultIndex.swift */; }; + 18CAB55CD090014DDDE479CD /* UpdateBannerCopy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CBFDA068907D8B52C49332F /* UpdateBannerCopy.swift */; }; 1A911A8A04B7386F14EFC000 /* DatePageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B15898506DA642E05E2E0704 /* DatePageView.swift */; }; 1B8470063FF3F149A02D581B /* AppConstantsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD119E93048F3C571E60BD8A /* AppConstantsTests.swift */; }; 1BC0B2F0938220AACB87EEE5 /* AppStateTagTabsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A798505F66B8166624E3427B /* AppStateTagTabsTests.swift */; }; @@ -35,6 +36,7 @@ 259FADF1F7F47C7A4E83239F /* GitServiceConflictsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033EE7836DA14707E8F3DED7 /* GitServiceConflictsTests.swift */; }; 262F3A286213866E6B358E86 /* SyntaxHighlighterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DC46BE38C3A41CFB43FA338 /* SyntaxHighlighterTests.swift */; }; 26E5832E8B9FBDFE5A7ABC95 /* SaveButtonVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5D92FC21925B030CA71BEB4 /* SaveButtonVisibilityTests.swift */; }; + 26FE69D731F89E553CA8BA7D /* VaultRootResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B2003A2EAD1D02026B4441 /* VaultRootResolver.swift */; }; 27A417FEB8C3977F970C53BD /* SettingsManagerGitHubPATTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12936BAA078D86D1DC91F861 /* SettingsManagerGitHubPATTests.swift */; }; 27E128B4A212C4DD0825DB04 /* AppThemeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9E0B2A72DE74833458BEA9E /* AppThemeTests.swift */; }; 299185049C56A7F7C6A8EB93 /* RelatedLinksPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62C26BB5AD5C76352C46111E /* RelatedLinksPaneView.swift */; }; @@ -82,8 +84,10 @@ 59A6F7BB258D1FF977C53B25 /* CommandPaletteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29CA9E8A87139F33100BCC83 /* CommandPaletteView.swift */; }; 5A17B54B9E5169462B921F4B /* FontEnumerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B8349DC1AB30D81F11E1404 /* FontEnumerator.swift */; }; 5AA144EEAA57D266BAC41315 /* PullAndRefreshWIPAutoSaveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53D9D2ED28ABE373443038FE /* PullAndRefreshWIPAutoSaveTests.swift */; }; + 5BCAD7EC3ED27CF026D15715 /* UpdateBannerCopyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B68A3971BA893EE67B78D53 /* UpdateBannerCopyTests.swift */; }; 5FCFF21AE73D6C392F4BAB2D /* MarkdownPreviewSemanticHiding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01A4BCE46B415D8AD66B87BB /* MarkdownPreviewSemanticHiding.swift */; }; 5FDEADDD3F6DC4FE1AB1466D /* AppStateGraphTabTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E187A6A841FAE77D9C9440A /* AppStateGraphTabTests.swift */; }; + 5FEDFF1E01A54B9725A648ED /* CloneRepositoryValidationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FE1039711DA9FA8B1DD3A3D /* CloneRepositoryValidationTests.swift */; }; 6044649992300375F1BDB874 /* AppStateGraphTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBF1B8B03D6E5299A4700177 /* AppStateGraphTests.swift */; }; 61F38C954D4BD659595F04D8 /* AppStateFileOperationsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2819956191CAC51CF76DAFA /* AppStateFileOperationsTests.swift */; }; 63ED7B40721FC00C49571D34 /* SplitPaneEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7603E5241D305A3BAE781451 /* SplitPaneEditorView.swift */; }; @@ -104,9 +108,12 @@ 7715A2594C966414407B38C9 /* SettingsPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37535D804D7598740D99FEDD /* SettingsPersistenceTests.swift */; }; 77C6E256FA654823D534EE68 /* NewNoteFolderPickerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7084D8A137CDD236F0B90C0 /* NewNoteFolderPickerTests.swift */; }; 7841C9E4C60EE935FA8109EF /* AppStateSyncToRemoteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CC853F6E8FEBD07D8F6509D /* AppStateSyncToRemoteTests.swift */; }; + 7A613FF66AF9AAFEC1AFFEC5 /* FileSearchResultTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BFB379F8935A6440EEEE25F /* FileSearchResultTests.swift */; }; + 7A91335AEF118317EAFB1093 /* VaultRootResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CF1CF1FCB641683D887AC3F /* VaultRootResolverTests.swift */; }; 7A972695B65AFE2D1DA7A523 /* SettingsManagerBareExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 380760DBBA1A62E71C1C9D15 /* SettingsManagerBareExtensionsTests.swift */; }; 7B87ED4DFA4B6DE92EE7B6B9 /* MarkdownTablePrettifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E077B978A164674094EE6435 /* MarkdownTablePrettifierTests.swift */; }; 7FA55FF63CB1CC6E03BEEBCE /* AppStateWikiLinkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF0502C0261847F74C47B810 /* AppStateWikiLinkTests.swift */; }; + 84BB4A4A3072E7D1C3466CB0 /* MiniBrowserURLNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA5D5FBCC522E3211A1BDF01 /* MiniBrowserURLNormalizer.swift */; }; 84CE3D3A60AFCABF88D62FFC /* AppStateSearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35A600692EB7955AD6BF962D /* AppStateSearchTests.swift */; }; 855536CCA2FBCEEFBA605D30 /* WikiLinkClickTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCDF9EB2EC353844301E6106 /* WikiLinkClickTests.swift */; }; 86E0161BF2CD685ACD548296 /* SynapseThemeLayoutConstantsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACDCB904B2D9DC31F370D424 /* SynapseThemeLayoutConstantsTests.swift */; }; @@ -116,6 +123,7 @@ 895F0B858ADA0B50766DCA00 /* AsyncFileScanTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB767AA001E28102BB91278 /* AsyncFileScanTests.swift */; }; 897A06A0A6C817BE9AAAAA03 /* AppStateCommandPaletteGuardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 147AFC9C373247489F744397 /* AppStateCommandPaletteGuardTests.swift */; }; 899061DD86BA4E4A6719C95B /* AppStateNavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6076EE60ED15C4BA9011BE7 /* AppStateNavigationTests.swift */; }; + 89CEEB93F78960EDA6799F19 /* CloneRepositoryValidation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09F6E8F7026EA35EF8C3D4A0 /* CloneRepositoryValidation.swift */; }; 89E3EA85538AA25FAAB34501 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = C4C7BBA308D786C6CE2F1328 /* SwiftTerm */; }; 8AC37ADAD3179D24D35DBAB1 /* CalendarPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89BAED87C130E2C30AE200FD /* CalendarPaneView.swift */; }; 8B53A015F2ED2112F0131B2B /* GitErrorHostnameExtractionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C248544EA6817276B7203C84 /* GitErrorHostnameExtractionTests.swift */; }; @@ -145,6 +153,7 @@ 9EC02229A8FFE70905BE9465 /* CalendarDayActivityCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 556021F9F9711240A933E0D0 /* CalendarDayActivityCalculator.swift */; }; A033139B390220EE576027A0 /* AppStateCurrentDirectoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A59692ED432DF0BD26FADE0 /* AppStateCurrentDirectoryTests.swift */; }; A0988EBDF1218D4D58E2B842 /* FontEnumeratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89A37B5687F020E8B15BBA70 /* FontEnumeratorTests.swift */; }; + A1AF763BE77049159AC0EAB2 /* FlatFolderNavigatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B57F6CF48999648AB4119F7 /* FlatFolderNavigatorTests.swift */; }; A3005E593A077EC5A3F1A612 /* MarkdownTaskCheckboxInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6300BFA84F1EDAD32147A4E2 /* MarkdownTaskCheckboxInteraction.swift */; }; A38F90B99AB5AA539DC7F82D /* MarkdownCalloutStructTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 440CE64110DAE1213527960D /* MarkdownCalloutStructTests.swift */; }; A4D708D3B7584CFD4A9C4ADD /* SettingsManagerNotePaneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFD958AFEB9C0730DDEB54AD /* SettingsManagerNotePaneTests.swift */; }; @@ -176,6 +185,7 @@ C38FA2CB562C74DF68451A15 /* SidebarAutoCollapseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F0D39A7D2927674CD66BA12 /* SidebarAutoCollapseTests.swift */; }; C5A0A4277768143F62DF8C81 /* GitShellQuoteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CF317BC8D703EBD8B9E0AF4 /* GitShellQuoteTests.swift */; }; C786C6538DD91671FCB92EEC /* VaultIndexNotifyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41163003C27A75907A3B1909 /* VaultIndexNotifyTests.swift */; }; + C8F0384B93EC8F1DA0AD2E05 /* FolderAppearancePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91BA803E39558BD7A8C87F8C /* FolderAppearancePicker.swift */; }; C9FF200E8A6D943707610F1E /* AppStateTemplatesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26C7B58403081D448E5999D8 /* AppStateTemplatesTests.swift */; }; CEC89756C5CC8F759BD67615 /* AppStateTabsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A96D01FE3D3F9C3438D9E4A /* AppStateTabsTests.swift */; }; D118D4ED00651328D2591ED3 /* MarkdownTablePrettifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = B36D775B1AD89841EFA0C7C7 /* MarkdownTablePrettifier.swift */; }; @@ -185,6 +195,7 @@ D5571EA4AC7F4E0AEA154D25 /* UpdateBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8BBCBD8C0A2D78B878E78B9 /* UpdateBannerView.swift */; }; D5787BB6EF12171216FBBEB4 /* AppStateNewNoteFlowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4632EB6F4E70945C784CC180 /* AppStateNewNoteFlowTests.swift */; }; D640E62885695B1FA87F6417 /* PinningFeatureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84754D16BCE7669177FF151A /* PinningFeatureTests.swift */; }; + DAAB6B929F2D28FA31686DFC /* MiniBrowserURLNormalizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F72730A6C96CF7378E89F39 /* MiniBrowserURLNormalizerTests.swift */; }; DB7BB560268ADEDB34E2A56B /* AppStateRelatedLinksTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 272B2D760A5DF243524A2DF1 /* AppStateRelatedLinksTests.swift */; }; DBA2C58B64AA4C690BD3A4F3 /* SyntaxHighlightingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 318FE7CEE90A23A1237EF0F1 /* SyntaxHighlightingTests.swift */; }; DC6C566BCC20D6F619A926CB /* FileTreeSortingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BD983D6D3F278E0AD47E0D1 /* FileTreeSortingTests.swift */; }; @@ -199,6 +210,7 @@ E7F2A859E55A23EA28421B0B /* MiniBrowserPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CEB7D2020A789C4C8007B9F /* MiniBrowserPaneView.swift */; }; E84D8DF773FF8BED5152D2BC /* CollapsibleSectionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A307C880E354FA57E8BFEB62 /* CollapsibleSectionsTests.swift */; }; EA0BACF7C14159B75533C9F5 /* PinnedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69131C0C1C0EAB9BC5CADE83 /* PinnedItem.swift */; }; + EB6E846F0AD529528603D9A2 /* FolderAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = A03FAAE3D3DB0E7E35AE9608 /* FolderAppearance.swift */; }; F03FCDD03015035E226FE7CD /* GitService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF3182DCA96C9518DC154E22 /* GitService.swift */; }; F0CC31A93376DEA4A2F12A95 /* SynapseApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48E13069F2D634A684FAE713 /* SynapseApp.swift */; }; F130A3AE3317CD34B8419BAA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CB8BA24E7E55C8EF0A3530F0 /* Assets.xcassets */; }; @@ -231,9 +243,11 @@ 0584EC822EFEC9CD5AA1241C /* SlashCommandsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlashCommandsTests.swift; sourceTree = ""; }; 07462473C99EA91E27849EEA /* DailyNoteStartupBehaviorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyNoteStartupBehaviorTests.swift; sourceTree = ""; }; 077732396E925C86B668C094 /* AutoUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoUpdater.swift; sourceTree = ""; }; + 09F6E8F7026EA35EF8C3D4A0 /* CloneRepositoryValidation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloneRepositoryValidation.swift; sourceTree = ""; }; 0A550D18D4F2CA38FCA1D0B8 /* CodeBlockCopyButtonTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeBlockCopyButtonTests.swift; sourceTree = ""; }; 0AFE1FD9B064388EFB7A9159 /* SidebarNotePanePersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarNotePanePersistenceTests.swift; sourceTree = ""; }; 0BCDAA7EF51BC5642E126414 /* SettingsManagerCollapsedPanesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManagerCollapsedPanesTests.swift; sourceTree = ""; }; + 0BFB379F8935A6440EEEE25F /* FileSearchResultTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSearchResultTests.swift; sourceTree = ""; }; 0DE78783DAFB7D972767FA82 /* AutoUpdaterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoUpdaterTests.swift; sourceTree = ""; }; 0E15235537B931DCAA752074 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 0EC7FB41D2BBF7D5C216E83B /* AppStateOpenFolderDirtyFileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateOpenFolderDirtyFileTests.swift; sourceTree = ""; }; @@ -259,7 +273,10 @@ 2ADBC72A6A85DB161568424D /* GraphPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphPaneView.swift; sourceTree = ""; }; 2C258F5BE9BBC6264833097E /* FolderPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderPickerView.swift; sourceTree = ""; }; 2C6CE68315BD0E501C83B675 /* CommandPaletteWikiLinkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteWikiLinkTests.swift; sourceTree = ""; }; + 2CF1CF1FCB641683D887AC3F /* VaultRootResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultRootResolverTests.swift; sourceTree = ""; }; 2DAD8FE980268DD35D6D28F6 /* SynapseTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SynapseTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 2F72730A6C96CF7378E89F39 /* MiniBrowserURLNormalizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MiniBrowserURLNormalizerTests.swift; sourceTree = ""; }; + 2FE1039711DA9FA8B1DD3A3D /* CloneRepositoryValidationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloneRepositoryValidationTests.swift; sourceTree = ""; }; 318FE7CEE90A23A1237EF0F1 /* SyntaxHighlightingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyntaxHighlightingTests.swift; sourceTree = ""; }; 35A600692EB7955AD6BF962D /* AppStateSearchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateSearchTests.swift; sourceTree = ""; }; 37535D804D7598740D99FEDD /* SettingsPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsPersistenceTests.swift; sourceTree = ""; }; @@ -290,6 +307,7 @@ 533FB35FAD975DD196500CCA /* MarkdownPreviewRendererTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownPreviewRendererTests.swift; sourceTree = ""; }; 53D9D2ED28ABE373443038FE /* PullAndRefreshWIPAutoSaveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PullAndRefreshWIPAutoSaveTests.swift; sourceTree = ""; }; 556021F9F9711240A933E0D0 /* CalendarDayActivityCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarDayActivityCalculator.swift; sourceTree = ""; }; + 55B2003A2EAD1D02026B4441 /* VaultRootResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultRootResolver.swift; sourceTree = ""; }; 56691DF2CDDFA2DF5F263CF3 /* AppStateCoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateCoreTests.swift; sourceTree = ""; }; 575EBDEB0FCDE2BC05FFF26C /* MarkdownPreviewCSS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownPreviewCSS.swift; sourceTree = ""; }; 581F07CA2599C2ED63315D92 /* AppStateFolderOperationsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateFolderOperationsTests.swift; sourceTree = ""; }; @@ -323,7 +341,9 @@ 78E1B729A125740F1A8C3AAE /* FileTreeHiddenItemsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTreeHiddenItemsTests.swift; sourceTree = ""; }; 79CCB6B287F3565EC8CEF2AF /* SidebarPaneTitleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarPaneTitleTests.swift; sourceTree = ""; }; 7A59692ED432DF0BD26FADE0 /* AppStateCurrentDirectoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateCurrentDirectoryTests.swift; sourceTree = ""; }; + 7B57F6CF48999648AB4119F7 /* FlatFolderNavigatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlatFolderNavigatorTests.swift; sourceTree = ""; }; 7B7F3B91E514504EEA336B55 /* GistPublisherHTTPTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GistPublisherHTTPTests.swift; sourceTree = ""; }; + 7CBFDA068907D8B52C49332F /* UpdateBannerCopy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateBannerCopy.swift; sourceTree = ""; }; 7CEBE5113F540623222FB440 /* MarkdownPreviewSemanticHidingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownPreviewSemanticHidingTests.swift; sourceTree = ""; }; 7CF317BC8D703EBD8B9E0AF4 /* GitShellQuoteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitShellQuoteTests.swift; sourceTree = ""; }; 7DA2BE40EFC241572A7CC76A /* MarkdownCalloutDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownCalloutDetectorTests.swift; sourceTree = ""; }; @@ -340,12 +360,14 @@ 89BAED87C130E2C30AE200FD /* CalendarPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarPaneView.swift; sourceTree = ""; }; 8A03956EC9D388F74A50F6F3 /* ImageSidebarEmbedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageSidebarEmbedTests.swift; sourceTree = ""; }; 8B1F0DD61D59D79D8119FB6A /* InlineTagClickTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineTagClickTests.swift; sourceTree = ""; }; + 8B68A3971BA893EE67B78D53 /* UpdateBannerCopyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateBannerCopyTests.swift; sourceTree = ""; }; 8DC2A24B73EE5F6505170332 /* AppStateSettingsPropagationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateSettingsPropagationTests.swift; sourceTree = ""; }; 8E6A63BB6A6141AA83800A2E /* AppStateCloneRepositoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateCloneRepositoryTests.swift; sourceTree = ""; }; 8E8DF75C0D5AF462C70102AF /* NavigationStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationStateTests.swift; sourceTree = ""; }; 8F24B33A93487844014AF953 /* TabBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarView.swift; sourceTree = ""; }; 90131F79CAE1FCE936E4CB27 /* MarkdownDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownDocument.swift; sourceTree = ""; }; 91A80215C2DB89F815C1432F /* SearchNotificationConstantsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchNotificationConstantsTests.swift; sourceTree = ""; }; + 91BA803E39558BD7A8C87F8C /* FolderAppearancePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderAppearancePicker.swift; sourceTree = ""; }; 928B9D0E079FF89907F205E4 /* TagsPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsPaneView.swift; sourceTree = ""; }; 9573DDE37ACEA626605DC7AF /* FileTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTreeView.swift; sourceTree = ""; }; 96A12A2466CE1E1184E8ADA3 /* MarkdownPreviewRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownPreviewRenderer.swift; sourceTree = ""; }; @@ -357,6 +379,7 @@ 9D6A78049408A64B9D89D287 /* SettingsManagerSidebarCollapseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManagerSidebarCollapseTests.swift; sourceTree = ""; }; 9D6B6923B1814AF9178F7B51 /* SidebarDragDropTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarDragDropTests.swift; sourceTree = ""; }; 9D9C3A2B05AAC380C1B9F486 /* GistPublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GistPublisherTests.swift; sourceTree = ""; }; + A03FAAE3D3DB0E7E35AE9608 /* FolderAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderAppearance.swift; sourceTree = ""; }; A0F7229E12831164A430591D /* SettingsManagerFontSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManagerFontSettingsTests.swift; sourceTree = ""; }; A15F4D02024CEC0C6D118D0D /* CalendarDayActivityCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarDayActivityCalculatorTests.swift; sourceTree = ""; }; A2378A288D2FE5AEAA19A8EA /* CollapsibleSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleSection.swift; sourceTree = ""; }; @@ -390,6 +413,7 @@ C5928863F052D71F95DEA4C4 /* TaskListCheckboxInteractionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskListCheckboxInteractionTests.swift; sourceTree = ""; }; C5F2CB84A6974094C752A300 /* MarkdownTaskCheckboxMatchesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownTaskCheckboxMatchesTests.swift; sourceTree = ""; }; C9C59A9091EA8E6FE3683544 /* AppStateDateFilteringTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateDateFilteringTests.swift; sourceTree = ""; }; + CA5D5FBCC522E3211A1BDF01 /* MiniBrowserURLNormalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MiniBrowserURLNormalizer.swift; sourceTree = ""; }; CB3B1F42FA2D44B23BE869BF /* SidebarPaneItemCodableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarPaneItemCodableTests.swift; sourceTree = ""; }; CB8BA24E7E55C8EF0A3530F0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; CC1886EC1E29B04F46A977BF /* AppStateTemplatesDirNormalizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateTemplatesDirNormalizationTests.swift; sourceTree = ""; }; @@ -456,6 +480,7 @@ 077732396E925C86B668C094 /* AutoUpdater.swift */, 556021F9F9711240A933E0D0 /* CalendarDayActivityCalculator.swift */, 89BAED87C130E2C30AE200FD /* CalendarPaneView.swift */, + 09F6E8F7026EA35EF8C3D4A0 /* CloneRepositoryValidation.swift */, A2378A288D2FE5AEAA19A8EA /* CollapsibleSection.swift */, 29CA9E8A87139F33100BCC83 /* CommandPaletteView.swift */, 4A1C79B3232B98A6F756FE47 /* Constants.swift */, @@ -465,6 +490,8 @@ CD24A6CF1D5D237DEE3390E6 /* EditorState.swift */, 6DF7018244BB6E635327C670 /* EditorView.swift */, 9573DDE37ACEA626605DC7AF /* FileTreeView.swift */, + A03FAAE3D3DB0E7E35AE9608 /* FolderAppearance.swift */, + 91BA803E39558BD7A8C87F8C /* FolderAppearancePicker.swift */, 2C258F5BE9BBC6264833097E /* FolderPickerView.swift */, 1B8349DC1AB30D81F11E1404 /* FontEnumerator.swift */, F88EA4153C25D088562FAC1C /* GistPublisher.swift */, @@ -484,6 +511,7 @@ B36D775B1AD89841EFA0C7C7 /* MarkdownTablePrettifier.swift */, 6300BFA84F1EDAD32147A4E2 /* MarkdownTaskCheckboxInteraction.swift */, 9CEB7D2020A789C4C8007B9F /* MiniBrowserPaneView.swift */, + CA5D5FBCC522E3211A1BDF01 /* MiniBrowserURLNormalizer.swift */, 467CD2A4FDF2E60201500051 /* NavigationState.swift */, C356D4BD698C91F1AB43F2B8 /* NoteContentCache.swift */, 69131C0C1C0EAB9BC5CADE83 /* PinnedItem.swift */, @@ -502,8 +530,10 @@ B2933BCDF0B2B56488D2D9F4 /* TerminalPaneView.swift */, 466921F2CFCBF80C26417D68 /* Theme.swift */, 72E0030885CC8341D5A746FD /* ThemeEnvironment.swift */, + 7CBFDA068907D8B52C49332F /* UpdateBannerCopy.swift */, D8BBCBD8C0A2D78B878E78B9 /* UpdateBannerView.swift */, 49A381F0D18E754A88B51A80 /* VaultIndex.swift */, + 55B2003A2EAD1D02026B4441 /* VaultRootResolver.swift */, ); path = Synapse; sourceTree = ""; @@ -567,6 +597,7 @@ 1F6B970F5933269DFCBB921C /* AutoUpdaterFetchTests.swift */, 0DE78783DAFB7D972767FA82 /* AutoUpdaterTests.swift */, A15F4D02024CEC0C6D118D0D /* CalendarDayActivityCalculatorTests.swift */, + 2FE1039711DA9FA8B1DD3A3D /* CloneRepositoryValidationTests.swift */, 0A550D18D4F2CA38FCA1D0B8 /* CodeBlockCopyButtonTests.swift */, 71DDA03EE1F69F7782A6C13A /* CodeBlockLayoutTests.swift */, A307C880E354FA57E8BFEB62 /* CollapsibleSectionsTests.swift */, @@ -580,9 +611,11 @@ 24898CF86DAC494714686ED0 /* EmbeddableNotesTests.swift */, 39C2E0E77C530CA093D34089 /* EmojiFlickerTests.swift */, 72208A96C6AB803EA25A56D8 /* FileBrowserErrorTests.swift */, + 0BFB379F8935A6440EEEE25F /* FileSearchResultTests.swift */, 4E033CC3C89CE74D228468EF /* FileTreeDragDropTests.swift */, 78E1B729A125740F1A8C3AAE /* FileTreeHiddenItemsTests.swift */, 6BD983D6D3F278E0AD47E0D1 /* FileTreeSortingTests.swift */, + 7B57F6CF48999648AB4119F7 /* FlatFolderNavigatorTests.swift */, 89A37B5687F020E8B15BBA70 /* FontEnumeratorTests.swift */, 121F66A181E43AA3CA9F134E /* FSEventsVaultWatcherTests.swift */, 7B7F3B91E514504EEA336B55 /* GistPublisherHTTPTests.swift */, @@ -621,6 +654,7 @@ 7CEBE5113F540623222FB440 /* MarkdownPreviewSemanticHidingTests.swift */, E077B978A164674094EE6435 /* MarkdownTablePrettifierTests.swift */, C5F2CB84A6974094C752A300 /* MarkdownTaskCheckboxMatchesTests.swift */, + 2F72730A6C96CF7378E89F39 /* MiniBrowserURLNormalizerTests.swift */, 8E8DF75C0D5AF462C70102AF /* NavigationStateTests.swift */, D7084D8A137CDD236F0B90C0 /* NewNoteFolderPickerTests.swift */, 9D419E073C2917DEE1A85E20 /* NoteGraphModelTests.swift */, @@ -666,9 +700,11 @@ FEFD465FCA255C7225417AB1 /* TemplatesDirectoryHiddenFolderTests.swift */, 47250559CE52FF1D2C46C549 /* TemplatesDirectoryUIBehaviorTests.swift */, CD5F4BF1B221EC143CBF1982 /* ThemeEnvironmentTests.swift */, + 8B68A3971BA893EE67B78D53 /* UpdateBannerCopyTests.swift */, C2B942CDAEC5B557F6B0ABE9 /* VaultFullTextSearchTests.swift */, 41163003C27A75907A3B1909 /* VaultIndexNotifyTests.swift */, D3088575F730FD77375CDC73 /* VaultIndexTests.swift */, + 2CF1CF1FCB641683D887AC3F /* VaultRootResolverTests.swift */, BCDF9EB2EC353844301E6106 /* WikiLinkClickTests.swift */, ); path = SynapseTests; @@ -825,6 +861,7 @@ C2CD6237CEEEBDC1FFBAB0BA /* AutoUpdaterFetchTests.swift in Sources */, AE9908CACA946E363AF49D5A /* AutoUpdaterTests.swift in Sources */, 4A963D450EF3EA0CC87ECA01 /* CalendarDayActivityCalculatorTests.swift in Sources */, + 5FEDFF1E01A54B9725A648ED /* CloneRepositoryValidationTests.swift in Sources */, B87F1BB42DB175A44CDDAE72 /* CodeBlockCopyButtonTests.swift in Sources */, 6460A69C604E78BA3A371AE9 /* CodeBlockLayoutTests.swift in Sources */, E84D8DF773FF8BED5152D2BC /* CollapsibleSectionsTests.swift in Sources */, @@ -839,9 +876,11 @@ B4DE2D6CEED7359ED53618F6 /* EmojiFlickerTests.swift in Sources */, 07DB5CDC55F3B14B5FAB7960 /* FSEventsVaultWatcherTests.swift in Sources */, D3B93EE1AE6B5B34F88C47B9 /* FileBrowserErrorTests.swift in Sources */, + 7A613FF66AF9AAFEC1AFFEC5 /* FileSearchResultTests.swift in Sources */, 0B7F1C079293FF928E135F19 /* FileTreeDragDropTests.swift in Sources */, 9826D148FDB25BE367FE775A /* FileTreeHiddenItemsTests.swift in Sources */, DC6C566BCC20D6F619A926CB /* FileTreeSortingTests.swift in Sources */, + A1AF763BE77049159AC0EAB2 /* FlatFolderNavigatorTests.swift in Sources */, A0988EBDF1218D4D58E2B842 /* FontEnumeratorTests.swift in Sources */, 2F329EFC9C37AE1116380D50 /* GistPublisherHTTPTests.swift in Sources */, 933A55A4037C635EB3FABDCE /* GistPublisherTests.swift in Sources */, @@ -879,6 +918,7 @@ 10E0EAC866951DD0805DEC21 /* MarkdownPreviewSemanticHidingTests.swift in Sources */, 7B87ED4DFA4B6DE92EE7B6B9 /* MarkdownTablePrettifierTests.swift in Sources */, E2146C55904AE7F445BAD8C5 /* MarkdownTaskCheckboxMatchesTests.swift in Sources */, + DAAB6B929F2D28FA31686DFC /* MiniBrowserURLNormalizerTests.swift in Sources */, 989BD4518C2A6CDBED609D7C /* NavigationStateTests.swift in Sources */, 77C6E256FA654823D534EE68 /* NewNoteFolderPickerTests.swift in Sources */, 9C491E4C25AF389520BC1BD1 /* NoteGraphModelTests.swift in Sources */, @@ -924,9 +964,11 @@ 0BAD97ABBAA364DB85827080 /* TemplatesDirectoryHiddenFolderTests.swift in Sources */, 4DCEA0C615CA64683100C661 /* TemplatesDirectoryUIBehaviorTests.swift in Sources */, F9F4DA3682F63DC0C0AE6E4B /* ThemeEnvironmentTests.swift in Sources */, + 5BCAD7EC3ED27CF026D15715 /* UpdateBannerCopyTests.swift in Sources */, 2090AC5921E42CC1E91C1CEF /* VaultFullTextSearchTests.swift in Sources */, C786C6538DD91671FCB92EEC /* VaultIndexNotifyTests.swift in Sources */, 6BB676AA07F70C9D85355EF6 /* VaultIndexTests.swift in Sources */, + 7A91335AEF118317EAFB1093 /* VaultRootResolverTests.swift in Sources */, 855536CCA2FBCEEFBA605D30 /* WikiLinkClickTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -940,6 +982,7 @@ C1CD8FA28738B344240C177B /* AutoUpdater.swift in Sources */, 9EC02229A8FFE70905BE9465 /* CalendarDayActivityCalculator.swift in Sources */, 8AC37ADAD3179D24D35DBAB1 /* CalendarPaneView.swift in Sources */, + 89CEEB93F78960EDA6799F19 /* CloneRepositoryValidation.swift in Sources */, 8DF6C8091C8EF8192BDDCDD1 /* CollapsibleSection.swift in Sources */, 59A6F7BB258D1FF977C53B25 /* CommandPaletteView.swift in Sources */, 95878F8A850A56ED9838178A /* Constants.swift in Sources */, @@ -949,6 +992,8 @@ E48F37F260D24AE089E48440 /* EditorState.swift in Sources */, 589CC9B2EF571DD4C8E39414 /* EditorView.swift in Sources */, 5027113BDFD3F4EFCBB359A4 /* FileTreeView.swift in Sources */, + EB6E846F0AD529528603D9A2 /* FolderAppearance.swift in Sources */, + C8F0384B93EC8F1DA0AD2E05 /* FolderAppearancePicker.swift in Sources */, 0F5E75F1929BB107A3E4F9E6 /* FolderPickerView.swift in Sources */, 5A17B54B9E5169462B921F4B /* FontEnumerator.swift in Sources */, 149B715AD9DF9829286F386F /* GistPublisher.swift in Sources */, @@ -967,6 +1012,7 @@ D118D4ED00651328D2591ED3 /* MarkdownTablePrettifier.swift in Sources */, A3005E593A077EC5A3F1A612 /* MarkdownTaskCheckboxInteraction.swift in Sources */, E7F2A859E55A23EA28421B0B /* MiniBrowserPaneView.swift in Sources */, + 84BB4A4A3072E7D1C3466CB0 /* MiniBrowserURLNormalizer.swift in Sources */, 21A4FAA4EAA4D3E0868F1486 /* NavigationState.swift in Sources */, D53AEDB7F672A0ADBAFA6B02 /* NoteContentCache.swift in Sources */, EA0BACF7C14159B75533C9F5 /* PinnedItem.swift in Sources */, @@ -984,8 +1030,10 @@ B26B4268AD9B4A526D7D6049 /* TerminalPaneView.swift in Sources */, B0B6400BA0E32822AB81A020 /* Theme.swift in Sources */, B5CEFA8AF7B58E8ED7328867 /* ThemeEnvironment.swift in Sources */, + 18CAB55CD090014DDDE479CD /* UpdateBannerCopy.swift in Sources */, D5571EA4AC7F4E0AEA154D25 /* UpdateBannerView.swift in Sources */, 168BD2D7D140EB2F1BC65CBC /* VaultIndex.swift in Sources */, + 26FE69D731F89E553CA8BA7D /* VaultRootResolver.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/macOS/Synapse/AppState.swift b/macOS/Synapse/AppState.swift index 9c83c08..95571de 100644 --- a/macOS/Synapse/AppState.swift +++ b/macOS/Synapse/AppState.swift @@ -209,6 +209,7 @@ class AppState: ObservableObject { @Published var canGoForward: Bool = false @Published var isCommandPalettePresented: Bool = false @Published var isNewNotePromptRequested: Bool = false + @Published var isNewFolderPromptRequested: Bool = false @Published var pendingTemplateURL: URL? = nil @Published var pendingCursorPosition: Int? = nil @Published var pendingCursorRange: NSRange? = nil @@ -501,6 +502,32 @@ class AppState: ObservableObject { return settings.pinnedItems.contains { $0.isTag && $0.name == tagName && $0.matchesVaultPath(root.path) } } + // MARK: - Folder Appearance + + /// Returns the current appearance for a folder, if one has been set. + func folderAppearance(for url: URL) -> FolderAppearance? { + let rel = relativePath(for: url) + return settings.folderAppearances.first { $0.relativePath == rel } + } + + /// Saves (creates or replaces) a folder appearance. + func setFolderAppearance(_ appearance: FolderAppearance, for url: URL) { + let rel = relativePath(for: url) + var appearances = settings.folderAppearances + if let idx = appearances.firstIndex(where: { $0.relativePath == rel }) { + appearances[idx] = appearance + } else { + appearances.append(appearance) + } + settings.folderAppearances = appearances + } + + /// Removes any custom appearance for a folder, reverting it to defaults. + func clearFolderAppearance(for url: URL) { + let rel = relativePath(for: url) + settings.folderAppearances.removeAll { $0.relativePath == rel } + } + @objc private func handleAppTermination() { persistDirtyFileIfNeeded() // State file is NOT removed on quit - it's needed for "Previously open notes" restoration @@ -3330,4 +3357,209 @@ class AppState: ObservableObject { guard let stateURL = stateFileURL else { return } try? FileManager.default.removeItem(at: stateURL) } + + // MARK: - Flat Folder Navigator (Issue #200) + + /// The current directory being displayed in the flat folder navigator. + /// Defaults to rootURL when not explicitly set. + @Published var flatNavigatorCurrentDirectory: URL? + + /// Navigation path tracking for the flat folder navigator. + /// Contains the history of directories navigated through (for breadcrumb support). + private var flatNavigatorPathStack: [URL] = [] + + /// Tracks whether the back button is currently being hovered during a drag operation. + @Published var flatNavigatorBackButtonIsDragHovering: Bool = false + + /// Timer for delayed navigation when hovering over back button during drag. + private var flatNavigatorBackButtonDragTimer: Timer? + + /// Returns the navigation path as an array of URLs from root to current. + var flatNavigatorPath: [URL] { + guard let root = rootURL else { return [] } + guard let current = flatNavigatorCurrentDirectory else { return [root] } + + var path: [URL] = [root] + let rootPath = root.standardizedFileURL.path + let currentPath = current.standardizedFileURL.path + + // Build path from root to current + if currentPath.hasPrefix(rootPath) && current != root { + var components = currentPath.dropFirst(rootPath.count) + .split(separator: "/") + .map(String.init) + + var buildingPath = root + for component in components { + buildingPath = buildingPath.appendingPathComponent(component, isDirectory: true) + path.append(buildingPath) + } + } + + return path + } + + /// Returns the display name of the current directory. + var flatNavigatorCurrentDirectoryName: String { + guard let current = flatNavigatorCurrentDirectory else { + return rootURL?.lastPathComponent ?? "Library" + } + return current.lastPathComponent + } + + /// Returns the contents of the current directory for flat navigator display. + var flatNavigatorCurrentContents: [URL] { + guard let directory = flatNavigatorCurrentDirectory ?? rootURL else { return [] } + + let fm = FileManager.default + guard let contents = try? fm.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.isDirectoryKey, .contentModificationDateKey], + options: [] + ) else { return [] } + + // Filter and sort: folders first, then files, alphabetically within each group + var items: [(url: URL, isDirectory: Bool, name: String)] = [] + + for url in contents { + let isDir = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false + let name = url.lastPathComponent + + // Skip hidden files and .git + if name.hasPrefix(".") { continue } + if isDir && name == ".git" { continue } + if !isDir && name.hasPrefix(".") { continue } + + // Apply settings filters + if !settings.shouldShowFile(url) && !isDir { continue } + if isDir && settings.shouldHideItem(named: name) { continue } + + items.append((url, isDir, name)) + } + + // Sort based on the current sort criterion and direction + items.sort(by: { (a, b) -> Bool in + // Always keep directories before files regardless of sort criterion + if a.isDirectory != b.isDirectory { + return a.isDirectory // Directories first + } + + // Both items are same type (both dirs or both files), apply selected sort + let comparison: ComparisonResult + switch sortCriterion { + case .name: + comparison = a.name.localizedCaseInsensitiveCompare(b.name) + case .modified: + let date1 = (try? a.url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast + let date2 = (try? b.url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast + comparison = date1.compare(date2) + } + + // Apply ascending/descending + return sortAscending ? (comparison == .orderedAscending) : (comparison == .orderedDescending) + }) + + return items.map { $0.url } + } + + /// Returns true if the user can navigate back (i.e., not at root). + var canNavigateBackInFlatNavigator: Bool { + guard let root = rootURL else { return false } + guard let current = flatNavigatorCurrentDirectory else { return false } + return current.standardizedFileURL != root.standardizedFileURL + } + + /// Navigate into a folder in the flat navigator. + func navigateToFolder(_ folder: URL) { + guard let root = rootURL else { return } + + // Validate the folder is within the vault + let rootPath = root.standardizedFileURL.path + let folderPath = folder.standardizedFileURL.path + guard folderPath.hasPrefix(rootPath) else { return } + + // Verify it's actually a directory + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: folder.path, isDirectory: &isDirectory), + isDirectory.boolValue else { return } + + flatNavigatorCurrentDirectory = folder + flatNavigatorPathStack.append(folder) + } + + /// Navigate back up one level in the flat navigator. + func navigateBackInFlatNavigator() { + guard let root = rootURL else { return } + let current = flatNavigatorCurrentDirectory ?? root + + // If at root, do nothing + if current.standardizedFileURL == root.standardizedFileURL { + // Ensure flatNavigatorCurrentDirectory is set to root + flatNavigatorCurrentDirectory = root + return + } + + // Navigate to parent + let parent = current.deletingLastPathComponent() + flatNavigatorCurrentDirectory = parent + + // Pop from path stack if applicable + if !flatNavigatorPathStack.isEmpty { + flatNavigatorPathStack.removeLast() + } + } + + /// Navigate directly to the root directory. + func navigateToRootInFlatNavigator() { + flatNavigatorCurrentDirectory = rootURL + flatNavigatorPathStack.removeAll() + } + + /// Called when drag hover starts over the back button. + func flatNavigatorBackButtonDragHoverStarted() { + flatNavigatorBackButtonIsDragHovering = true + + // Schedule navigation up after a delay (same pattern as folder auto-expand) + flatNavigatorBackButtonDragTimer?.invalidate() + let timer = Timer(timeInterval: 0.6, repeats: false) { [weak self] _ in + DispatchQueue.main.async { + self?.navigateBackInFlatNavigator() + } + } + RunLoop.main.add(timer, forMode: .common) + flatNavigatorBackButtonDragTimer = timer + } + + /// Called when drag hover ends over the back button. + func flatNavigatorBackButtonDragHoverEnded() { + flatNavigatorBackButtonIsDragHovering = false + flatNavigatorBackButtonDragTimer?.invalidate() + flatNavigatorBackButtonDragTimer = nil + } + + /// Drop a file onto a pinned item (folder only). + func dropFile(_ fileURL: URL, ontoPinnedItem pinnedItem: PinnedItem) throws -> URL { + // Validate source file exists + guard FileManager.default.fileExists(atPath: fileURL.path) else { + throw FileBrowserError.operationFailed("Source file does not exist") + } + + // Validate target is a folder + guard pinnedItem.isFolder else { + throw FileBrowserError.operationFailed("Target is not a folder") + } + + guard let targetURL = pinnedItem.url else { + throw FileBrowserError.operationFailed("Target folder not found") + } + + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: targetURL.path, isDirectory: &isDirectory), + isDirectory.boolValue else { + throw FileBrowserError.operationFailed("Target is not a folder") + } + + // Use existing moveFile functionality + return try moveFile(at: fileURL, toFolder: targetURL) + } } diff --git a/macOS/Synapse/AppTheme.swift b/macOS/Synapse/AppTheme.swift index c6fa888..2403276 100644 --- a/macOS/Synapse/AppTheme.swift +++ b/macOS/Synapse/AppTheme.swift @@ -195,7 +195,7 @@ extension AppTheme { "text.secondary": "#9DA7B3", "text.muted": "#7D8590", "accent": "#2F81F7", - "accent.soft": "#1F6FEB", + "accent.soft": "#0D4A9E", // Darker blue for selected row background "border": "#30363D", "divider": "#21262D", "row": "#161B22", diff --git a/macOS/Synapse/CalendarPaneView.swift b/macOS/Synapse/CalendarPaneView.swift index 06f60b0..f1d775c 100644 --- a/macOS/Synapse/CalendarPaneView.swift +++ b/macOS/Synapse/CalendarPaneView.swift @@ -223,7 +223,8 @@ private struct DayCell: View { private var textColor: Color { if isToday { - return SynapseTheme.accent + // Use white for today to ensure contrast against the accent badge + return Color.white } if !isInCurrentMonth { return SynapseTheme.textMuted.opacity(0.5) diff --git a/macOS/Synapse/EditorView.swift b/macOS/Synapse/EditorView.swift index e6d0b61..8a59b11 100644 --- a/macOS/Synapse/EditorView.swift +++ b/macOS/Synapse/EditorView.swift @@ -1773,9 +1773,20 @@ extension LinkAwareTextView { } if let frontmatter = semanticStyles.frontmatter { if NSIntersectionRange(frontmatter.contentRange, scopeRange).length > 0 { + // Use a static/fixed line height for frontmatter that doesn't change with user settings + let frontmatterFont = NSFont.systemFont(ofSize: 11) + let naturalLineHeight = frontmatterFont.ascender - frontmatterFont.descender + frontmatterFont.leading + let staticLineHeightMultiple: CGFloat = 1.2 + let desiredLineHeight = naturalLineHeight * staticLineHeightMultiple + let extraSpacing = max(0, desiredLineHeight - naturalLineHeight) + let frontmatterParagraphStyle = NSMutableParagraphStyle() + frontmatterParagraphStyle.minimumLineHeight = naturalLineHeight + frontmatterParagraphStyle.maximumLineHeight = naturalLineHeight + frontmatterParagraphStyle.lineSpacing = extraSpacing storage.addAttributes([ - .font: NSFont.systemFont(ofSize: 11), + .font: frontmatterFont, .foregroundColor: SynapseTheme.editorMuted, + .paragraphStyle: frontmatterParagraphStyle, ], range: frontmatter.contentRange) } let openingFence = NSRange(location: frontmatter.range.location, length: min(3, frontmatter.range.length)) diff --git a/macOS/Synapse/FileTreeView.swift b/macOS/Synapse/FileTreeView.swift index 1deb2d0..8bce50c 100644 --- a/macOS/Synapse/FileTreeView.swift +++ b/macOS/Synapse/FileTreeView.swift @@ -153,12 +153,16 @@ struct FileTreeView: View { @State private var dragOverFolderURL: URL? = nil /// Pending conflict requiring user confirmation before overwrite. @State private var moveConflict: FileMoveConflict? = nil + /// Tracks which pinned folder is currently being hovered as a drop target (Issue #200). + @State private var dragOverPinnedFolderID: UUID? = nil /// Count of `allFiles` updates we expect from an in-flight `moveFile` refresh. /// `refreshAllFiles()` completes asynchronously after `moveFile` returns, so a /// simple boolean cleared in `defer` races the `onChange` and still triggers a /// full `refresh()` that wipes `childrenCache` and jumps scroll. Each increment /// consumes one `onChange` delivery. @State private var pendingMoveRefreshSkips = 0 + /// The folder URL for which the appearance picker sheet is being presented. + @State private var folderAppearanceTarget: URL? = nil var body: some View { ScrollViewReader { proxy in @@ -223,11 +227,18 @@ struct FileTreeView: View { let dir = appState.targetDirectoryForNewNote ?? appState.targetDirectoryForTemplate ?? appState.rootURL presentCreateNote(in: dir) } + .onChange(of: appState.isNewFolderPromptRequested) { _, requested in + guard requested else { return } + appState.isNewFolderPromptRequested = false + let dir = appState.targetDirectoryForTemplate ?? appState.rootURL + presentCreateFolder(in: dir) + } .sheet(item: $editorAction) { action in BrowserItemEditorSheet(action: action) { submittedName, selectedFolder in handleEditorSubmit(action: action, submittedName: submittedName, selectedFolder: selectedFolder) } } + .appearancePickerSheet(target: $folderAppearanceTarget) .alert( deleteTarget?.title ?? "Delete", isPresented: Binding( @@ -377,7 +388,18 @@ struct FileTreeView: View { if !isPinnedSectionCollapsed { ForEach(appState.pinnedItems) { item in - PinnedItemRow(item: item) + PinnedItemRow( + item: item, + dragOverPinnedFolderID: $dragOverPinnedFolderID, + onDropFile: { fileURL, pinnedItem in + // Handle drop onto pinned folder (Issue #200) + do { + _ = try appState.dropFile(fileURL, ontoPinnedItem: pinnedItem) + } catch { + errorMessage = error.localizedDescription + } + } + ) } } } @@ -416,7 +438,7 @@ struct FileTreeView: View { appState.sortCriterion = criterion appState.sortAscending = true } - refresh() + refreshWithoutNavigation() }) { Text(criterion.rawValue) .font(.system(size: 11, weight: .semibold, design: .rounded)) @@ -437,7 +459,7 @@ struct FileTreeView: View { Button(action: { appState.sortAscending.toggle() - refresh() + refreshWithoutNavigation() }) { Image(systemName: appState.sortAscending ? "arrow.up" : "arrow.down") .font(.system(size: 11, weight: .semibold)) @@ -517,7 +539,11 @@ struct FileTreeView: View { @ViewBuilder private var folderTreeContent: some View { - if nodes.isEmpty { + // Flat Folder Navigator (Issue #200): shows one directory level at a time + let currentContents = appState.flatNavigatorCurrentContents + let isAtRoot = !appState.canNavigateBackInFlatNavigator + + if currentContents.isEmpty && isAtRoot { VStack(alignment: .leading, spacing: 8) { Text("No notes yet") .font(.system(size: 13, weight: .semibold, design: .rounded)) @@ -530,33 +556,61 @@ struct FileTreeView: View { .padding(.vertical, 8) } else { LazyVStack(alignment: .leading, spacing: 6) { - RootDropTargetRow( - vaultName: appState.rootURL?.lastPathComponent ?? "Root", - isTargeted: dragOverFolderURL == appState.rootURL, - onDrop: { providers in - guard let root = appState.rootURL else { return false } - handleDrop(providers: providers, toFolder: root) - return true - }, - setTargeted: { targeted in - dragOverFolderURL = targeted ? appState.rootURL : nil - } - ) - - ForEach(nodes) { node in - FileNodeRow( - node: node, - depth: 0, - expandedDirs: $expandedDirs, - dragOverFolderURL: $dragOverFolderURL, - loadChildren: { loadChildren(for: $0) }, - onCreateNote: { presentCreateNote(in: $0) }, - onCreateFolder: { presentCreateFolder(in: $0) }, - onRename: { presentRename(for: $0, isDirectory: $1) }, - onDelete: { presentDelete(for: $0, isDirectory: $1) }, - onMoveFile: { presentMoveFile(from: $0, toFolder: $1) } + // Back Button (Issue #200): shown when not at root + if !isAtRoot { + FlatNavigatorBackButton( + folderName: appState.flatNavigatorCurrentDirectoryName, + isDragHovering: appState.flatNavigatorBackButtonIsDragHovering, + onTap: { + withAnimation(.easeInOut(duration: 0.2)) { + appState.navigateBackInFlatNavigator() + } + }, + onDragHoverStarted: { + appState.flatNavigatorBackButtonDragHoverStarted() + }, + onDragHoverEnded: { + appState.flatNavigatorBackButtonDragHoverEnded() + } ) } + + // Flat list of folders and files at current level + ForEach(currentContents, id: \.self) { url in + let isDirectory = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false + if isDirectory { + FlatFolderRow( + folderURL: url, + isSelected: appState.selectedFile == url, + isDragTarget: dragOverFolderURL == url, + onTap: { + // Navigate into folder (Issue #200) + withAnimation(.easeInOut(duration: 0.2)) { + appState.navigateToFolder(url) + } + }, + onDrop: { providers in + handleDrop(providers: providers, toFolder: url) + return true + }, + setDragTarget: { targeted in + dragOverFolderURL = targeted ? url : nil + }, + onRename: { presentRename(for: url, isDirectory: true) }, + onDelete: { presentDelete(for: url, isDirectory: true) }, + onCustomizeAppearance: { folderAppearanceTarget = url } + ) + } else { + FlatFileRow( + fileURL: url, + isSelected: appState.selectedFile == url, + onTap: { appState.openFile(url) }, + onCmdTap: { appState.openFileInNewTab(url) }, + onRename: { presentRename(for: url, isDirectory: false) }, + onDelete: { presentDelete(for: url, isDirectory: false) } + ) + } + } } .padding(.vertical, 4) } @@ -590,6 +644,18 @@ struct FileTreeView: View { expandPath(to: appState.selectedFile) } + /// Refreshes the file tree without navigating to the selected file. + /// Used when sorting changes to avoid snapping back to the selected file. + private func refreshWithoutNavigation() { + guard let root = appState.rootURL else { + nodes = [] + return + } + childrenCache = [:] + nodes = buildFileTreeLevel(at: root, sortCriterion: appState.sortCriterion, ascending: appState.sortAscending, settings: settings) + // Intentionally NOT calling expandPath to preserve current scroll position + } + /// Returns cached children for a directory, or kicks off an async load and returns nil. /// On cache hit this is instant; on miss, the directory is scanned off the main thread /// and the cache is populated when done (triggering a re-render). @@ -667,6 +733,12 @@ struct FileTreeView: View { } expandedDirs.insert(root) + + // Sync flat folder navigator to show the folder containing the selected file (Issue #200) + let parentFolder = file.deletingLastPathComponent() + if parentFolder.standardizedFileURL.path.hasPrefix(rootPath) { + appState.flatNavigatorCurrentDirectory = parentFolder + } } private func revealSelection(with proxy: ScrollViewProxy, animated: Bool = true) { @@ -822,6 +894,7 @@ struct FileNodeRow: View { let onDelete: (URL, Bool) -> Void /// Called when a file has been dropped onto a valid folder target. let onMoveFile: (URL, URL) -> Void + let onCustomizeAppearance: ((URL) -> Void)? /// Timer used to auto-expand a collapsed folder when hovering during a drag. @State private var dragHoverExpandTimer: Timer? = nil @@ -838,12 +911,18 @@ struct FileNodeRow: View { Spacer().frame(width: CGFloat(depth) * SynapseTheme.Layout.fileTreeIndentWidth) if node.isDirectory { + let nodeAppearance = appState.folderAppearance(for: node.url) + let folderIconColor = nodeAppearance?.resolvedColor ?? SynapseTheme.accent + let folderIconSymbol: String = { + if isTemplatesDirectory { return "folder.badge.gearshape.fill" } + return nodeAppearance?.resolvedSymbolName ?? "folder.fill" + }() Image(systemName: isExpanded ? "chevron.down" : "chevron.right") .font(.system(size: 8, weight: .bold)) .frame(width: 10) .foregroundStyle(SynapseTheme.textMuted) - Image(systemName: isTemplatesDirectory ? "folder.badge.gearshape.fill" : "folder.fill") - .foregroundStyle(SynapseTheme.accent) + Image(systemName: folderIconSymbol) + .foregroundStyle(folderIconColor) } else { Spacer().frame(width: 10) Image(systemName: node.isMarkdown ? "doc.text.fill" : "doc.text") @@ -916,6 +995,10 @@ struct FileNodeRow: View { } else { Button("Pin") { appState.pinItem(node.url) } } + if node.isDirectory { + Divider() + Button("Customize Appearance…") { onCustomizeAppearance?(node.url) } + } Divider() Button("Rename") { onRename(node.url, node.isDirectory) } Button("Delete", role: .destructive) { onDelete(node.url, node.isDirectory) } @@ -933,7 +1016,8 @@ struct FileNodeRow: View { onCreateFolder: onCreateFolder, onRename: onRename, onDelete: onDelete, - onMoveFile: onMoveFile + onMoveFile: onMoveFile, + onCustomizeAppearance: onCustomizeAppearance ) } } @@ -1073,6 +1157,12 @@ private struct FolderDropModifier: ViewModifier { struct PinnedItemRow: View { @EnvironmentObject var appState: AppState let item: PinnedItem + @Binding var dragOverPinnedFolderID: UUID? + let onDropFile: (URL, PinnedItem) -> Void + + private var isDragTarget: Bool { + item.isFolder && dragOverPinnedFolderID == item.id + } var body: some View { Button(action: handleTap) { @@ -1097,7 +1187,15 @@ struct PinnedItemRow: View { .padding(.horizontal, 8) .background { RoundedRectangle(cornerRadius: 4, style: .continuous) - .fill(SynapseTheme.row) + .fill(isDragTarget + ? SynapseTheme.accent.opacity(0.18) + : SynapseTheme.row) + } + .overlay { + if isDragTarget { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .stroke(SynapseTheme.accent, lineWidth: 1.5) + } } } .buttonStyle(.plain) @@ -1116,6 +1214,13 @@ struct PinnedItemRow: View { handleCmdClick() } ) + // Drop target for pinned folders (Issue #200) + .modifier(PinnedFolderDropModifier( + isFolder: item.isFolder, + item: item, + dragOverPinnedFolderID: $dragOverPinnedFolderID, + onDropFile: onDropFile + )) .contextMenu { if item.isTag { Button("Unpin") { appState.unpinTag(item.name) } @@ -1155,6 +1260,41 @@ struct PinnedItemRow: View { } } +// MARK: - Pinned Folder Drop Modifier (Issue #200) +/// Applies onDrop only to pinned folder items, enabling drag-and-drop file moving to pinned folders. +private struct PinnedFolderDropModifier: ViewModifier { + let isFolder: Bool + let item: PinnedItem + @Binding var dragOverPinnedFolderID: UUID? + let onDropFile: (URL, PinnedItem) -> Void + + @ViewBuilder + func body(content: Content) -> some View { + if isFolder { + content + .onDrop( + of: [.fileURL], + isTargeted: Binding( + get: { dragOverPinnedFolderID == item.id }, + set: { targeted in + dragOverPinnedFolderID = targeted ? item.id : nil + } + ) + ) { providers in + dragOverPinnedFolderID = nil + guard let provider = providers.first else { return false } + provider.loadItem(forTypeIdentifier: "public.file-url", options: nil) { itemData, _ in + guard let fileURL = extractSidebarFileURL(from: itemData) else { return } + DispatchQueue.main.async { onDropFile(fileURL, item) } + } + return true + } + } else { + content + } + } +} + private struct BrowserItemEditorSheet: View { @EnvironmentObject var appState: AppState let action: BrowserEditorAction @@ -1286,6 +1426,8 @@ private struct BrowserItemEditorSheet: View { } } + Spacer() + HStack { Spacer() Button("Cancel") { dismiss() } @@ -1302,3 +1444,233 @@ private struct BrowserItemEditorSheet: View { dismiss() } } + +// MARK: - Flat Folder Navigator Components (Issue #200) + +/// Back button for the flat folder navigator - navigates up one level. +/// Supports drag hover to navigate up during drag operations. +private struct FlatNavigatorBackButton: View { + let folderName: String + let isDragHovering: Bool + let onTap: () -> Void + let onDragHoverStarted: () -> Void + let onDragHoverEnded: () -> Void + + var body: some View { + Button(action: onTap) { + HStack(spacing: 6) { + Image(systemName: "chevron.left") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(isDragHovering ? SynapseTheme.accent : SynapseTheme.textMuted) + Text(folderName) + .font(.system(size: 13, weight: .medium, design: .rounded)) + .foregroundStyle(isDragHovering ? SynapseTheme.accent : SynapseTheme.textPrimary) + Spacer() + } + .padding(.vertical, 6) + .padding(.horizontal, 8) + .background { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(isDragHovering ? SynapseTheme.accent.opacity(0.18) : SynapseTheme.row) + } + .overlay { + if isDragHovering { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(SynapseTheme.accent, lineWidth: 1.5) + } + } + } + .buttonStyle(.plain) + .padding(.horizontal, 2) + .onHover { hovering in + if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() } + } + // Drag hover support for navigating up during drag operations + .onDrop( + of: [.fileURL], + isTargeted: Binding( + get: { isDragHovering }, + set: { targeted in + if targeted { + onDragHoverStarted() + } else { + onDragHoverEnded() + } + } + ) + ) { _ in + // Drop on back button completes the navigation up + onDragHoverEnded() + return true + } + } +} + +/// Row displaying a folder in the flat navigator - tap to navigate into. +private struct FlatFolderRow: View { + @EnvironmentObject var appState: AppState + let folderURL: URL + let isSelected: Bool + let isDragTarget: Bool + let onTap: () -> Void + let onDrop: ([NSItemProvider]) -> Bool + let setDragTarget: (Bool) -> Void + let onRename: () -> Void + let onDelete: () -> Void + let onCustomizeAppearance: () -> Void + + var folderName: String { folderURL.lastPathComponent } + + private var appearance: FolderAppearance? { appState.folderAppearance(for: folderURL) } + private var folderColor: Color { appearance?.resolvedColor ?? SynapseTheme.accent } + private var folderSymbol: String { + if let sym = appearance?.resolvedSymbolName { return sym } + return "folder.fill" + } + + var body: some View { + HStack(spacing: 6) { + Image(systemName: folderSymbol) + .foregroundStyle(folderColor) + Text(folderName) + .lineLimit(1) + .truncationMode(.middle) + .font(.system(size: 13, weight: isSelected ? .semibold : .medium, design: .rounded)) + .foregroundStyle(isSelected ? Color.white : SynapseTheme.textPrimary) + Spacer() + } + .padding(.vertical, 6) + .padding(.horizontal, 8) + .background { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(isDragTarget + ? SynapseTheme.accent.opacity(0.18) + : (isSelected ? SynapseTheme.accentSoft : (appearance?.resolvedColor?.opacity(0.12) ?? SynapseTheme.row))) + .overlay { + if isDragTarget { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(SynapseTheme.accent, lineWidth: 1.5) + } + } + } + .contentShape(Rectangle()) + .onTapGesture(perform: onTap) + .onHover { hovering in + if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() } + } + .onDrop( + of: [.fileURL], + isTargeted: Binding( + get: { isDragTarget }, + set: setDragTarget + ), + perform: onDrop + ) + .contextMenu { + Button("New Note") { appState.presentRootNoteSheet(in: folderURL) } + Button("New Folder") { + appState.targetDirectoryForTemplate = folderURL + appState.isNewFolderPromptRequested = true + } + Divider() + if appState.isPinned(folderURL) { + Button("Unpin") { appState.unpinItem(folderURL) } + } else { + Button("Pin") { appState.pinItem(folderURL) } + } + Divider() + Button("Customize Appearance…") { onCustomizeAppearance() } + Divider() + Button("Rename") { onRename() } + Button("Delete", role: .destructive) { onDelete() } + } + } +} + +/// Row displaying a file in the flat navigator. +private struct FlatFileRow: View { + @EnvironmentObject var appState: AppState + let fileURL: URL + let isSelected: Bool + let onTap: () -> Void + let onCmdTap: () -> Void + let onRename: () -> Void + let onDelete: () -> Void + + var fileName: String { fileURL.deletingPathExtension().lastPathComponent } + var isMarkdown: Bool { + let ext = fileURL.pathExtension.lowercased() + return ext == "md" || ext == "markdown" + } + + var body: some View { + Button(action: handleTap) { + HStack(spacing: 6) { + // Files now align with folders (no extra indent needed after removing chevrons) + Image(systemName: isMarkdown ? "doc.text.fill" : "doc.text") + .foregroundStyle(isMarkdown ? SynapseTheme.success : SynapseTheme.textMuted) + .opacity(0.8) + Text(fileName) + .lineLimit(1) + .truncationMode(.middle) + .font(.system(size: 13, weight: isSelected ? .semibold : .medium, design: .rounded)) + .foregroundStyle(isSelected ? Color.white : SynapseTheme.textPrimary) + Spacer() + } + .padding(.vertical, 6) + .padding(.horizontal, 8) + .background { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(isSelected ? SynapseTheme.accentSoft : SynapseTheme.row) + } + } + .buttonStyle(.plain) + .padding(.horizontal, 2) + .onHover { hovering in + if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() } + } + .onDrag { + sidebarFileItemProvider(for: fileURL) + } + .contextMenu { + Button("Open in Split") { appState.openFileInSplit(fileURL) } + Divider() + if appState.isPinned(fileURL) { + Button("Unpin") { appState.unpinItem(fileURL) } + } else { + Button("Pin") { appState.pinItem(fileURL) } + } + Divider() + Button("Rename") { onRename() } + Button("Delete", role: .destructive) { onDelete() } + } + } + + private func handleTap() { + // Check if Cmd key is pressed + let isCmdPressed = NSEvent.modifierFlags.contains(.command) + if isCmdPressed { + onCmdTap() + } else { + onTap() + } + } +} + +// MARK: - Appearance Picker Sheet Modifier + +private extension View { + func appearancePickerSheet(target: Binding) -> some View { + self.sheet(isPresented: Binding( + get: { target.wrappedValue != nil }, + set: { if !$0 { target.wrappedValue = nil } } + )) { + if let url = target.wrappedValue { + FolderAppearancePicker(folderURL: url) { + target.wrappedValue = nil + } + } + } + } +} + diff --git a/macOS/Synapse/FolderAppearance.swift b/macOS/Synapse/FolderAppearance.swift new file mode 100644 index 0000000..f69eb9f --- /dev/null +++ b/macOS/Synapse/FolderAppearance.swift @@ -0,0 +1,99 @@ +import SwiftUI + +// MARK: - Pastel Color Palette + +/// A named pastel color that looks good in both light and dark themes. +struct FolderColor: Identifiable, Equatable { + let id: String // key stored in settings + let label: String + let color: Color +} + +extension FolderColor { + /// The full predefined palette — 12 pastels. + static let palette: [FolderColor] = [ + FolderColor(id: "rose", label: "Rose", color: Color(hex: "#F4ACAC")!), + FolderColor(id: "peach", label: "Peach", color: Color(hex: "#F4C4A4")!), + FolderColor(id: "honey", label: "Honey", color: Color(hex: "#F4DFA4")!), + FolderColor(id: "sage", label: "Sage", color: Color(hex: "#B4E4B4")!), + FolderColor(id: "mint", label: "Mint", color: Color(hex: "#B4F4D4")!), + FolderColor(id: "teal", label: "Teal", color: Color(hex: "#A4D4E4")!), + FolderColor(id: "sky", label: "Sky", color: Color(hex: "#A4C4F4")!), + FolderColor(id: "lavender", label: "Lavender", color: Color(hex: "#C4B4F4")!), + FolderColor(id: "violet", label: "Violet", color: Color(hex: "#E4B4F4")!), + FolderColor(id: "blush", label: "Blush", color: Color(hex: "#F4B4D4")!), + FolderColor(id: "sand", label: "Sand", color: Color(hex: "#E4D4B4")!), + ] + + static func color(for id: String) -> FolderColor? { + palette.first { $0.id == id } + } +} + +// MARK: - Folder Icon Set + +/// A named SF Symbol outline icon for folders. +struct FolderIcon: Identifiable, Equatable { + let id: String // key stored in settings + let label: String + let symbolName: String // SF Symbol name +} + +extension FolderIcon { + /// 20 outlined SF Symbol icons. + static let set: [FolderIcon] = [ + FolderIcon(id: "star", label: "Star", symbolName: "star"), + FolderIcon(id: "heart", label: "Heart", symbolName: "heart"), + FolderIcon(id: "bookmark", label: "Bookmark", symbolName: "bookmark"), + FolderIcon(id: "tag", label: "Tag", symbolName: "tag"), + FolderIcon(id: "bolt", label: "Bolt", symbolName: "bolt"), + FolderIcon(id: "flame", label: "Flame", symbolName: "flame"), + FolderIcon(id: "leaf", label: "Leaf", symbolName: "leaf"), + FolderIcon(id: "moon", label: "Moon", symbolName: "moon"), + FolderIcon(id: "sun", label: "Sun", symbolName: "sun.max"), + FolderIcon(id: "cloud", label: "Cloud", symbolName: "cloud"), + FolderIcon(id: "drop", label: "Drop", symbolName: "drop"), + FolderIcon(id: "atom", label: "Atom", symbolName: "atom"), + FolderIcon(id: "briefcase", label: "Briefcase", symbolName: "briefcase"), + FolderIcon(id: "camera", label: "Camera", symbolName: "camera"), + FolderIcon(id: "music", label: "Music", symbolName: "music.note"), + FolderIcon(id: "book", label: "Book", symbolName: "book.closed"), + FolderIcon(id: "pencil", label: "Pencil", symbolName: "pencil"), + FolderIcon(id: "lightbulb", label: "Lightbulb", symbolName: "lightbulb"), + FolderIcon(id: "brain", label: "Brain", symbolName: "brain"), + FolderIcon(id: "chart", label: "Chart", symbolName: "chart.bar"), + FolderIcon(id: "robot", label: "Robot", symbolName: "cpu"), + FolderIcon(id: "mobile", label: "Mobile", symbolName: "iphone"), + FolderIcon(id: "people", label: "People", symbolName: "person.2"), + FolderIcon(id: "person", label: "Person", symbolName: "person"), + FolderIcon(id: "calendar", label: "Calendar", symbolName: "calendar"), + FolderIcon(id: "wrench", label: "Wrench", symbolName: "wrench"), + ] + + static func icon(for id: String) -> FolderIcon? { + set.first { $0.id == id } + } +} + +// MARK: - Folder Appearance Model + +/// Per-folder color + icon customization, stored relative to vault root for portability. +struct FolderAppearance: Codable, Equatable, Identifiable { + var id: String { relativePath } + /// Path relative to vault root (e.g. "Projects/Work"). + let relativePath: String + /// Key into `FolderColor.palette`, nil means default accent color. + var colorKey: String? + /// Key into `FolderIcon.set`, nil means default folder icon. + var iconKey: String? + + var resolvedColor: Color? { + guard let key = colorKey else { return nil } + return FolderColor.color(for: key)?.color + } + + var resolvedSymbolName: String? { + guard let key = iconKey else { return nil } + return FolderIcon.icon(for: key)?.symbolName + } +} diff --git a/macOS/Synapse/FolderAppearancePicker.swift b/macOS/Synapse/FolderAppearancePicker.swift new file mode 100644 index 0000000..1eac079 --- /dev/null +++ b/macOS/Synapse/FolderAppearancePicker.swift @@ -0,0 +1,213 @@ +import SwiftUI + +/// Sheet that lets the user pick a background color and icon for a folder. +struct FolderAppearancePicker: View { + @EnvironmentObject var appState: AppState + let folderURL: URL + let onDismiss: () -> Void + + @State private var selectedColorKey: String? + @State private var selectedIconKey: String? + + // 4-column grid + private let columns = Array(repeating: GridItem(.flexible(), spacing: 10), count: 4) + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // MARK: Header + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Folder Appearance") + .font(.system(size: 15, weight: .semibold, design: .rounded)) + .foregroundStyle(SynapseTheme.textPrimary) + Text(folderURL.lastPathComponent) + .font(.system(size: 12, weight: .regular, design: .rounded)) + .foregroundStyle(SynapseTheme.textMuted) + } + Spacer() + Button(action: { onDismiss() }) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 18)) + .foregroundStyle(SynapseTheme.textMuted) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 12) + + Divider().opacity(0.4) + + ScrollView { + VStack(alignment: .leading, spacing: 20) { + // MARK: Color section + VStack(alignment: .leading, spacing: 10) { + sectionHeader("Color", systemImage: "paintpalette") + + LazyVGrid(columns: columns, spacing: 10) { + // "None" swatch + colorSwatch(key: nil, color: SynapseTheme.row) + + ForEach(FolderColor.palette) { fc in + colorSwatch(key: fc.id, color: fc.color) + } + } + } + + Divider().opacity(0.4) + + // MARK: Icon section + VStack(alignment: .leading, spacing: 10) { + sectionHeader("Icon", systemImage: "square.grid.3x3") + + LazyVGrid(columns: columns, spacing: 10) { + // "None" icon swatch + iconSwatch(key: nil, symbolName: "folder.fill") + + ForEach(FolderIcon.set) { fi in + iconSwatch(key: fi.id, symbolName: fi.symbolName) + } + } + } + + // MARK: Action buttons + HStack(spacing: 10) { + Button("Clear") { + appState.clearFolderAppearance(for: folderURL) + onDismiss() + } + .buttonStyle(OutlineButtonStyle()) + + Spacer() + + Button("Apply") { + appState.setFolderAppearance( + FolderAppearance( + relativePath: appState.relativePath(for: folderURL) ?? folderURL.lastPathComponent, + colorKey: selectedColorKey, + iconKey: selectedIconKey + ), + for: folderURL + ) + onDismiss() + } + .buttonStyle(PrimaryButtonStyle()) + } + .padding(.top, 4) + } + .padding(16) + } + } + .frame(width: 320) + .background(SynapseTheme.panel) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .onAppear { + let current = appState.folderAppearance(for: folderURL) + selectedColorKey = current?.colorKey + selectedIconKey = current?.iconKey + } + } + + // MARK: - Subviews + + @ViewBuilder + private func sectionHeader(_ title: String, systemImage: String) -> some View { + HStack(spacing: 6) { + Image(systemName: systemImage) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(SynapseTheme.textMuted) + Text(title.uppercased()) + .font(.system(size: 10, weight: .semibold, design: .rounded)) + .tracking(0.8) + .foregroundStyle(SynapseTheme.textMuted) + } + } + + @ViewBuilder + private func colorSwatch(key: String?, color: Color) -> some View { + let isSelected = selectedColorKey == key + Button { + selectedColorKey = key + } label: { + ZStack { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(color) + .frame(height: 36) + + if key == nil { + // "None" indicator + Image(systemName: "slash.circle") + .font(.system(size: 14)) + .foregroundStyle(SynapseTheme.textMuted.opacity(0.6)) + } + + if isSelected { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(SynapseTheme.accent, lineWidth: 2) + Image(systemName: "checkmark") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(SynapseTheme.accent) + } else { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(SynapseTheme.textMuted.opacity(0.15), lineWidth: 1) + } + } + } + .buttonStyle(.plain) + } + + @ViewBuilder + private func iconSwatch(key: String?, symbolName: String) -> some View { + let isSelected = selectedIconKey == key + let bg: Color = isSelected ? SynapseTheme.accent.opacity(0.18) : SynapseTheme.row + let fg: Color = isSelected ? SynapseTheme.accent : SynapseTheme.textMuted + let strokeColor: Color = isSelected ? SynapseTheme.accent : SynapseTheme.textMuted.opacity(0.15) + + Button { + selectedIconKey = key + } label: { + ZStack { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(bg) + .frame(height: 36) + .overlay { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(strokeColor, lineWidth: isSelected ? 2 : 1) + } + Image(systemName: symbolName) + .font(.system(size: key == nil ? 14 : 14)) + .foregroundStyle(fg) + } + } + .buttonStyle(.plain) + } +} + +// MARK: - Button Styles + +private struct PrimaryButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + .padding(.horizontal, 16) + .padding(.vertical, 7) + .background(SynapseTheme.accent, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .opacity(configuration.isPressed ? 0.8 : 1) + } +} + +private struct OutlineButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 13, weight: .medium, design: .rounded)) + .foregroundStyle(SynapseTheme.textSecondary) + .padding(.horizontal, 16) + .padding(.vertical, 7) + .background { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(SynapseTheme.textMuted.opacity(0.3), lineWidth: 1) + } + .opacity(configuration.isPressed ? 0.7 : 1) + } +} diff --git a/macOS/Synapse/SettingsManager.swift b/macOS/Synapse/SettingsManager.swift index dab276d..74b427e 100644 --- a/macOS/Synapse/SettingsManager.swift +++ b/macOS/Synapse/SettingsManager.swift @@ -316,6 +316,11 @@ class SettingsManager: ObservableObject { didSet { save() } } + /// Per-folder background color and icon customizations, keyed by vault-relative path. + @Published var folderAppearances: [FolderAppearance] { + didSet { save() } + } + // MARK: - Computed theme properties /// The resolved active theme. Falls back to Synapse (Dark) if the name is unknown. @@ -569,6 +574,7 @@ class SettingsManager: ObservableObject { var respectGitignore: Bool? var activeThemeName: String? var customThemes: [AppTheme]? + var folderAppearances: [FolderAppearance]? init( onBootCommand: String, @@ -593,7 +599,8 @@ class SettingsManager: ObservableObject { editorLineHeight: Double? = nil, respectGitignore: Bool? = nil, activeThemeName: String? = nil, - customThemes: [AppTheme]? = nil + customThemes: [AppTheme]? = nil, + folderAppearances: [FolderAppearance]? = nil ) { self.onBootCommand = onBootCommand self.fileExtensionFilter = fileExtensionFilter @@ -618,6 +625,7 @@ class SettingsManager: ObservableObject { self.respectGitignore = respectGitignore self.activeThemeName = activeThemeName self.customThemes = customThemes + self.folderAppearances = folderAppearances } init(from decoder: Decoder) throws { @@ -645,6 +653,7 @@ class SettingsManager: ObservableObject { respectGitignore = try container.decodeIfPresent(Bool.self, forKey: .respectGitignore) activeThemeName = try container.decodeIfPresent(String.self, forKey: .activeThemeName) customThemes = try container.decodeIfPresent([AppTheme].self, forKey: .customThemes) + folderAppearances = try container.decodeIfPresent([FolderAppearance].self, forKey: .folderAppearances) } } @@ -743,6 +752,7 @@ class SettingsManager: ObservableObject { self.respectGitignore = true self.activeThemeName = "Synapse (Dark)" self.customThemes = [] + self.folderAppearances = [] applyLegacyConfig(Self.loadConfig(from: configPath)) self.isInitializing = false @@ -800,6 +810,7 @@ class SettingsManager: ObservableObject { self.respectGitignore = true self.activeThemeName = "Synapse (Dark)" self.customThemes = [] + self.folderAppearances = [] if let vaultRoot = vaultRoot { // Create .synapse folder and settings file if they don't exist @@ -931,6 +942,7 @@ class SettingsManager: ObservableObject { respectGitignore = vaultConfig.respectGitignore ?? true activeThemeName = vaultConfig.activeThemeName ?? "Synapse (Dark)" customThemes = vaultConfig.customThemes ?? [] + folderAppearances = vaultConfig.folderAppearances ?? [] return } @@ -956,6 +968,7 @@ class SettingsManager: ObservableObject { respectGitignore = true activeThemeName = "Synapse (Dark)" customThemes = [] + folderAppearances = [] } private func applyNoVaultDefaults() { @@ -979,6 +992,7 @@ class SettingsManager: ObservableObject { editorFontSize = 15 editorLineHeight = 1.6 respectGitignore = true + folderAppearances = [] } private func applyGlobalConfig(_ globalConfig: GlobalConfig?) { @@ -1179,6 +1193,7 @@ class SettingsManager: ObservableObject { let respectGitignore: Bool let activeThemeName: String let customThemes: [AppTheme] + let folderAppearances: [FolderAppearance] init(from s: SettingsManager) { useLegacyMode = s.useLegacyMode @@ -1216,6 +1231,7 @@ class SettingsManager: ObservableObject { respectGitignore = s.respectGitignore activeThemeName = s.activeThemeName customThemes = s.customThemes + folderAppearances = s.folderAppearances } func write() { @@ -1340,7 +1356,8 @@ class SettingsManager: ObservableObject { editorLineHeight: editorLineHeight == 1.6 ? nil : editorLineHeight, respectGitignore: respectGitignore ? nil : false, // omit when true (default) activeThemeName: activeThemeName == "Synapse (Dark)" ? nil : activeThemeName, - customThemes: customThemes.isEmpty ? nil : customThemes + customThemes: customThemes.isEmpty ? nil : customThemes, + folderAppearances: folderAppearances.isEmpty ? nil : folderAppearances ) let notedDir = vaultRootURL.appendingPathComponent(".synapse") try? FileManager.default.createDirectory(at: notedDir, withIntermediateDirectories: true) diff --git a/macOS/SynapseTests/FlatFolderNavigatorTests.swift b/macOS/SynapseTests/FlatFolderNavigatorTests.swift new file mode 100644 index 0000000..2330875 --- /dev/null +++ b/macOS/SynapseTests/FlatFolderNavigatorTests.swift @@ -0,0 +1,349 @@ +import XCTest +@testable import Synapse + +/// Tests for the flat folder navigator feature (Issue #200). +/// +/// Covers: +/// - Flat navigation state management (current directory tracking) +/// - Navigation into folders (drill-down behavior) +/// - Back button navigation (navigate up one level) +/// - Pinned folders as drop targets for drag-and-drop +/// - Drag-over-back-button navigation (hover to navigate up during drag) +/// - Flat view shows only current directory contents (no tree indentation) +final class FlatFolderNavigatorTests: XCTestCase { + + var sut: AppState! + var tempDir: URL! + var folderA: URL! + var folderB: URL! + var subfolder: URL! + var file1: URL! + var file2: URL! + + override func setUp() { + super.setUp() + sut = AppState() + tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try! FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + + // Create test folder structure: + // /tempDir/ + // folder-a/ + // file1.md + // subfolder/ + // file2.md + // folder-b/ + // (empty) + folderA = makeFolder(named: "folder-a") + folderB = makeFolder(named: "folder-b") + subfolder = makeFolder(named: "subfolder", in: folderA) + file1 = makeFile(named: "file1.md", in: folderA) + file2 = makeFile(named: "file2.md", in: subfolder) + + sut.openFolder(tempDir) + } + + override func tearDown() { + try? FileManager.default.removeItem(at: tempDir) + sut = nil + super.tearDown() + } + + // MARK: - Helpers + + private func makeFile(named name: String, in directory: URL? = nil) -> URL { + let dir = directory ?? tempDir! + let url = dir.appendingPathComponent(name) + FileManager.default.createFile(atPath: url.path, contents: "content".data(using: .utf8)) + return url + } + + private func makeFolder(named name: String, in directory: URL? = nil) -> URL { + let dir = directory ?? tempDir! + let url = dir.appendingPathComponent(name, isDirectory: true) + try! FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + // MARK: - Flat Navigation State Tests + + func test_initialState_currentDirectoryIsRoot() { + // Initially, current directory should return root (even though the stored value may be nil) + // When flatNavigatorCurrentDirectory is nil, it defaults to rootURL + let expectedRoot = tempDir.standardizedFileURL + let actual = sut.flatNavigatorCurrentDirectory?.standardizedFileURL ?? expectedRoot + XCTAssertEqual(actual, expectedRoot, + "Initial current directory should default to the root") + } + + func test_navigateToFolder_updatesCurrentDirectory() { + // When navigating to a folder + sut.navigateToFolder(folderA) + + // Then current directory should be that folder + XCTAssertEqual(sut.flatNavigatorCurrentDirectory?.standardizedFileURL, folderA.standardizedFileURL, + "Current directory should update to the navigated folder") + } + + func test_navigateToFolder_canNavigateMultipleLevels() { + // Navigate to folder-a + sut.navigateToFolder(folderA) + XCTAssertEqual(sut.flatNavigatorCurrentDirectory?.standardizedFileURL, folderA.standardizedFileURL) + + // Navigate deeper into subfolder + sut.navigateToFolder(subfolder) + XCTAssertEqual(sut.flatNavigatorCurrentDirectory?.standardizedFileURL, subfolder.standardizedFileURL, + "Should be able to navigate multiple levels deep") + } + + // MARK: - Back Button Navigation Tests + + func test_canNavigateBack_isFalseAtRoot() { + // At root level, should not be able to navigate back + XCTAssertFalse(sut.canNavigateBackInFlatNavigator, + "Should not be able to navigate back when at root") + } + + func test_canNavigateBack_isTrueInSubfolder() { + // Navigate to a subfolder + sut.navigateToFolder(folderA) + + // Then should be able to navigate back + XCTAssertTrue(sut.canNavigateBackInFlatNavigator, + "Should be able to navigate back when in a subfolder") + } + + func test_navigateBack_goesToParentDirectory() { + // Given we're in subfolder + sut.navigateToFolder(folderA) + sut.navigateToFolder(subfolder) + XCTAssertEqual(sut.flatNavigatorCurrentDirectory?.standardizedFileURL, subfolder.standardizedFileURL) + + // When navigating back + sut.navigateBackInFlatNavigator() + + // Then should be in parent directory + XCTAssertEqual(sut.flatNavigatorCurrentDirectory?.standardizedFileURL, folderA.standardizedFileURL, + "Navigate back should go to parent directory") + } + + func test_navigateBack_multipleTimesGoesToRoot() { + // Given we're deep in the hierarchy + sut.navigateToFolder(folderA) + sut.navigateToFolder(subfolder) + + // Navigate back once + sut.navigateBackInFlatNavigator() + XCTAssertEqual(sut.flatNavigatorCurrentDirectory?.standardizedFileURL, folderA.standardizedFileURL) + + // Navigate back again + sut.navigateBackInFlatNavigator() + XCTAssertEqual(sut.flatNavigatorCurrentDirectory?.standardizedFileURL, tempDir.standardizedFileURL, + "Multiple navigate back should reach root") + } + + func test_navigateBack_atRoot_doesNothing() { + // Given we're at root (explicitly set it) + sut.navigateToRootInFlatNavigator() + let expectedRoot = tempDir.standardizedFileURL + XCTAssertEqual(sut.flatNavigatorCurrentDirectory?.standardizedFileURL, expectedRoot) + + // When trying to navigate back at root + sut.navigateBackInFlatNavigator() + + // Should stay at root + XCTAssertEqual(sut.flatNavigatorCurrentDirectory?.standardizedFileURL, expectedRoot, + "Navigate back at root should stay at root") + } + + // MARK: - Flat View Content Tests + + func test_flatViewContents_returnsCurrentDirectoryContents() { + // Given we're in folder-a + sut.navigateToFolder(folderA) + + // When getting flat view contents + let contents = sut.flatNavigatorCurrentContents + + // Then should contain folder-a's contents (not root contents) + let contentPaths = contents.map { $0.standardizedFileURL.path } + XCTAssertTrue(contentPaths.contains(subfolder.standardizedFileURL.path), + "Should contain subfolder") + XCTAssertTrue(contentPaths.contains(file1.standardizedFileURL.path), + "Should contain file1") + XCTAssertFalse(contentPaths.contains(folderB.standardizedFileURL.path), + "Should NOT contain folderB (it's in root)") + } + + func test_flatViewContents_atRoot_showsRootContents() { + // When at root + let contents = sut.flatNavigatorCurrentContents + + // Should show root-level items + let contentPaths = contents.map { $0.standardizedFileURL.path } + XCTAssertTrue(contentPaths.contains(folderA.standardizedFileURL.path), + "Root view should contain folder-a") + XCTAssertTrue(contentPaths.contains(folderB.standardizedFileURL.path), + "Root view should contain folder-b") + XCTAssertFalse(contentPaths.contains(subfolder.standardizedFileURL.path), + "Root view should NOT contain nested items") + } + + // MARK: - Pinned Folder Drop Target Tests + + func test_pinnedFolderCanAcceptDrop() { + // Pin folderA + sut.pinItem(folderA) + + // folderA should be a valid drop target + let pinnedItem = sut.pinnedItems.first! + XCTAssertTrue(pinnedItem.isFolder, "Pinned item should be a folder") + XCTAssertNotNil(pinnedItem.url, "Pinned folder should have a URL") + } + + func test_dropFileOntoPinnedFolder_movesFile() throws { + // Pin folderB + sut.pinItem(folderB) + let pinnedItem = sut.pinnedItems.first! + + // Create a file to drop + let fileToMove = makeFile(named: "droptest.md") + + // When dropping file onto pinned folder + let result = try sut.dropFile(fileToMove, ontoPinnedItem: pinnedItem) + + // Then file should be moved to that folder + XCTAssertEqual(result.deletingLastPathComponent().standardizedFileURL, folderB.standardizedFileURL, + "File should be moved to pinned folder") + XCTAssertFalse(FileManager.default.fileExists(atPath: fileToMove.path), + "Original file should no longer exist") + } + + func test_dropFileOntoPinnedFile_throwsError() throws { + // Pin a file (not a folder) + let pinnedFile = makeFile(named: "pinned-note.md") + sut.pinItem(pinnedFile) + let pinnedItem = sut.pinnedItems.first! + + // Create a file to try to drop + let fileToMove = makeFile(named: "droptest.md") + + // When trying to drop onto a pinned file (not folder) + // Should throw an error + XCTAssertThrowsError(try sut.dropFile(fileToMove, ontoPinnedItem: pinnedItem)) { error in + XCTAssertEqual(error as? FileBrowserError, .operationFailed("Target is not a folder")) + } + } + + func test_dropFileOntoPinnedTag_throwsError() throws { + // Pin a tag + sut.pinTag("test-tag") + let pinnedItem = sut.pinnedItems.first! + + // Create a file to try to drop + let fileToMove = makeFile(named: "droptest.md") + + // When trying to drop onto a pinned tag + // Should throw an error + XCTAssertThrowsError(try sut.dropFile(fileToMove, ontoPinnedItem: pinnedItem)) { error in + XCTAssertEqual(error as? FileBrowserError, .operationFailed("Target is not a folder")) + } + } + + // MARK: - Drag-Over-Back-Button Navigation Tests + + func test_dragHoverOverBackButton_schedulesNavigationUp() { + // Given we're in a subfolder + sut.navigateToFolder(folderA) + let initialDir = sut.flatNavigatorCurrentDirectory + + // When drag hover begins over back button + sut.flatNavigatorBackButtonDragHoverStarted() + + // Should schedule navigation (we'll need to wait for the timer in real implementation) + // For testing, we check that the hover state is tracked + XCTAssertTrue(sut.flatNavigatorBackButtonIsDragHovering, + "Should track drag hover state over back button") + } + + func test_dragHoverEndOverBackButton_cancelsNavigation() { + // Given drag hover is active + sut.navigateToFolder(folderA) + sut.flatNavigatorBackButtonDragHoverStarted() + XCTAssertTrue(sut.flatNavigatorBackButtonIsDragHovering) + + // When drag hover ends + sut.flatNavigatorBackButtonDragHoverEnded() + + // Should cancel the hover state + XCTAssertFalse(sut.flatNavigatorBackButtonIsDragHovering, + "Should clear drag hover state") + XCTAssertEqual(sut.flatNavigatorCurrentDirectory?.standardizedFileURL, folderA.standardizedFileURL, + "Should NOT have navigated back when hover ended") + } + + func test_navigateToRoot_setsCurrentDirectoryToRoot() { + // Given we're deep in the hierarchy + sut.navigateToFolder(folderA) + sut.navigateToFolder(subfolder) + XCTAssertEqual(sut.flatNavigatorCurrentDirectory?.standardizedFileURL, subfolder.standardizedFileURL) + + // When navigating to root + sut.navigateToRootInFlatNavigator() + + // Should be at root + XCTAssertEqual(sut.flatNavigatorCurrentDirectory?.standardizedFileURL, tempDir.standardizedFileURL, + "Navigate to root should reset to root directory") + } + + func test_flatNavigatorPath_tracksNavigationHistory() { + // Initially path should just contain root + XCTAssertEqual(sut.flatNavigatorPath.count, 1) + XCTAssertEqual(sut.flatNavigatorPath.first?.standardizedFileURL, tempDir.standardizedFileURL) + + // Navigate to folder-a + sut.navigateToFolder(folderA) + XCTAssertEqual(sut.flatNavigatorPath.count, 2) + XCTAssertEqual(sut.flatNavigatorPath.last?.standardizedFileURL, folderA.standardizedFileURL) + + // Navigate to subfolder + sut.navigateToFolder(subfolder) + XCTAssertEqual(sut.flatNavigatorPath.count, 3) + XCTAssertEqual(sut.flatNavigatorPath.last?.standardizedFileURL, subfolder.standardizedFileURL) + + // Navigate back + sut.navigateBackInFlatNavigator() + XCTAssertEqual(sut.flatNavigatorPath.count, 2) + XCTAssertEqual(sut.flatNavigatorPath.last?.standardizedFileURL, folderA.standardizedFileURL) + } + + func test_flatNavigatorCurrentDirectoryName_showsFolderName() { + // At root, should show vault name + sut.flatNavigatorCurrentDirectory = tempDir + XCTAssertEqual(sut.flatNavigatorCurrentDirectoryName, tempDir.lastPathComponent) + + // In folder, should show that folder's name + sut.flatNavigatorCurrentDirectory = folderA + XCTAssertEqual(sut.flatNavigatorCurrentDirectoryName, "folder-a") + + // In subfolder + sut.flatNavigatorCurrentDirectory = subfolder + XCTAssertEqual(sut.flatNavigatorCurrentDirectoryName, "subfolder") + } + + func test_navigateBack_updatesSelectedFileIfNeeded() { + // Open a file in subfolder + sut.openFile(file2) + XCTAssertEqual(sut.selectedFile?.standardizedFileURL, file2.standardizedFileURL) + + // Navigate up + sut.navigateToFolder(folderA) + sut.navigateToFolder(subfolder) + sut.navigateBackInFlatNavigator() + + // The selected file should still be valid (pointing to new location if moved, or unchanged) + // This test documents expected behavior - actual implementation may vary + XCTAssertNotNil(sut.selectedFile, "Selected file should still be tracked after navigation") + } +} diff --git a/marketing-site/docs/images/customize.png b/marketing-site/docs/images/customize.png new file mode 100644 index 0000000..452635f Binary files /dev/null and b/marketing-site/docs/images/customize.png differ diff --git a/marketing-site/docs/index.md b/marketing-site/docs/index.md index 8b723c5..2c617c7 100644 --- a/marketing-site/docs/index.md +++ b/marketing-site/docs/index.md @@ -41,6 +41,8 @@ Synapse packs a robust set of features to boost your productivity. - **Paste HTML as Markdown:** Copy content from any website and paste it directly into Synapse — it's automatically converted to clean Markdown. - **View History:** Access the View History button in the file editor to see previous versions of your note. +![](images/customize.png) + ### Navigation - **Tabs:** Cycle through your most recently used (MRU) tabs seamlessly. - **Split Panes:** Work efficiently by splitting your editor vertically or horizontally.