From a4ea141a1216961766436325e49b9ee9c4b9a76a Mon Sep 17 00:00:00 2001 From: galz10 Date: Tue, 24 Mar 2026 18:59:46 -0700 Subject: [PATCH 1/4] feat(updates): add auto-update service and appcast tooling --- idx0.xcodeproj/project.pbxproj | 213 +++++++++------ .../xcshareddata/swiftpm/Package.resolved | 15 ++ ...oordinator+ShortcutCommandDispatcher.swift | 3 + idx0/App/AppCoordinator.swift | 13 + idx0/App/AppSettings.swift | 10 +- idx0/App/idx0App.swift | 15 ++ idx0/Keyboard/ShortcutActionID.swift | 1 + idx0/Keyboard/ShortcutRegistry.swift | 11 + idx0/Services/Updates/AppUpdateModels.swift | 99 +++++++ idx0/Services/Updates/AppUpdateReducer.swift | 78 ++++++ idx0/Services/Updates/AppUpdateService.swift | 175 ++++++++++++ idx0/Services/Updates/AppUpdateSupport.swift | 148 ++++++++++ .../Services/Updates/AppcastFeedBuilder.swift | 140 ++++++++++ .../Updates/SparkleUpdateDriver.swift | 210 +++++++++++++++ idx0/UI/CommandPaletteOverlay.swift | 11 + idx0/UI/MainWindow/TabBarOverlay.swift | 67 ++++- .../Inline/InlineAdvancedSettings.swift | 49 ++++ .../Settings/Tabs/AdvancedSettingsTab.swift | 33 +++ idx0Tests/AppCommandRegistryTests.swift | 8 + idx0Tests/AppSettingsKeyboardTests.swift | 3 + idx0Tests/AppUpdateActionMapperTests.swift | 23 ++ idx0Tests/AppUpdateReducerTests.swift | 96 +++++++ idx0Tests/AppUpdateServiceTests.swift | 254 ++++++++++++++++++ idx0Tests/AppcastScriptTests.swift | 44 +++ .../Keyboard/ShortcutRegistryTests.swift | 8 + project.yml | 8 + scripts/generate-appcast.sh | 240 +++++++++++++++++ scripts/manual-release.sh | 29 +- scripts/publish-appcast.sh | 207 ++++++++++++++ 29 files changed, 2122 insertions(+), 89 deletions(-) create mode 100644 idx0.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 idx0/Services/Updates/AppUpdateModels.swift create mode 100644 idx0/Services/Updates/AppUpdateReducer.swift create mode 100644 idx0/Services/Updates/AppUpdateService.swift create mode 100644 idx0/Services/Updates/AppUpdateSupport.swift create mode 100644 idx0/Services/Updates/AppcastFeedBuilder.swift create mode 100644 idx0/Services/Updates/SparkleUpdateDriver.swift create mode 100644 idx0Tests/AppUpdateActionMapperTests.swift create mode 100644 idx0Tests/AppUpdateReducerTests.swift create mode 100644 idx0Tests/AppUpdateServiceTests.swift create mode 100644 idx0Tests/AppcastScriptTests.swift create mode 100755 scripts/generate-appcast.sh create mode 100755 scripts/publish-appcast.sh diff --git a/idx0.xcodeproj/project.pbxproj b/idx0.xcodeproj/project.pbxproj index 1e1c0a5..cb15ffc 100644 --- a/idx0.xcodeproj/project.pbxproj +++ b/idx0.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ 0DB0C74997CFC15B9535105D /* SessionCreationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C9A58C93C0BA47A57C70B5 /* SessionCreationResult.swift */; }; 0FB090602494B2B0F0F4823B /* terminal-themes.json in Resources */ = {isa = PBXBuildFile; fileRef = 57FC8B1654F989A31FC64476 /* terminal-themes.json */; }; 1126AF1C9E126EABC088B50C /* ShortcutRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF02E5083B28A0C54E599BF2 /* ShortcutRegistry.swift */; }; + 123F3493042A939B4B88AA96 /* AppUpdateModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFBFE1CD0AF73B0C348164EB /* AppUpdateModels.swift */; }; 12E88F91FCDA7BC82CEF2C57 /* SessionContainerView+NiriTiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42954794B6E7533BCCF88CC3 /* SessionContainerView+NiriTiles.swift */; }; 131CEB0DEE775C1C144BFDAB /* SessionService+NiriCanvasOps.swift in Sources */ = {isa = PBXBuildFile; fileRef = 769DAB2595E31721E2C06C50 /* SessionService+NiriCanvasOps.swift */; }; 137E9BBDEACCB42A8B5729C5 /* SessionSidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0884C8A5D1749BC2E9960939 /* SessionSidebarView.swift */; }; @@ -30,6 +31,7 @@ 14D7D7D82D5BB685BDCE5851 /* excalidraw-build-manifest.json in Resources */ = {isa = PBXBuildFile; fileRef = DFEC4330AF1DE7C978BA59E8 /* excalidraw-build-manifest.json */; }; 171E27C515BB614D1D8283B6 /* InlineSafetySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBE452CDCBD97B412A836AE6 /* InlineSafetySettings.swift */; }; 18FBDF897EAC7B0F4E8503FD /* SessionContainerView+NiriDragResize.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9A3B6B056A35C674DB49A2E /* SessionContainerView+NiriDragResize.swift */; }; + 1A0F2E8D668D683B8867EA6A /* AppUpdateReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E114055EBA3DF5BBC2A415B /* AppUpdateReducer.swift */; }; 1A4F10D868A0E1ADE5022C32 /* Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = E040B0FC30190D925785844A /* Session.swift */; }; 1A532D3B915A03ADAE33F692 /* GhosttyTerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF32A5CDE91085A45FAEF298 /* GhosttyTerminalView.swift */; }; 1C72135109AB0ADCE4FF8352 /* IPCCommandRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02AC2552BD0D866484FFA51B /* IPCCommandRouter.swift */; }; @@ -42,7 +44,6 @@ 260C5CC03FCDD41F93EA7867 /* InlineSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B5460029DE37A1C42C476E9 /* InlineSettingsView.swift */; }; 26FA772FB59719EE96B56CD4 /* SessionModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0E869F53140CDC4C857F1C4 /* SessionModels.swift */; }; 275AE7A80AD803D3E65870CC /* GhosttyAppHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8575A62595B953CB87681D3E /* GhosttyAppHost.swift */; }; - 2AE9DE55597E704B7B99B0EF /* NiriTileSpotlight.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FA0B16E5CCCBFB1FAA5620C /* NiriTileSpotlight.swift */; }; 2C239931857F2B7586F7AD86 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9962C2537A206B836AAA1CE /* AppSettings.swift */; }; 2E56891B5B216C3D8A39510F /* AppearanceSettingsTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F9C7E86A82614D71AC3D0D9 /* AppearanceSettingsTab.swift */; }; 2EE1C95E4D9823EA95497533 /* idx0.icns.backup-20260322-075809 in Resources */ = {isa = PBXBuildFile; fileRef = 150785B3AEE2598ED5427E3D /* idx0.icns.backup-20260322-075809 */; }; @@ -53,10 +54,13 @@ 31A5A9CF7BD7E948863873C0 /* SessionContainerView+DotGridBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A191151AD26769E8CDA81EC /* SessionContainerView+DotGridBackground.swift */; }; 32B3F51F8DA85538A8809945 /* VibeCLITool.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E0653D39F91E2565C8E6CD3 /* VibeCLITool.swift */; }; 32D4F0906148D849D01C66AD /* WorktreeInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1775F64883C385AC9E7EEA /* WorktreeInfo.swift */; }; + 348C60AE1905ADD12C9E4137 /* AppUpdateReducerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 092385479C2D4B5510106B64 /* AppUpdateReducerTests.swift */; }; 35036CCAD106D88964E5BDF2 /* SessionService+LayoutPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 056B07A95FE3CA67B2B2271D /* SessionService+LayoutPersistence.swift */; }; 36BEF74F6B08D19650654337 /* MainWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CEBF25DC90157CBD3474018 /* MainWindowView.swift */; }; + 39DCAD54703D104C31FF9C7C /* SparkleUpdateDriver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A68C133C38FD7A76EE002417 /* SparkleUpdateDriver.swift */; }; 3CF158624E7F01DDB5E8350D /* ShortcutDispatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F221307A99F88DEEBAC4E3 /* ShortcutDispatcher.swift */; }; 3D8560BC744B6AB4A7866143 /* SessionService+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3289229AFB744281FC5DEFEF /* SessionService+Utilities.swift */; }; + 3DC1365CE260E5D8A9BDDC45 /* AppUpdateService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E86661A2CCBDD15FD47600D8 /* AppUpdateService.swift */; }; 3EB35636AF5FF0DC4C2DF8F9 /* SessionRestoreCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04A5622E3348C3DBA98663A6 /* SessionRestoreCoordinator.swift */; }; 3EF6ECE428D7E215E9DD613B /* VibeCLIDiscoveryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDC139CC9450EA9A8A81A2AD /* VibeCLIDiscoveryService.swift */; }; 3F370869C5D7E4FFB73FE800 /* SessionContainerView+NiriItemViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77D07E1D74241531F78C9443 /* SessionContainerView+NiriItemViews.swift */; }; @@ -68,12 +72,14 @@ 494C890E5589D1B119EBD88A /* BootstrapCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895476111B258993B0FEA484 /* BootstrapCoordinator.swift */; }; 4F6196992E7F66DA2186660E /* WorkflowService+Collaboration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CF5961CD0CE1C041063401E /* WorkflowService+Collaboration.swift */; }; 513164D96822F28A6CA0C613 /* AppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D8BC9E84CE6A06A49953DFF /* AppCoordinator.swift */; }; + 51335F167FAAB60880BD963A /* AppUpdateServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7757B8E1B8B425D0DE78C5E /* AppUpdateServiceTests.swift */; }; 51FAF64104FF11398A50EA09 /* WorkflowService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9D80D208AF87981378D8861 /* WorkflowService.swift */; }; 5223471DFFD9913F75C5340B /* SessionContainerView+OverviewSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C47CFFC6BEC30EC1BF35A8C /* SessionContainerView+OverviewSnapshot.swift */; }; 534ADFC85488135D61AE5A30 /* Debouncer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC9EB9F783ED62C631DAF42 /* Debouncer.swift */; }; 553B8C11C5536D6292D03BD5 /* SessionServiceTests+Launch.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5BC04FF0FF385A5961DB941 /* SessionServiceTests+Launch.swift */; }; 55ECD5BC9024D9F952EC63FD /* MultiPaneTerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BD45C1A409BBBB63FF2426F /* MultiPaneTerminalView.swift */; }; 56234CCB329323A350942A0E /* SessionService+SessionOps.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3D11360E25BF9244274C97B /* SessionService+SessionOps.swift */; }; + 5884A8FBF5C47453B469980A /* TerminalMonitorServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DF5DDE163F1A0D142C48A1 /* TerminalMonitorServiceTests.swift */; }; 58987615D32B830F852135E5 /* T3CodeRuntimeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F79E05AB6D634F53DE988F5 /* T3CodeRuntimeTests.swift */; }; 5EF9A714D8C684B843006A0E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D1D4C1C59381DEB755085208 /* Assets.xcassets */; }; 5F13BE8A667BCCFB0BA879E6 /* AgentOutputScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DC53446A59CB69B5DBD424E /* AgentOutputScanner.swift */; }; @@ -90,10 +96,13 @@ 69D61B8290DCFD28F70CBDAA /* WorkflowModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8966AEA81C75928E237AA817 /* WorkflowModels.swift */; }; 6CE7AAF4682FDAFAFECE3B7D /* WorkflowStores.swift in Sources */ = {isa = PBXBuildFile; fileRef = E348CC63163C1B52E07B8506 /* WorkflowStores.swift */; }; 6D5F242BF42126B4C201A625 /* VSCodeRuntimeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F7FCA5B2CB17DD2E1C5B0EE /* VSCodeRuntimeTests.swift */; }; + 6F6B700A605984F175B5B1ED /* AppUpdateActionMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5816C22890F8B9D231A92B79 /* AppUpdateActionMapperTests.swift */; }; + 7AAB313E7318042DD6A0131D /* SessionService+LaunchPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9AC133221097B436EBAB1F1 /* SessionService+LaunchPolicy.swift */; }; 7B5F59ED5D78EE2A951F8FBC /* AttentionCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 084CE6508D3146437C560DE3 /* AttentionCenter.swift */; }; 7BE5F38B67778F1D254B885D /* WorkflowRailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C27AA169BC08CF6EE14A8627 /* WorkflowRailView.swift */; }; 7C716586AFB06407163868B7 /* IPCServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BE3CDAF378E55CAE439A878 /* IPCServer.swift */; }; 821B4A069664F9160CAC46E1 /* SettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235013244275BB5AAB61F398 /* SettingsStore.swift */; }; + 82BA865D8D7C6861CE414F7A /* AppUpdateSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2AC98993D6DFB52E9E765F6 /* AppUpdateSupport.swift */; }; 82D049E4AC737CFE1246492E /* idx0-icon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 72CCCD57FC8E6C67F1BC4E0D /* idx0-icon.icon */; }; 8323E5B818F6AF91B355CA11 /* AutoCheckpointServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBE1EAD2776C1DBE09E2DF1B /* AutoCheckpointServiceTests.swift */; }; 8554E9BCA4F511D4B5D570A5 /* SessionLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587FDB03500BF529C7811E4D /* SessionLauncher.swift */; }; @@ -105,6 +114,7 @@ 8A81C3C80D2CD2BD1B68B79F /* KeyChord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 637F53E30E925BBEEC83DB9B /* KeyChord.swift */; }; 8BF129880FF444F438569691 /* EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C80AC018F4093657E2E44FF /* EmptyStateView.swift */; }; 8EBD5438089ADC23B4BB26F1 /* SidebarResizeHandle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D273EB3EC383B3187985031 /* SidebarResizeHandle.swift */; }; + 8EE8BCD4E98EC6C8D34F5C60 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = F53E79E457609B3BD9B87D33 /* Sparkle */; }; 8FF61E78CEF3CED33E727E96 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48ED9438C4AE6FC0DC03E5B6 /* SettingsView.swift */; }; 93DE98E542473532A19E901D /* AgentOutputScannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87226D8ADDB27F27B21E992F /* AgentOutputScannerTests.swift */; }; 94AFBD78A57A783D255100DE /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = E915E2738ADB3CF7EF3C96AC /* GhosttyKit.xcframework */; }; @@ -118,9 +128,7 @@ 9F6995C2F654B9422548C148 /* GitServiceParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90EE0DE10784650DC7094D02 /* GitServiceParsingTests.swift */; }; A0AB01333BC4220F20076085 /* ShellIntegrationHealthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AAF63EE32EAE7ED03E3F9F3 /* ShellIntegrationHealthService.swift */; }; A122A994F2782295A25D68DD /* SessionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E6EBE25C09A2F6F2E8B66E9 /* SessionStore.swift */; }; - A1F4A1B2C3D4E5F60718293A /* SessionService+LaunchPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5C /* SessionService+LaunchPolicy.swift */; }; A23649679F38533F0195A526 /* FuzzyMatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A57DFA12FE234759A547796 /* FuzzyMatch.swift */; }; - A2B3C4D5E6F708192A3B4C5D /* OpenCodeRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3D4E5F60718293A4B5C6D7E /* OpenCodeRuntime.swift */; }; A375DD04002E0A4558CAE50F /* WorktreeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3906E4FD5F3EF5BB6539B6C7 /* WorktreeService.swift */; }; A50FCD80554EC338D5BA1C99 /* idx0.icns in Resources */ = {isa = PBXBuildFile; fileRef = B5048750915F84088FED8574 /* idx0.icns */; }; A5E353B0DD8E950DD86D88F8 /* WorktreeInspectorSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EA436F0CE3DD4AD2B662DC6 /* WorktreeInspectorSheet.swift */; }; @@ -133,12 +141,12 @@ AC0E96F84AA1A2B46939E49A /* SessionContainerView+BrowserSplitResize.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37B4C15A60B8C9E450BD51 /* SessionContainerView+BrowserSplitResize.swift */; }; ADCF4292D8351F942D4DEFF3 /* SessionBrowserController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A24BF75FB7EE9A24CCF2980 /* SessionBrowserController.swift */; }; AFE3451E6F9A754B645F9435 /* SupervisionQueueServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6F8B6A69E321355348D4610 /* SupervisionQueueServiceTests.swift */; }; - B2C3D4E5F60718293A4B5C6D /* OpenCodeRuntimeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4E5F60718293A4B5C6D7E8F /* OpenCodeRuntimeTests.swift */; }; - B2F4A1B2C3D4E5F60718293A /* TerminalMonitorServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1B2C3D4E5F60718293A4B5C /* TerminalMonitorServiceTests.swift */; }; B38C2362FF1FA6D006B66388 /* MainWindowAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C6A64222CEF84C19CD92011 /* MainWindowAlerts.swift */; }; B5CAACE9CD2D4D5BDFA5BA6B /* GitMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F54BC57F8B1D33CACD3BDB /* GitMonitor.swift */; }; + B5CFE5F6459F81A8666CB315 /* RestoreLaunchQueueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1177D409C8A650B1E7C2195E /* RestoreLaunchQueueTests.swift */; }; B7B219357B0FDD7940FAE7F1 /* URLRoutingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18F76B8A80EF8F1C875F70AF /* URLRoutingService.swift */; }; B90D94F0BE0BFE26A93BAEF3 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDE658FF7D978BF701A583AD /* Logger.swift */; }; + B979DB37F81B4C1D82176614 /* OpenCodeRuntimeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9D7D0881CD90FDAA7D118A0 /* OpenCodeRuntimeTests.swift */; }; BB80B7E9B71CD45EA416FE14 /* QuickSwitchOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01D145799AD07A6882DC6625 /* QuickSwitchOverlay.swift */; }; BC153A7DE0D6E4176DA9A38A /* NiriOnboardingGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2DF94962EFABB2E7A81F69E /* NiriOnboardingGate.swift */; }; BC19750CA3CDDAD79E3C74DC /* InlineSessionSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10430D5FE541E8A7589355A3 /* InlineSessionSettings.swift */; }; @@ -149,11 +157,12 @@ C0A4A5965304E63A0BF4553F /* SessionAttentionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B8A6EB207A51CF2A5DCB136 /* SessionAttentionState.swift */; }; C13B2218F2761EAD637D9F91 /* SessionDetailsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F726013BE89EA789F943F47A /* SessionDetailsSheet.swift */; }; C3EFE3C0F35228889DA5DBF5 /* AppCommandRegistryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 164D3E27DB793DF6250CB60F /* AppCommandRegistryTests.swift */; }; - C3F4A1B2C3D4E5F60718293A /* RestoreLaunchQueueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B2C3D4E5F60718293A4B5C /* RestoreLaunchQueueTests.swift */; }; C4BF4ED638BB64C3380B9C1F /* T3CodeRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4C448F93D9024D3A2D87EFB /* T3CodeRuntime.swift */; }; C4DD853D2845C83830C3BCB6 /* SessionServiceIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5186DB32861A1F5757732E2D /* SessionServiceIntegrationTests.swift */; }; C9B33E60BEBEAFE88CF419E9 /* WorktreePathGenerationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25D22DAD38D8E3EFB649D270 /* WorktreePathGenerationTests.swift */; }; + CA1FC091D045423FB05535BF /* OpenCodeRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B93A1507BB453BBDD70153 /* OpenCodeRuntime.swift */; }; CA2FF80394C68F4A1BB2F4B8 /* BrowserModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE47596354CF7323C58CE3A9 /* BrowserModels.swift */; }; + CE4C14297F620402C8CC5ACD /* AppcastFeedBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38282641CA1CF6410364DFB7 /* AppcastFeedBuilder.swift */; }; D0A0A761157727FDFB96C9C1 /* NiriAppRegistryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F8EAB1EE8E31AFAE297503B /* NiriAppRegistryTests.swift */; }; D12D4558CDCE82CA5E170731 /* InlineGeneralSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC5385ADD3DADF082CD3893E /* InlineGeneralSettings.swift */; }; D2CA90D9F216BA9D486B7DFB /* NiriOnboardingSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50394D4823261CF930572E8D /* NiriOnboardingSheet.swift */; }; @@ -176,6 +185,8 @@ E8ADEE219025F24923A990A7 /* LayoutStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D63C7BC71EBD7E31BF944A3 /* LayoutStateTests.swift */; }; E9E3AA2DAF596FC94179B679 /* SessionContainerNiriWorkspaceLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D41A657FCE96AB8403908B5 /* SessionContainerNiriWorkspaceLayout.swift */; }; EC929DF32DAD540380D36B11 /* AppCommandRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31C148B9BDC30B6C6959E7D7 /* AppCommandRegistry.swift */; }; + EDAC00F6D6DE6FACA11972AE /* AppcastScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47BFE4FA370E9755B7121AFC /* AppcastScriptTests.swift */; }; + EEB3852F18CB4DA5E93C2A3F /* NiriTileSpotlight.swift in Sources */ = {isa = PBXBuildFile; fileRef = 311CD22F7731839DA5785C05 /* NiriTileSpotlight.swift */; }; F4C53DF6B11BED3E1D89E8C6 /* IPCContract.swift in Sources */ = {isa = PBXBuildFile; fileRef = 126519A706915EF6A552C36D /* IPCContract.swift */; }; F7EC6D8E4650E5265FBE2611 /* FileSystemPaths.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ED7E9421B0B45E803E9F9D /* FileSystemPaths.swift */; }; F85827AECC0D99E9AD51A676 /* CodableSessionRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC66C9B0E0BEF3C9699990C /* CodableSessionRecord.swift */; }; @@ -206,6 +217,7 @@ 084CE6508D3146437C560DE3 /* AttentionCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttentionCenter.swift; sourceTree = ""; }; 0884C8A5D1749BC2E9960939 /* SessionSidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionSidebarView.swift; sourceTree = ""; }; 0913EC98AEA1A5769872F12E /* SessionContainerView+NiriQuickAddToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionContainerView+NiriQuickAddToolbar.swift"; sourceTree = ""; }; + 092385479C2D4B5510106B64 /* AppUpdateReducerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateReducerTests.swift; sourceTree = ""; }; 09FF7D2151EF790BBDC1581E /* CommandPaletteOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteOverlay.swift; sourceTree = ""; }; 0D41A657FCE96AB8403908B5 /* SessionContainerNiriWorkspaceLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionContainerNiriWorkspaceLayout.swift; sourceTree = ""; }; 0E0653D39F91E2565C8E6CD3 /* VibeCLITool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VibeCLITool.swift; sourceTree = ""; }; @@ -214,10 +226,11 @@ 0F777BA529A3C61CA2F671D0 /* TerminalMonitorService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalMonitorService.swift; sourceTree = ""; }; 10430D5FE541E8A7589355A3 /* InlineSessionSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineSessionSettings.swift; sourceTree = ""; }; 10A52A17C7439012AC5990DE /* TerminalTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalTheme.swift; sourceTree = ""; }; + 1177D409C8A650B1E7C2195E /* RestoreLaunchQueueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoreLaunchQueueTests.swift; sourceTree = ""; }; 126519A706915EF6A552C36D /* IPCContract.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPCContract.swift; sourceTree = ""; }; 12978EF6AC1D54BA282486F1 /* InlineAdvancedSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineAdvancedSettings.swift; sourceTree = ""; }; 14A94CD6356D5E7A13A9C7F9 /* ChromeCookieImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChromeCookieImporter.swift; sourceTree = ""; }; - 150785B3AEE2598ED5427E3D /* idx0.icns.backup-20260322-075809 */ = {isa = PBXFileReference; lastKnownFileType = file; path = "idx0.icns.backup-20260322-075809"; sourceTree = ""; }; + 150785B3AEE2598ED5427E3D /* idx0.icns.backup-20260322-075809 */ = {isa = PBXFileReference; path = "idx0.icns.backup-20260322-075809"; sourceTree = ""; }; 164D3E27DB793DF6250CB60F /* AppCommandRegistryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCommandRegistryTests.swift; sourceTree = ""; }; 18F76B8A80EF8F1C875F70AF /* URLRoutingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLRoutingService.swift; sourceTree = ""; }; 1B5460029DE37A1C42C476E9 /* InlineSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineSettingsView.swift; sourceTree = ""; }; @@ -240,10 +253,12 @@ 2D273EB3EC383B3187985031 /* SidebarResizeHandle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeHandle.swift; sourceTree = ""; }; 2F9C7E86A82614D71AC3D0D9 /* AppearanceSettingsTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettingsTab.swift; sourceTree = ""; }; 3112DCA0B9FFD523792928A9 /* WorkflowModelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkflowModelsTests.swift; sourceTree = ""; }; + 311CD22F7731839DA5785C05 /* NiriTileSpotlight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NiriTileSpotlight.swift; sourceTree = ""; }; 31C148B9BDC30B6C6959E7D7 /* AppCommandRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCommandRegistry.swift; sourceTree = ""; }; 3289229AFB744281FC5DEFEF /* SessionService+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionService+Utilities.swift"; sourceTree = ""; }; 32E53938E65ED3647C1B2638 /* NewSessionSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewSessionSheet.swift; sourceTree = ""; }; 3534C31D8F7EBEEBF48AD2E8 /* BrowserDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserDataStore.swift; sourceTree = ""; }; + 38282641CA1CF6410364DFB7 /* AppcastFeedBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppcastFeedBuilder.swift; sourceTree = ""; }; 38E02C2F50EC48C2251DD478 /* TerminalSessionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSessionController.swift; sourceTree = ""; }; 3906E4FD5F3EF5BB6539B6C7 /* WorktreeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorktreeService.swift; sourceTree = ""; }; 3BC66C9B0E0BEF3C9699990C /* CodableSessionRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableSessionRecord.swift; sourceTree = ""; }; @@ -252,6 +267,7 @@ 42954794B6E7533BCCF88CC3 /* SessionContainerView+NiriTiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionContainerView+NiriTiles.swift"; sourceTree = ""; }; 42D76EDDAC2E2645252553FD /* ShellPoolService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShellPoolService.swift; sourceTree = ""; }; 458D8A4C0F0B2D457EA02E7B /* idx0.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = idx0.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 47BFE4FA370E9755B7121AFC /* AppcastScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppcastScriptTests.swift; sourceTree = ""; }; 47EAE9A64AE413ADDB3582DD /* SessionContainerView+NiriResizeVisualizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionContainerView+NiriResizeVisualizer.swift"; sourceTree = ""; }; 48ED9438C4AE6FC0DC03E5B6 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 4A8517E677C5C358D38F1205 /* LayoutState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutState.swift; sourceTree = ""; }; @@ -263,6 +279,7 @@ 5186DB32861A1F5757732E2D /* SessionServiceIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionServiceIntegrationTests.swift; sourceTree = ""; }; 528147C5E1AFD16F42402E97 /* RenameSessionSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameSessionSheet.swift; sourceTree = ""; }; 57FC8B1654F989A31FC64476 /* terminal-themes.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "terminal-themes.json"; sourceTree = ""; }; + 5816C22890F8B9D231A92B79 /* AppUpdateActionMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateActionMapperTests.swift; sourceTree = ""; }; 587FDB03500BF529C7811E4D /* SessionLauncher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionLauncher.swift; sourceTree = ""; }; 5A24BF75FB7EE9A24CCF2980 /* SessionBrowserController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionBrowserController.swift; sourceTree = ""; }; 5BE3CDAF378E55CAE439A878 /* IPCServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPCServer.swift; sourceTree = ""; }; @@ -278,7 +295,6 @@ 694CAD837713AD44B34D8010 /* SessionService+RuntimeLaunch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionService+RuntimeLaunch.swift"; sourceTree = ""; }; 6AA6DAB9923306A0236363D1 /* SessionContainerView+NiriCanvasSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionContainerView+NiriCanvasSurface.swift"; sourceTree = ""; }; 6EA8C7331AA94A8E5C8F6DCF /* PaneTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaneTreeView.swift; sourceTree = ""; }; - 6FA0B16E5CCCBFB1FAA5620C /* NiriTileSpotlight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NiriTileSpotlight.swift; sourceTree = ""; }; 714F5099FB5B255B533B9993 /* SessionSettingsTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionSettingsTab.swift; sourceTree = ""; }; 72068E526B83803FAFB09694 /* MainWindowSheets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindowSheets.swift; sourceTree = ""; }; 72CCCD57FC8E6C67F1BC4E0D /* idx0-icon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = "idx0-icon.icon"; sourceTree = ""; }; @@ -299,6 +315,7 @@ 8966AEA81C75928E237AA817 /* WorkflowModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkflowModels.swift; sourceTree = ""; }; 8A191151AD26769E8CDA81EC /* SessionContainerView+DotGridBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionContainerView+DotGridBackground.swift"; sourceTree = ""; }; 8BF57F5E981ABB2BAEE4C63F /* TimelineService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineService.swift; sourceTree = ""; }; + 8E114055EBA3DF5BBC2A415B /* AppUpdateReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateReducer.swift; sourceTree = ""; }; 8E4D791CA80D5EF489708F88 /* InlineSettingsSharedComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineSettingsSharedComponents.swift; sourceTree = ""; }; 8EA436F0CE3DD4AD2B662DC6 /* WorktreeInspectorSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorktreeInspectorSheet.swift; sourceTree = ""; }; 909DA6DBEE1EA05FB9D1BB55 /* idx0-GhosttyBridge.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "idx0-GhosttyBridge.h"; sourceTree = ""; }; @@ -315,29 +332,29 @@ 9DC53446A59CB69B5DBD424E /* AgentOutputScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentOutputScanner.swift; sourceTree = ""; }; 9FF8648BC48369ECB556E182 /* ShortcutRegistryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutRegistryTests.swift; sourceTree = ""; }; A0F16A8A4931D21AFF9A4EFF /* idx0_GhosttyBridge.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = idx0_GhosttyBridge.c; sourceTree = ""; }; - A1B2C3D4E5F60718293A4B5C /* SessionService+LaunchPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionService+LaunchPolicy.swift"; sourceTree = ""; }; A4BFDA28B8B9F4BC41947EE4 /* BranchNameGeneratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BranchNameGeneratorTests.swift; sourceTree = ""; }; A4CE738D0D42BB63C71A00FE /* VSCodeRuntime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VSCodeRuntime.swift; sourceTree = ""; }; A529B4E560D78A441368B39A /* AgentEventRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentEventRouter.swift; sourceTree = ""; }; A5C9A58C93C0BA47A57C70B5 /* SessionCreationResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionCreationResult.swift; sourceTree = ""; }; A642FC6166A2A37453A5E9D9 /* AutoCheckpointService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCheckpointService.swift; sourceTree = ""; }; A658252F414281E424DD4F0D /* TabBarOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarOverlay.swift; sourceTree = ""; }; + A68C133C38FD7A76EE002417 /* SparkleUpdateDriver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SparkleUpdateDriver.swift; sourceTree = ""; }; A96BB4B4F5A3E28F37E9D262 /* ProjectService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectService.swift; sourceTree = ""; }; A9D80D208AF87981378D8861 /* WorkflowService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkflowService.swift; sourceTree = ""; }; AE47596354CF7323C58CE3A9 /* BrowserModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserModels.swift; sourceTree = ""; }; AFB0F063E8279381E6167B64 /* SessionCreationRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionCreationRequest.swift; sourceTree = ""; }; - B1B2C3D4E5F60718293A4B5C /* TerminalMonitorServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalMonitorServiceTests.swift; sourceTree = ""; }; + B2B93A1507BB453BBDD70153 /* OpenCodeRuntime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenCodeRuntime.swift; sourceTree = ""; }; B5048750915F84088FED8574 /* idx0.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = idx0.icns; sourceTree = ""; }; B5BC04FF0FF385A5961DB941 /* SessionServiceTests+Launch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionServiceTests+Launch.swift"; sourceTree = ""; }; BC5385ADD3DADF082CD3893E /* InlineGeneralSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineGeneralSettings.swift; sourceTree = ""; }; BEA146F38A368A5A2BBAD9E0 /* WorkflowServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkflowServiceTests.swift; sourceTree = ""; }; BF02E5083B28A0C54E599BF2 /* ShortcutRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutRegistry.swift; sourceTree = ""; }; + BFBFE1CD0AF73B0C348164EB /* AppUpdateModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateModels.swift; sourceTree = ""; }; C0E869F53140CDC4C857F1C4 /* SessionModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionModels.swift; sourceTree = ""; }; C154F30A5F338A27AB53712F /* SessionServiceTests+Niri.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionServiceTests+Niri.swift"; sourceTree = ""; }; - C1B2C3D4E5F60718293A4B5C /* RestoreLaunchQueueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoreLaunchQueueTests.swift; sourceTree = ""; }; C27AA169BC08CF6EE14A8627 /* WorkflowRailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkflowRailView.swift; sourceTree = ""; }; C2DF94962EFABB2E7A81F69E /* NiriOnboardingGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NiriOnboardingGate.swift; sourceTree = ""; }; - C3D4E5F60718293A4B5C6D7E /* OpenCodeRuntime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenCodeRuntime.swift; sourceTree = ""; }; + C7757B8E1B8B425D0DE78C5E /* AppUpdateServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateServiceTests.swift; sourceTree = ""; }; C8D0CE8D1993A019F20D0CFD /* idx0-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "idx0-Bridging-Header.h"; sourceTree = ""; }; CBE452CDCBD97B412A836AE6 /* InlineSafetySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineSafetySettings.swift; sourceTree = ""; }; CC152560147637D6EC0CD35B /* openvscode-build-manifest.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "openvscode-build-manifest.json"; sourceTree = ""; }; @@ -345,29 +362,33 @@ CDE12E08D37699A7663BB063 /* SessionContainerView+NiriMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionContainerView+NiriMetrics.swift"; sourceTree = ""; }; CF32A5CDE91085A45FAEF298 /* GhosttyTerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalView.swift; sourceTree = ""; }; D093665B29003B4CDD1793FD /* SessionStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionStoreTests.swift; sourceTree = ""; }; + D0DF5DDE163F1A0D142C48A1 /* TerminalMonitorServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalMonitorServiceTests.swift; sourceTree = ""; }; D1D4C1C59381DEB755085208 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; D217118EDBB72CD44D1B9247 /* CheckpointsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckpointsSheet.swift; sourceTree = ""; }; D247FD43F4437A9B3BD963BC /* NiriAppRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NiriAppRegistry.swift; sourceTree = ""; }; D24B9DA71E880339383D7D43 /* HandoffComposerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandoffComposerSheet.swift; sourceTree = ""; }; - D4E5F60718293A4B5C6D7E8F /* OpenCodeRuntimeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenCodeRuntimeTests.swift; sourceTree = ""; }; D6ED7E9421B0B45E803E9F9D /* FileSystemPaths.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSystemPaths.swift; sourceTree = ""; }; D89C8135090E45DA8C94DBFE /* SessionService+Lifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionService+Lifecycle.swift"; sourceTree = ""; }; D8B40C0353C4A178B385EB0D /* FuzzyMatchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuzzyMatchTests.swift; sourceTree = ""; }; D9962C2537A206B836AAA1CE /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; D9A3B6B056A35C674DB49A2E /* SessionContainerView+NiriDragResize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionContainerView+NiriDragResize.swift"; sourceTree = ""; }; D9AEFE7132B90AF0A032108D /* AppCoordinator+ShortcutCommandDispatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppCoordinator+ShortcutCommandDispatcher.swift"; sourceTree = ""; }; + D9D7D0881CD90FDAA7D118A0 /* OpenCodeRuntimeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenCodeRuntimeTests.swift; sourceTree = ""; }; DAF59ED0F82787D85C955F4E /* ExcalidrawRuntime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExcalidrawRuntime.swift; sourceTree = ""; }; DB1775F64883C385AC9E7EEA /* WorktreeInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorktreeInfo.swift; sourceTree = ""; }; DD36B859D9782DE4AFD5826A /* KeyboardSettingsViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardSettingsViews.swift; sourceTree = ""; }; DFEC4330AF1DE7C978BA59E8 /* excalidraw-build-manifest.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "excalidraw-build-manifest.json"; sourceTree = ""; }; E03201642735DCA04A98616C /* SessionContainerView+NiriGestures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionContainerView+NiriGestures.swift"; sourceTree = ""; }; E040B0FC30190D925785844A /* Session.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Session.swift; sourceTree = ""; }; + E2AC98993D6DFB52E9E765F6 /* AppUpdateSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateSupport.swift; sourceTree = ""; }; E348CC63163C1B52E07B8506 /* WorkflowStores.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkflowStores.swift; sourceTree = ""; }; E4C448F93D9024D3A2D87EFB /* T3CodeRuntime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = T3CodeRuntime.swift; sourceTree = ""; }; E51C372C3C84AB24712625F5 /* ShortcutActionID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutActionID.swift; sourceTree = ""; }; E68FA8BE49E49930255DBBDA /* SessionContainerView+NiriSupportTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionContainerView+NiriSupportTypes.swift"; sourceTree = ""; }; E6F8B6A69E321355348D4610 /* SupervisionQueueServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupervisionQueueServiceTests.swift; sourceTree = ""; }; + E86661A2CCBDD15FD47600D8 /* AppUpdateService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateService.swift; sourceTree = ""; }; E915E2738ADB3CF7EF3C96AC /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = ""; }; + E9AC133221097B436EBAB1F1 /* SessionService+LaunchPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionService+LaunchPolicy.swift"; sourceTree = ""; }; EA71778431F55CCEF74C5BBD /* AppSettingsKeyboardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsKeyboardTests.swift; sourceTree = ""; }; EAB281CC5B7AD26E420B6787 /* SafetySettingsTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafetySettingsTab.swift; sourceTree = ""; }; EAEFA3D2F171F4CF5682027F /* BrowserToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserToolbar.swift; sourceTree = ""; }; @@ -389,6 +410,7 @@ buildActionMask = 2147483647; files = ( 94AFBD78A57A783D255100DE /* GhosttyKit.xcframework in Frameworks */, + 8EE8BCD4E98EC6C8D34F5C60 /* Sparkle in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -435,6 +457,19 @@ path = Themes; sourceTree = ""; }; + 21E07F971C7230FE6850C384 /* Updates */ = { + isa = PBXGroup; + children = ( + 38282641CA1CF6410364DFB7 /* AppcastFeedBuilder.swift */, + BFBFE1CD0AF73B0C348164EB /* AppUpdateModels.swift */, + 8E114055EBA3DF5BBC2A415B /* AppUpdateReducer.swift */, + E86661A2CCBDD15FD47600D8 /* AppUpdateService.swift */, + E2AC98993D6DFB52E9E765F6 /* AppUpdateSupport.swift */, + A68C133C38FD7A76EE002417 /* SparkleUpdateDriver.swift */, + ); + path = Updates; + sourceTree = ""; + }; 252E6D81A5DC49EC4CE70E25 /* Sidebar */ = { isa = PBXGroup; children = ( @@ -447,6 +482,7 @@ 26AAC074DB4C91C29ACE7E8D /* SessionContainer */ = { isa = PBXGroup; children = ( + 311CD22F7731839DA5785C05 /* NiriTileSpotlight.swift */, 0D41A657FCE96AB8403908B5 /* SessionContainerNiriWorkspaceLayout.swift */, 297FF569B25ACB8A99CA5CB1 /* SessionContainerView.swift */, FD37B4C15A60B8C9E450BD51 /* SessionContainerView+BrowserSplitResize.swift */, @@ -458,7 +494,6 @@ 77D07E1D74241531F78C9443 /* SessionContainerView+NiriItemViews.swift */, CDE12E08D37699A7663BB063 /* SessionContainerView+NiriMetrics.swift */, 0913EC98AEA1A5769872F12E /* SessionContainerView+NiriQuickAddToolbar.swift */, - 6FA0B16E5CCCBFB1FAA5620C /* NiriTileSpotlight.swift */, 47EAE9A64AE413ADDB3582DD /* SessionContainerView+NiriResizeVisualizer.swift */, E68FA8BE49E49930255DBBDA /* SessionContainerView+NiriSupportTypes.swift */, 42954794B6E7533BCCF88CC3 /* SessionContainerView+NiriTiles.swift */, @@ -527,9 +562,9 @@ isa = PBXGroup; children = ( A0153BB7359B4F14866A3A5B /* Excalidraw */, + 9608E279AE67D4F12B99474B /* OpenCode */, F10EF007191D14CF7D249BA4 /* T3Code */, 1194C0592BDF22BD406C09A1 /* VSCode */, - F60718293A4B5C6D7E8F9012 /* OpenCode */, ); path = Apps; sourceTree = ""; @@ -542,6 +577,7 @@ AF18F409670833FE789994F5 /* Git */, B1810C44F149726AB3ADC730 /* Runtime */, CB5AEC58BE215F14E1888AA4 /* Session */, + 21E07F971C7230FE6850C384 /* Updates */, 399D78E302FD18EC9DE62F30 /* Workflow */, ); path = Services; @@ -593,6 +629,14 @@ name = Products; sourceTree = ""; }; + 6E006EF0D3A9444B87A46A4F /* OpenCode */ = { + isa = PBXGroup; + children = ( + B2B93A1507BB453BBDD70153 /* OpenCodeRuntime.swift */, + ); + path = OpenCode; + sourceTree = ""; + }; 741C4D18DB52408CFC522991 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -657,6 +701,14 @@ path = Tabs; sourceTree = ""; }; + 9608E279AE67D4F12B99474B /* OpenCode */ = { + isa = PBXGroup; + children = ( + D9D7D0881CD90FDAA7D118A0 /* OpenCodeRuntimeTests.swift */, + ); + path = OpenCode; + sourceTree = ""; + }; 9683579B0B776409E0F9A98C /* Keyboard */ = { isa = PBXGroup; children = ( @@ -784,8 +836,8 @@ C0E869F53140CDC4C857F1C4 /* SessionModels.swift */, 04A5622E3348C3DBA98663A6 /* SessionRestoreCoordinator.swift */, 603A01395B9650650ACB1F9B /* SessionService.swift */, + E9AC133221097B436EBAB1F1 /* SessionService+LaunchPolicy.swift */, 056B07A95FE3CA67B2B2271D /* SessionService+LayoutPersistence.swift */, - A1B2C3D4E5F60718293A4B5C /* SessionService+LaunchPolicy.swift */, D89C8135090E45DA8C94DBFE /* SessionService+Lifecycle.swift */, 769DAB2595E31721E2C06C50 /* SessionService+NiriCanvasOps.swift */, 694CAD837713AD44B34D8010 /* SessionService+RuntimeLaunch.swift */, @@ -818,9 +870,9 @@ children = ( EBBA45F552AB22BE4D13D991 /* Core */, 57BBF569C19DB83BFFCF9386 /* Excalidraw */, + 6E006EF0D3A9444B87A46A4F /* OpenCode */, 0848A2A746C74F4F4070941D /* T3Code */, 621B3AF26F6F5F6CF82AF166 /* VSCode */, - E5F60718293A4B5C6D7E8F90 /* OpenCode */, ); path = Apps; sourceTree = ""; @@ -846,14 +898,6 @@ path = Commands; sourceTree = ""; }; - E5F60718293A4B5C6D7E8F90 /* OpenCode */ = { - isa = PBXGroup; - children = ( - C3D4E5F60718293A4B5C6D7E /* OpenCodeRuntime.swift */, - ); - path = OpenCode; - sourceTree = ""; - }; EBBA45F552AB22BE4D13D991 /* Core */ = { isa = PBXGroup; children = ( @@ -878,14 +922,6 @@ path = T3Code; sourceTree = ""; }; - F60718293A4B5C6D7E8F9012 /* OpenCode */ = { - isa = PBXGroup; - children = ( - D4E5F60718293A4B5C6D7E8F /* OpenCodeRuntimeTests.swift */, - ); - path = OpenCode; - sourceTree = ""; - }; FA210FBF0BB82335A3ADE7D6 /* idx0 */ = { isa = PBXGroup; children = ( @@ -907,18 +943,22 @@ isa = PBXGroup; children = ( 87226D8ADDB27F27B21E992F /* AgentOutputScannerTests.swift */, + 47BFE4FA370E9755B7121AFC /* AppcastScriptTests.swift */, 164D3E27DB793DF6250CB60F /* AppCommandRegistryTests.swift */, EA71778431F55CCEF74C5BBD /* AppSettingsKeyboardTests.swift */, + 5816C22890F8B9D231A92B79 /* AppUpdateActionMapperTests.swift */, + 092385479C2D4B5510106B64 /* AppUpdateReducerTests.swift */, + C7757B8E1B8B425D0DE78C5E /* AppUpdateServiceTests.swift */, EBE1EAD2776C1DBE09E2DF1B /* AutoCheckpointServiceTests.swift */, A4BFDA28B8B9F4BC41947EE4 /* BranchNameGeneratorTests.swift */, 2ABECF80192D5F946ED8666B /* BrowserDataStoreTests.swift */, - C1B2C3D4E5F60718293A4B5C /* RestoreLaunchQueueTests.swift */, D8B40C0353C4A178B385EB0D /* FuzzyMatchTests.swift */, 90EE0DE10784650DC7094D02 /* GitServiceParsingTests.swift */, 5D63C7BC71EBD7E31BF944A3 /* LayoutStateTests.swift */, 5F8EAB1EE8E31AFAE297503B /* NiriAppRegistryTests.swift */, 922E70866C06A866A454B057 /* NiriOnboardingGateTests.swift */, 88326F59CB46BC92265E9EB5 /* PaneNodeTests.swift */, + 1177D409C8A650B1E7C2195E /* RestoreLaunchQueueTests.swift */, 779E0989D99E1DDC0D97FD22 /* SessionModelTests.swift */, 5186DB32861A1F5757732E2D /* SessionServiceIntegrationTests.swift */, F7B0FD907F47C273890F14B9 /* SessionServiceTests.swift */, @@ -926,7 +966,7 @@ C154F30A5F338A27AB53712F /* SessionServiceTests+Niri.swift */, D093665B29003B4CDD1793FD /* SessionStoreTests.swift */, E6F8B6A69E321355348D4610 /* SupervisionQueueServiceTests.swift */, - B1B2C3D4E5F60718293A4B5C /* TerminalMonitorServiceTests.swift */, + D0DF5DDE163F1A0D142C48A1 /* TerminalMonitorServiceTests.swift */, 867382630242730813387E6E /* TerminalThemeTests.swift */, 3112DCA0B9FFD523792928A9 /* WorkflowModelsTests.swift */, BEA146F38A368A5A2BBAD9E0 /* WorkflowServiceTests.swift */, @@ -954,6 +994,7 @@ ); name = idx0; packageProductDependencies = ( + F53E79E457609B3BD9B87D33 /* Sparkle */, ); productName = idx0; productReference = 458D8A4C0F0B2D457EA02E7B /* idx0.app */; @@ -1000,6 +1041,9 @@ ); mainGroup = 9EB5A76D6BFB2D325553B59D; minimizedProjectReferenceProxies = 1; + packageReferences = ( + 9FF836AC9923AC2A9F49BBD0 /* XCRemoteSwiftPackageReference "Sparkle" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 6C3019B6C5145211C93452F6 /* Products */; projectDirPath = ""; @@ -1037,17 +1081,22 @@ 93DE98E542473532A19E901D /* AgentOutputScannerTests.swift in Sources */, C3EFE3C0F35228889DA5DBF5 /* AppCommandRegistryTests.swift in Sources */, 6266D5D37F7E1D7FF596B10D /* AppSettingsKeyboardTests.swift in Sources */, + 6F6B700A605984F175B5B1ED /* AppUpdateActionMapperTests.swift in Sources */, + 348C60AE1905ADD12C9E4137 /* AppUpdateReducerTests.swift in Sources */, + 51335F167FAAB60880BD963A /* AppUpdateServiceTests.swift in Sources */, + EDAC00F6D6DE6FACA11972AE /* AppcastScriptTests.swift in Sources */, 8323E5B818F6AF91B355CA11 /* AutoCheckpointServiceTests.swift in Sources */, 22B1B9E0EA022D8228A26BC9 /* BranchNameGeneratorTests.swift in Sources */, E39960209C6BA428A922B5E0 /* BrowserDataStoreTests.swift in Sources */, - C3F4A1B2C3D4E5F60718293A /* RestoreLaunchQueueTests.swift in Sources */, 066EDA0406A00EAD947B9D4F /* ExcalidrawRuntimeTests.swift in Sources */, 05FDC6EF2EA1379932BEC9FA /* FuzzyMatchTests.swift in Sources */, 9F6995C2F654B9422548C148 /* GitServiceParsingTests.swift in Sources */, E8ADEE219025F24923A990A7 /* LayoutStateTests.swift in Sources */, D0A0A761157727FDFB96C9C1 /* NiriAppRegistryTests.swift in Sources */, 0A82A6B83B1252493812FBE4 /* NiriOnboardingGateTests.swift in Sources */, + B979DB37F81B4C1D82176614 /* OpenCodeRuntimeTests.swift in Sources */, 9B8617148268B082058F92FE /* PaneNodeTests.swift in Sources */, + B5CFE5F6459F81A8666CB315 /* RestoreLaunchQueueTests.swift in Sources */, 458A0057B76598B3D58CD0F3 /* SessionModelTests.swift in Sources */, C4DD853D2845C83830C3BCB6 /* SessionServiceIntegrationTests.swift in Sources */, 553B8C11C5536D6292D03BD5 /* SessionServiceTests+Launch.swift in Sources */, @@ -1057,8 +1106,7 @@ E72847AEEF99AC4FF921A86E /* ShortcutRegistryTests.swift in Sources */, AFE3451E6F9A754B645F9435 /* SupervisionQueueServiceTests.swift in Sources */, 58987615D32B830F852135E5 /* T3CodeRuntimeTests.swift in Sources */, - B2F4A1B2C3D4E5F60718293A /* TerminalMonitorServiceTests.swift in Sources */, - B2C3D4E5F60718293A4B5C6D /* OpenCodeRuntimeTests.swift in Sources */, + 5884A8FBF5C47453B469980A /* TerminalMonitorServiceTests.swift in Sources */, 036A444E148F29E56A156F65 /* TerminalThemeTests.swift in Sources */, 6D5F242BF42126B4C201A625 /* VSCodeRuntimeTests.swift in Sources */, E2B377CCEA8EC06AC24CFDE8 /* WorkflowModelsTests.swift in Sources */, @@ -1078,6 +1126,11 @@ E614B4D76132103D01EDC1AF /* AppCoordinator+ShortcutCommandDispatcher.swift in Sources */, 513164D96822F28A6CA0C613 /* AppCoordinator.swift in Sources */, 2C239931857F2B7586F7AD86 /* AppSettings.swift in Sources */, + 123F3493042A939B4B88AA96 /* AppUpdateModels.swift in Sources */, + 1A0F2E8D668D683B8867EA6A /* AppUpdateReducer.swift in Sources */, + 3DC1365CE260E5D8A9BDDC45 /* AppUpdateService.swift in Sources */, + 82BA865D8D7C6861CE414F7A /* AppUpdateSupport.swift in Sources */, + CE4C14297F620402C8CC5ACD /* AppcastFeedBuilder.swift in Sources */, 2E56891B5B216C3D8A39510F /* AppearanceSettingsTab.swift in Sources */, 7B5F59ED5D78EE2A951F8FBC /* AttentionCenter.swift in Sources */, E75A4BFE43D605C454B38907 /* AutoCheckpointService.swift in Sources */, @@ -1127,6 +1180,8 @@ BF15EBA18655AF1B536F5ED5 /* NiriAppRegistry.swift in Sources */, BC153A7DE0D6E4176DA9A38A /* NiriOnboardingGate.swift in Sources */, D2CA90D9F216BA9D486B7DFB /* NiriOnboardingSheet.swift in Sources */, + EEB3852F18CB4DA5E93C2A3F /* NiriTileSpotlight.swift in Sources */, + CA1FC091D045423FB05535BF /* OpenCodeRuntime.swift in Sources */, 96CB8BB3138CE08A017A0073 /* PaneNode.swift in Sources */, 48CD82F3363A82E058277D62 /* PaneTreeView.swift in Sources */, D83F7A3DA959C9615086FCE5 /* ProcessRunner.swift in Sources */, @@ -1147,7 +1202,6 @@ 3F370869C5D7E4FFB73FE800 /* SessionContainerView+NiriItemViews.swift in Sources */, 8673AB3379194493EB7BAE19 /* SessionContainerView+NiriMetrics.swift in Sources */, FB60AE91F3B4A3DB36E008F3 /* SessionContainerView+NiriQuickAddToolbar.swift in Sources */, - 2AE9DE55597E704B7B99B0EF /* NiriTileSpotlight.swift in Sources */, 65172983E398238D0E4AFD07 /* SessionContainerView+NiriResizeVisualizer.swift in Sources */, 08DD928F54822DCB46AAC44C /* SessionContainerView+NiriSupportTypes.swift in Sources */, 12E88F91FCDA7BC82CEF2C57 /* SessionContainerView+NiriTiles.swift in Sources */, @@ -1160,8 +1214,8 @@ 8554E9BCA4F511D4B5D570A5 /* SessionLauncher.swift in Sources */, 26FA772FB59719EE96B56CD4 /* SessionModels.swift in Sources */, 3EB35636AF5FF0DC4C2DF8F9 /* SessionRestoreCoordinator.swift in Sources */, + 7AAB313E7318042DD6A0131D /* SessionService+LaunchPolicy.swift in Sources */, 35036CCAD106D88964E5BDF2 /* SessionService+LayoutPersistence.swift in Sources */, - A1F4A1B2C3D4E5F60718293A /* SessionService+LaunchPolicy.swift in Sources */, A8121E3B671B6E1F0F5CC1CB /* SessionService+Lifecycle.swift in Sources */, 131CEB0DEE775C1C144BFDAB /* SessionService+NiriCanvasOps.swift in Sources */, A907F0A7793223D7D2922FFC /* SessionService+RuntimeLaunch.swift in Sources */, @@ -1182,9 +1236,9 @@ 1126AF1C9E126EABC088B50C /* ShortcutRegistry.swift in Sources */, A7A7DEE20873E024562444A6 /* ShortcutValidator.swift in Sources */, 8EBD5438089ADC23B4BB26F1 /* SidebarResizeHandle.swift in Sources */, + 39DCAD54703D104C31FF9C7C /* SparkleUpdateDriver.swift in Sources */, 860E1AE5892CF200352256FC /* SupervisionQueueService.swift in Sources */, C4BF4ED638BB64C3380B9C1F /* T3CodeRuntime.swift in Sources */, - A2B3C4D5E6F708192A3B4C5D /* OpenCodeRuntime.swift in Sources */, 893B94051D5F59B2413D91BC /* TabBarOverlay.swift in Sources */, 64447C377FF57F3FD4ED4CC9 /* TerminalMonitorService.swift in Sources */, 63CB95EF0E4145A598612B03 /* TerminalSessionController.swift in Sources */, @@ -1227,35 +1281,29 @@ 0A80E460BD4ADD08E0262271 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = "idx0-icon"; - AUTOMATION_APPLE_EVENTS = NO; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 6RFK6NMTV7; - ENABLE_HARDENED_RUNTIME = YES; - ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; - ENABLE_RESOURCE_ACCESS_CALENDARS = NO; - ENABLE_RESOURCE_ACCESS_CAMERA = NO; - ENABLE_RESOURCE_ACCESS_CONTACTS = NO; - ENABLE_RESOURCE_ACCESS_LOCATION = NO; - ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "\".\"", ); GENERATE_INFOPLIST_FILE = YES; - HEADER_SEARCH_PATHS = "$(SRCROOT)"; + HEADER_SEARCH_PATHS = ( + "$(SRCROOT)", + ); INFOPLIST_KEY_CFBundleDisplayName = IDX0; INFOPLIST_KEY_CFBundleIconFile = idx0.icns; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + INFOPLIST_KEY_SUFeedURL = "https://raw.githubusercontent.com/galz10/idx0-appcast/main/appcast.xml"; + INFOPLIST_KEY_SUPublicEDKey = CHANGE_ME_SPARKLE_PUBLIC_KEY; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.0.12; + MARKETING_VERSION = 0.0.1; OTHER_LDFLAGS = ( "-lc++", "-framework", @@ -1273,12 +1321,6 @@ ); PRODUCT_BUNDLE_IDENTIFIER = com.gal.idx0; PRODUCT_NAME = idx0; - RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; - RUNTIME_EXCEPTION_ALLOW_JIT = NO; - RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO; - RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO; - RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO; - RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = NO; SDKROOT = macosx; SWIFT_OBJC_BRIDGING_HEADER = "idx0-Bridging-Header.h"; }; @@ -1287,35 +1329,29 @@ 113FCC8CAF125E40DFB30950 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = "idx0-icon"; - AUTOMATION_APPLE_EVENTS = NO; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 6RFK6NMTV7; - ENABLE_HARDENED_RUNTIME = YES; - ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; - ENABLE_RESOURCE_ACCESS_CALENDARS = NO; - ENABLE_RESOURCE_ACCESS_CAMERA = NO; - ENABLE_RESOURCE_ACCESS_CONTACTS = NO; - ENABLE_RESOURCE_ACCESS_LOCATION = NO; - ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "\".\"", ); GENERATE_INFOPLIST_FILE = YES; - HEADER_SEARCH_PATHS = "$(SRCROOT)"; + HEADER_SEARCH_PATHS = ( + "$(SRCROOT)", + ); INFOPLIST_KEY_CFBundleDisplayName = IDX0; INFOPLIST_KEY_CFBundleIconFile = idx0.icns; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + INFOPLIST_KEY_SUFeedURL = "https://raw.githubusercontent.com/galz10/idx0-appcast/main/appcast.xml"; + INFOPLIST_KEY_SUPublicEDKey = CHANGE_ME_SPARKLE_PUBLIC_KEY; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.0.12; + MARKETING_VERSION = 0.0.1; OTHER_LDFLAGS = ( "-lc++", "-framework", @@ -1333,12 +1369,6 @@ ); PRODUCT_BUNDLE_IDENTIFIER = com.gal.idx0; PRODUCT_NAME = idx0; - RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; - RUNTIME_EXCEPTION_ALLOW_JIT = NO; - RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO; - RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO; - RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO; - RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = NO; SDKROOT = macosx; SWIFT_OBJC_BRIDGING_HEADER = "idx0-Bridging-Header.h"; }; @@ -1404,10 +1434,7 @@ isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; - CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = 6RFK6NMTV7; GENERATE_INFOPLIST_FILE = YES; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -1488,10 +1515,7 @@ isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; - CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = 6RFK6NMTV7; GENERATE_INFOPLIST_FILE = YES; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -1536,6 +1560,25 @@ defaultConfigurationName = Debug; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 9FF836AC9923AC2A9F49BBD0 /* XCRemoteSwiftPackageReference "Sparkle" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/sparkle-project/Sparkle"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.7.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + F53E79E457609B3BD9B87D33 /* Sparkle */ = { + isa = XCSwiftPackageProductDependency; + package = 9FF836AC9923AC2A9F49BBD0 /* XCRemoteSwiftPackageReference "Sparkle" */; + productName = Sparkle; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = AF7B5A13E0BC2D619DB7A311 /* Project object */; } diff --git a/idx0.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/idx0.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..6e450d9 --- /dev/null +++ b/idx0.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "e721da7f9826abdffcb6185e886155efa2514bd6234475f1afa893e29eb258d6", + "pins" : [ + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "21d8df80440b1ca3b65fa82e40782f1e5a9e6ba2", + "version" : "2.9.0" + } + } + ], + "version" : 3 +} diff --git a/idx0/App/AppCoordinator+ShortcutCommandDispatcher.swift b/idx0/App/AppCoordinator+ShortcutCommandDispatcher.swift index fd0f232..550d688 100644 --- a/idx0/App/AppCoordinator+ShortcutCommandDispatcher.swift +++ b/idx0/App/AppCoordinator+ShortcutCommandDispatcher.swift @@ -97,6 +97,9 @@ extension AppCoordinator { showingQuickSwitch = false showingSettings = true return true + case .checkForUpdates: + appUpdateService.checkNow() + return true case .toggleSidebar: sessionService.saveSettings { $0.sidebarVisible.toggle() } return true diff --git a/idx0/App/AppCoordinator.swift b/idx0/App/AppCoordinator.swift index 6bc888f..34f5957 100644 --- a/idx0/App/AppCoordinator.swift +++ b/idx0/App/AppCoordinator.swift @@ -25,6 +25,7 @@ final class AppCoordinator: ObservableObject { let terminalMonitor = TerminalMonitorService() let autoCheckpointService: AutoCheckpointService let shellPool = ShellPoolService() + let appUpdateService: AppUpdateService private var ipcServer: IPCServer? private let ipcCommandRouter: IPCCommandRouter @@ -83,6 +84,18 @@ final class AppCoordinator: ObservableObject { gitService: gitService, storageURL: paths.appSupportDirectory.appendingPathComponent("auto-checkpoints.json", isDirectory: false) ) + let environmentProvider = ProcessEnvironmentProvider() + let updateDriver = SparkleUpdateDriver(environment: environmentProvider) + let sessionServiceForUpdates = self.sessionService + self.appUpdateService = AppUpdateService( + driver: updateDriver, + scheduler: TimerUpdateScheduler(), + versionProvider: BundleAppVersionProvider(), + environment: environmentProvider, + autoCheckEnabledProvider: { + sessionServiceForUpdates.settings.autoCheckForUpdates + } + ) // Configure terminal monitor self.terminalMonitor.configure( diff --git a/idx0/App/AppSettings.swift b/idx0/App/AppSettings.swift index 86ccebd..768d50b 100644 --- a/idx0/App/AppSettings.swift +++ b/idx0/App/AppSettings.swift @@ -262,7 +262,7 @@ struct NiriSettings: Codable, Equatable { } struct AppSettings: Codable, Equatable { - static let schemaVersion = 7 + static let schemaVersion = 8 var schemaVersion: Int var sidebarVisible: Bool @@ -289,6 +289,7 @@ struct AppSettings: Codable, Equatable { var customKeybindings: [String: KeyChord] var workflowRailWidth: Double var terminalThemeID: String? + var autoCheckForUpdates: Bool init( schemaVersion: Int = AppSettings.schemaVersion, @@ -315,7 +316,8 @@ struct AppSettings: Codable, Equatable { modKeySetting: ModKeySetting = .commandOption, customKeybindings: [String: KeyChord] = [:], workflowRailWidth: Double = 300, - terminalThemeID: String? = nil + terminalThemeID: String? = nil, + autoCheckForUpdates: Bool = true ) { self.schemaVersion = schemaVersion self.sidebarVisible = sidebarVisible @@ -342,6 +344,7 @@ struct AppSettings: Codable, Equatable { self.customKeybindings = customKeybindings self.workflowRailWidth = workflowRailWidth self.terminalThemeID = terminalThemeID + self.autoCheckForUpdates = autoCheckForUpdates } private enum CodingKeys: String, CodingKey { @@ -371,6 +374,7 @@ struct AppSettings: Codable, Equatable { case customKeybindings case workflowRailWidth case terminalThemeID + case autoCheckForUpdates } init(from decoder: Decoder) throws { @@ -407,6 +411,7 @@ struct AppSettings: Codable, Equatable { customKeybindings = try container.decodeIfPresent([String: KeyChord].self, forKey: .customKeybindings) ?? [:] workflowRailWidth = try container.decodeIfPresent(Double.self, forKey: .workflowRailWidth) ?? 300 terminalThemeID = try container.decodeIfPresent(String.self, forKey: .terminalThemeID) + autoCheckForUpdates = try container.decodeIfPresent(Bool.self, forKey: .autoCheckForUpdates) ?? true } func encode(to encoder: Encoder) throws { @@ -436,6 +441,7 @@ struct AppSettings: Codable, Equatable { try container.encode(customKeybindings, forKey: .customKeybindings) try container.encode(workflowRailWidth, forKey: .workflowRailWidth) try container.encodeIfPresent(terminalThemeID, forKey: .terminalThemeID) + try container.encode(autoCheckForUpdates, forKey: .autoCheckForUpdates) // Keep legacy key populated to avoid older dev builds misreading link behavior. try container.encode(openLinksInDefaultBrowser, forKey: .openLinksInDefaultBrowser) } diff --git a/idx0/App/idx0App.swift b/idx0/App/idx0App.swift index 48ed055..5130bd0 100644 --- a/idx0/App/idx0App.swift +++ b/idx0/App/idx0App.swift @@ -14,6 +14,7 @@ struct idx0App: App { .environmentObject(coordinator) .environmentObject(coordinator.sessionService) .environmentObject(coordinator.workflowService) + .environmentObject(coordinator.appUpdateService) .environment(\.themeColors, themeColors) .frame(minWidth: 600, minHeight: 400) .preferredColorScheme(themeColors.isLight ? .light : .dark) @@ -362,6 +363,19 @@ struct idx0App: App { } } + CommandGroup(after: .appInfo) { + Button("Check for Updates…") { + _ = coordinator.performCommand(.checkForUpdates) + } + + if let actionTitle = coordinator.appUpdateService.contextualMenuActionTitle { + Button(actionTitle) { + coordinator.appUpdateService.performPrimaryAction() + } + .disabled(!coordinator.appUpdateService.canPerformPrimaryAction) + } + } + CommandGroup(replacing: .appSettings) { Button("Settings...") { _ = coordinator.performCommand(.openSettings) @@ -374,6 +388,7 @@ struct idx0App: App { SettingsView() .environmentObject(coordinator.sessionService) .environmentObject(coordinator.workflowService) + .environmentObject(coordinator.appUpdateService) } } diff --git a/idx0/Keyboard/ShortcutActionID.swift b/idx0/Keyboard/ShortcutActionID.swift index e54830c..11ff0ad 100644 --- a/idx0/Keyboard/ShortcutActionID.swift +++ b/idx0/Keyboard/ShortcutActionID.swift @@ -15,6 +15,7 @@ enum ShortcutActionID: String, Codable, CaseIterable, Hashable { case commandPalette case keyboardShortcuts case openSettings + case checkForUpdates // Navigation case toggleSidebar diff --git a/idx0/Keyboard/ShortcutRegistry.swift b/idx0/Keyboard/ShortcutRegistry.swift index 9d7e228..e2151d5 100644 --- a/idx0/Keyboard/ShortcutRegistry.swift +++ b/idx0/Keyboard/ShortcutRegistry.swift @@ -292,6 +292,17 @@ struct ShortcutRegistry { macBindings: [.mac(.comma, [.command])], niriBindings: [] ), + ShortcutDescriptor( + id: .checkForUpdates, + title: "Check for Updates", + detail: "Check whether a newer IDX0 version is available", + section: .navigation, + niriActionName: nil, + niriCompatibility: .adapted, + remappable: true, + macBindings: [], + niriBindings: [] + ), ShortcutDescriptor( id: .toggleSidebar, title: "Toggle Sidebar", diff --git a/idx0/Services/Updates/AppUpdateModels.swift b/idx0/Services/Updates/AppUpdateModels.swift new file mode 100644 index 0000000..dcbd6b4 --- /dev/null +++ b/idx0/Services/Updates/AppUpdateModels.swift @@ -0,0 +1,99 @@ +import Foundation + +enum AppUpdateStatus: String, Equatable { + case disabled + case idle + case checking + case upToDate + case available + case downloading + case downloaded + case error +} + +struct AppUpdateState: Equatable { + var currentVersion: String + var availableVersion: String? + var progress: Double? + var lastCheckedAt: Date? + var errorMessage: String? + var enabled: Bool + var status: AppUpdateStatus + + init( + currentVersion: String, + availableVersion: String? = nil, + progress: Double? = nil, + lastCheckedAt: Date? = nil, + errorMessage: String? = nil, + enabled: Bool = true, + status: AppUpdateStatus = .idle + ) { + self.currentVersion = currentVersion + self.availableVersion = availableVersion + self.progress = progress + self.lastCheckedAt = lastCheckedAt + self.errorMessage = errorMessage + self.enabled = enabled + self.status = status + } +} + +enum AppUpdateCheckSource: Equatable { + case startup + case scheduled + case manual + case retry +} + +enum AppUpdateEvent: Equatable { + case policyChanged(enabled: Bool) + case checkRequested(source: AppUpdateCheckSource) + case checkSucceeded(availableVersion: String?, checkedAt: Date) + case checkFailed(message: String, checkedAt: Date) + case downloadStarted + case downloadProgress(Double) + case downloadSucceeded + case downloadFailed(String) + case installStarted + case installFailed(String) +} + +enum AppUpdatePrimaryAction: Equatable { + case check + case download + case install + case retry +} + +enum AppUpdateActionMapper { + static func primaryAction(for status: AppUpdateStatus) -> AppUpdatePrimaryAction? { + switch status { + case .disabled, .checking, .downloading: + return nil + case .idle, .upToDate: + return .check + case .available: + return .download + case .downloaded: + return .install + case .error: + return .retry + } + } + + static func primaryActionTitle(for status: AppUpdateStatus) -> String? { + switch primaryAction(for: status) { + case .check: + return "Check for Updates" + case .download: + return "Download Update" + case .install: + return "Install Update" + case .retry: + return "Retry" + case nil: + return nil + } + } +} diff --git a/idx0/Services/Updates/AppUpdateReducer.swift b/idx0/Services/Updates/AppUpdateReducer.swift new file mode 100644 index 0000000..cb6b190 --- /dev/null +++ b/idx0/Services/Updates/AppUpdateReducer.swift @@ -0,0 +1,78 @@ +import Foundation + +enum AppUpdateReducer { + static func reduce(state: AppUpdateState, event: AppUpdateEvent) -> AppUpdateState { + var next = state + + switch event { + case .policyChanged(let enabled): + next.enabled = enabled + next.progress = nil + if !enabled { + next.status = .disabled + next.errorMessage = nil + } else if next.status == .disabled { + next.status = .idle + next.errorMessage = nil + } + + case .checkRequested: + guard next.enabled else { return next } + if next.status == .checking || next.status == .downloading { + return next + } + next.status = .checking + next.errorMessage = nil + next.progress = nil + + case .checkSucceeded(let availableVersion, let checkedAt): + guard next.enabled else { return next } + next.lastCheckedAt = checkedAt + next.errorMessage = nil + next.progress = nil + next.availableVersion = availableVersion + next.status = availableVersion == nil ? .upToDate : .available + + case .checkFailed(let message, let checkedAt): + guard next.enabled else { return next } + next.lastCheckedAt = checkedAt + next.errorMessage = message + next.progress = nil + next.status = .error + + case .downloadStarted: + guard next.enabled else { return next } + next.status = .downloading + next.progress = 0 + next.errorMessage = nil + + case .downloadProgress(let value): + guard next.enabled else { return next } + next.status = .downloading + next.progress = min(max(value, 0), 1) + + case .downloadSucceeded: + guard next.enabled else { return next } + next.status = .downloaded + next.progress = 1 + next.errorMessage = nil + + case .downloadFailed(let message): + guard next.enabled else { return next } + next.status = .error + next.progress = nil + next.errorMessage = message + + case .installStarted: + guard next.enabled else { return next } + next.errorMessage = nil + + case .installFailed(let message): + guard next.enabled else { return next } + next.status = .error + next.errorMessage = message + } + + return next + } +} diff --git a/idx0/Services/Updates/AppUpdateService.swift b/idx0/Services/Updates/AppUpdateService.swift new file mode 100644 index 0000000..87be1ee --- /dev/null +++ b/idx0/Services/Updates/AppUpdateService.swift @@ -0,0 +1,175 @@ +import Foundation + +@MainActor +final class AppUpdateService: ObservableObject { + static let startupDelay: TimeInterval = 15 + static let pollInterval: TimeInterval = 4 * 60 * 60 + + @Published private(set) var state: AppUpdateState + + private let driver: AppUpdateDriverProtocol + private let scheduler: UpdateSchedulerProtocol + private let versionProvider: AppVersionProviding + private let environment: EnvironmentProviding + private let autoCheckEnabledProvider: () -> Bool + private let now: () -> Date + + private var startupToken: UpdateSchedulerCancellable? + private var repeatingToken: UpdateSchedulerCancellable? + + init( + driver: AppUpdateDriverProtocol, + scheduler: UpdateSchedulerProtocol, + versionProvider: AppVersionProviding, + environment: EnvironmentProviding, + autoCheckEnabledProvider: @escaping () -> Bool, + now: @escaping () -> Date = Date.init + ) { + self.driver = driver + self.scheduler = scheduler + self.versionProvider = versionProvider + self.environment = environment + self.autoCheckEnabledProvider = autoCheckEnabledProvider + self.now = now + + self.state = AppUpdateState(currentVersion: versionProvider.currentVersion) + + self.driver.onEvent = { [weak self] event in + Task { @MainActor in + self?.handleDriverEvent(event) + } + } + + refreshPolicy() + } + + func refreshPolicy() { + let enabled = !environment.isRunningTests && !environment.isDebugBuild && !environment.disableAutoUpdate + state = AppUpdateReducer.reduce(state: state, event: .policyChanged(enabled: enabled)) + configureScheduling() + } + + func checkNow() { + checkNow(source: .manual) + } + + func performPrimaryAction() { + guard let action = AppUpdateActionMapper.primaryAction(for: state.status) else { + return + } + + switch action { + case .check: + checkNow(source: .manual) + case .retry: + checkNow(source: .retry) + case .download: + guard state.enabled else { return } + state = AppUpdateReducer.reduce(state: state, event: .downloadStarted) + driver.downloadUpdate() + case .install: + guard state.enabled else { return } + state = AppUpdateReducer.reduce(state: state, event: .installStarted) + driver.installUpdate() + } + } + + var canPerformPrimaryAction: Bool { + AppUpdateActionMapper.primaryAction(for: state.status) != nil + } + + var primaryActionTitle: String? { + AppUpdateActionMapper.primaryActionTitle(for: state.status) + } + + var contextualMenuActionTitle: String? { + switch state.status { + case .available: + return "Download Update" + case .downloaded: + return "Install Update" + case .error: + return "Retry Update Check" + default: + return nil + } + } + + var statusDescription: String { + switch state.status { + case .disabled: + return "Updates are disabled in this environment." + case .idle: + return autoCheckEnabledProvider() ? "Auto-check is enabled." : "Auto-check is disabled." + case .checking: + return "Checking for updates…" + case .upToDate: + return "IDX0 is up to date." + case .available: + if let availableVersion = state.availableVersion { + return "Version \(availableVersion) is available." + } + return "An update is available." + case .downloading: + let progress = Int((state.progress ?? 0) * 100) + return "Downloading update (\(progress)%)." + case .downloaded: + return "Update downloaded and ready to install." + case .error: + return state.errorMessage ?? "Update check failed." + } + } + + private func configureScheduling() { + startupToken?.cancel() + repeatingToken?.cancel() + startupToken = nil + repeatingToken = nil + + guard state.enabled, autoCheckEnabledProvider() else { + return + } + + startupToken = scheduler.schedule(after: Self.startupDelay) { [weak self] in + self?.checkNow(source: .startup) + } + + repeatingToken = scheduler.scheduleRepeating(every: Self.pollInterval) { [weak self] in + self?.checkNow(source: .scheduled) + } + } + + private func checkNow(source: AppUpdateCheckSource) { + guard state.enabled else { return } + guard state.status != .checking, state.status != .downloading else { return } + + state = AppUpdateReducer.reduce(state: state, event: .checkRequested(source: source)) + driver.checkForUpdates( + feedURLOverride: environment.updateFeedURLOverride, + currentVersion: state.currentVersion + ) + } + + private func handleDriverEvent(_ event: AppUpdateDriverEvent) { + switch event { + case .checkSucceeded(let availableVersion, _): + state = AppUpdateReducer.reduce( + state: state, + event: .checkSucceeded(availableVersion: availableVersion, checkedAt: now()) + ) + case .checkFailed(let message): + state = AppUpdateReducer.reduce( + state: state, + event: .checkFailed(message: message, checkedAt: now()) + ) + case .downloadProgress(let value): + state = AppUpdateReducer.reduce(state: state, event: .downloadProgress(value)) + case .downloadCompleted: + state = AppUpdateReducer.reduce(state: state, event: .downloadSucceeded) + case .downloadFailed(let message): + state = AppUpdateReducer.reduce(state: state, event: .downloadFailed(message)) + case .installFailed(let message): + state = AppUpdateReducer.reduce(state: state, event: .installFailed(message)) + } + } +} diff --git a/idx0/Services/Updates/AppUpdateSupport.swift b/idx0/Services/Updates/AppUpdateSupport.swift new file mode 100644 index 0000000..514390d --- /dev/null +++ b/idx0/Services/Updates/AppUpdateSupport.swift @@ -0,0 +1,148 @@ +import Foundation + +@MainActor +protocol AppUpdateDriverProtocol: AnyObject { + var onEvent: ((AppUpdateDriverEvent) -> Void)? { get set } + func checkForUpdates(feedURLOverride: URL?, currentVersion: String) + func downloadUpdate() + func installUpdate() +} + +enum AppUpdateDriverEvent: Equatable { + case checkSucceeded(availableVersion: String?, downloadURL: URL?) + case checkFailed(message: String) + case downloadProgress(Double) + case downloadCompleted + case downloadFailed(message: String) + case installFailed(message: String) +} + +protocol UpdateSchedulerCancellable { + func cancel() +} + +@MainActor +protocol UpdateSchedulerProtocol { + @discardableResult + func schedule( + after interval: TimeInterval, + _ action: @escaping @Sendable @MainActor () -> Void + ) -> UpdateSchedulerCancellable + + @discardableResult + func scheduleRepeating( + every interval: TimeInterval, + _ action: @escaping @Sendable @MainActor () -> Void + ) -> UpdateSchedulerCancellable +} + +protocol AppVersionProviding { + var currentVersion: String { get } +} + +protocol EnvironmentProviding { + var isRunningTests: Bool { get } + var isDebugBuild: Bool { get } + var disableAutoUpdate: Bool { get } + var updateFeedURLOverride: URL? { get } + var defaultUpdateFeedURL: URL? { get } +} + +struct BundleAppVersionProvider: AppVersionProviding { + private let bundle: Bundle + + init(bundle: Bundle = .main) { + self.bundle = bundle + } + + var currentVersion: String { + guard let short = bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String else { + return "0.0.0" + } + + let cleaned = short.trimmingCharacters(in: .whitespacesAndNewlines) + return cleaned.isEmpty ? "0.0.0" : cleaned + } +} + +struct ProcessEnvironmentProvider: EnvironmentProviding { + private let environment: [String: String] + private let bundle: Bundle + + init(environment: [String: String] = ProcessInfo.processInfo.environment, bundle: Bundle = .main) { + self.environment = environment + self.bundle = bundle + } + + var isRunningTests: Bool { + environment["XCTestBundlePath"] != nil || environment["XCTestConfigurationFilePath"] != nil + } + + var isDebugBuild: Bool { + #if DEBUG + true + #else + false + #endif + } + + var disableAutoUpdate: Bool { + environment["IDX0_DISABLE_AUTO_UPDATE"] == "1" + } + + var updateFeedURLOverride: URL? { + guard let raw = environment["IDX0_UPDATE_FEED_URL"], !raw.isEmpty else { + return nil + } + return URL(string: raw) + } + + var defaultUpdateFeedURL: URL? { + guard let raw = bundle.object(forInfoDictionaryKey: "SUFeedURL") as? String, + !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return nil + } + return URL(string: raw) + } +} + +@MainActor +final class TimerUpdateScheduler: UpdateSchedulerProtocol { + @discardableResult + func schedule( + after interval: TimeInterval, + _ action: @escaping @Sendable @MainActor () -> Void + ) -> UpdateSchedulerCancellable { + let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in + Task { @MainActor in + action() + } + } + return TimerUpdateSchedulerToken(timer: timer) + } + + @discardableResult + func scheduleRepeating( + every interval: TimeInterval, + _ action: @escaping @Sendable @MainActor () -> Void + ) -> UpdateSchedulerCancellable { + let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in + Task { @MainActor in + action() + } + } + return TimerUpdateSchedulerToken(timer: timer) + } +} + +private final class TimerUpdateSchedulerToken: UpdateSchedulerCancellable { + private weak var timer: Timer? + + init(timer: Timer) { + self.timer = timer + } + + func cancel() { + timer?.invalidate() + } +} diff --git a/idx0/Services/Updates/AppcastFeedBuilder.swift b/idx0/Services/Updates/AppcastFeedBuilder.swift new file mode 100644 index 0000000..1673fef --- /dev/null +++ b/idx0/Services/Updates/AppcastFeedBuilder.swift @@ -0,0 +1,140 @@ +import Foundation + +struct AppcastReleaseEntry: Equatable { + var version: String + var downloadURL: URL + var length: Int + var publishedAt: Date + var prerelease: Bool + var signature: String? + var minimumSystemVersion: String? + var notesURL: URL? +} + +enum AppcastFeedBuilder { + static func buildXML( + entries: [AppcastReleaseEntry], + title: String = "IDX0", + includePrerelease: Bool = false + ) -> String { + let filtered = entries + .filter { includePrerelease || !$0.prerelease } + .sorted(by: sortEntries) + + guard !filtered.isEmpty else { + return """ + + + + \(xmlEscape("\(title) Updates")) + \(xmlEscape("Latest releases for \(title)")) + + + """ + } + + var items: [String] = [] + for entry in filtered { + var enclosureAttributes: [String: String] = [ + "url": entry.downloadURL.absoluteString, + "length": "\(entry.length)", + "type": "application/octet-stream", + "sparkle:version": buildVersion(from: entry.version), + "sparkle:shortVersionString": entry.version, + ] + + if let signature = entry.signature, !signature.isEmpty { + enclosureAttributes["sparkle:edSignature"] = signature + } + if let minimumSystemVersion = entry.minimumSystemVersion, !minimumSystemVersion.isEmpty { + enclosureAttributes["sparkle:minimumSystemVersion"] = minimumSystemVersion + } + + let enclosureText = enclosureAttributes + .sorted(by: { $0.key < $1.key }) + .map { key, value in "\(key)=\"\(xmlEscape(value))\"" } + .joined(separator: " ") + + var itemLines: [String] = [ + " ", + " \(xmlEscape("\(title) \(entry.version)"))", + " \(xmlEscape(httpDateFormatter.string(from: entry.publishedAt)))", + " ", + ] + + if let notesURL = entry.notesURL { + itemLines.append(" \(xmlEscape(notesURL.absoluteString))") + } + + itemLines.append(" ") + items.append(itemLines.joined(separator: "\n")) + } + + return """ + + + + \(xmlEscape("\(title) Updates")) + \(xmlEscape("Latest releases for \(title)")) + \(items.joined(separator: "\n")) + + + """ + } + + private static func sortEntries(lhs: AppcastReleaseEntry, rhs: AppcastReleaseEntry) -> Bool { + let l = semanticVersionComponents(lhs.version) + let r = semanticVersionComponents(rhs.version) + + if l.numeric != r.numeric { + for index in 0.. rv + } + } + } + + if l.isPrerelease != r.isPrerelease { + return !l.isPrerelease + } + + return lhs.publishedAt > rhs.publishedAt + } + + private static func semanticVersionComponents(_ raw: String) -> (numeric: [Int], isPrerelease: Bool) { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "^v", with: "", options: .regularExpression) + let parts = trimmed.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false) + let numeric = parts.first? + .split(separator: ".") + .map { Int($0) ?? 0 } ?? [] + return (numeric: numeric, isPrerelease: parts.count > 1) + } + + private static func buildVersion(from version: String) -> String { + let digits = version.compactMap { char -> String? in + char.isNumber ? String(char) : nil + } + let compact = digits.joined() + return compact.isEmpty ? version : compact + } + + private static func xmlEscape(_ text: String) -> String { + text + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "\"", with: """) + .replacingOccurrences(of: "'", with: "'") + } + + private static let httpDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z" + return formatter + }() +} diff --git a/idx0/Services/Updates/SparkleUpdateDriver.swift b/idx0/Services/Updates/SparkleUpdateDriver.swift new file mode 100644 index 0000000..d9f488b --- /dev/null +++ b/idx0/Services/Updates/SparkleUpdateDriver.swift @@ -0,0 +1,210 @@ +import AppKit +import Foundation +#if canImport(Sparkle) +import Sparkle +#endif + +@MainActor +final class SparkleUpdateDriver: AppUpdateDriverProtocol { + var onEvent: ((AppUpdateDriverEvent) -> Void)? + + private let environment: EnvironmentProviding + private let parser = AppcastFeedParser() + private var latestDownloadURL: URL? + private var checkTask: Task? + + init(environment: EnvironmentProviding = ProcessEnvironmentProvider()) { + self.environment = environment + } + + deinit { + checkTask?.cancel() + } + + func checkForUpdates(feedURLOverride: URL?, currentVersion: String) { + let feedURL = feedURLOverride ?? environment.defaultUpdateFeedURL + guard let feedURL else { + onEvent?(.checkFailed(message: "Update feed URL is not configured.")) + return + } + + checkTask?.cancel() + checkTask = Task { + do { + let (data, response) = try await URLSession.shared.data(from: feedURL) + if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + self.onEvent?(.checkFailed(message: "Update feed request failed with status \(http.statusCode).")) + return + } + + guard let item = parser.parseFirstItem(from: data) else { + self.latestDownloadURL = nil + self.onEvent?(.checkSucceeded(availableVersion: nil, downloadURL: nil)) + return + } + + let availableVersion = item.version + let downloadURL = item.downloadURL + let hasUpdate = isVersion(availableVersion, newerThan: currentVersion) + + self.latestDownloadURL = hasUpdate ? downloadURL : nil + self.onEvent?( + .checkSucceeded( + availableVersion: hasUpdate ? availableVersion : nil, + downloadURL: hasUpdate ? downloadURL : nil + ) + ) + } catch { + if Task.isCancelled { + return + } + self.onEvent?(.checkFailed(message: error.localizedDescription)) + } + } + } + + func downloadUpdate() { + guard let url = latestDownloadURL else { + onEvent?(.downloadFailed(message: "No update download URL is available.")) + return + } + + Task { + onEvent?(.downloadProgress(0.1)) + try? await Task.sleep(nanoseconds: 120_000_000) + onEvent?(.downloadProgress(0.5)) + _ = NSWorkspace.shared.open(url) + onEvent?(.downloadProgress(1.0)) + onEvent?(.downloadCompleted) + } + } + + func installUpdate() { + guard let url = latestDownloadURL else { + onEvent?(.installFailed(message: "No downloaded update is available to install.")) + return + } + + if !NSWorkspace.shared.open(url) { + onEvent?(.installFailed(message: "Could not open the downloaded update package.")) + } + } + + private func isVersion(_ lhsRaw: String, newerThan rhsRaw: String) -> Bool { + let lhs = normalizeVersion(lhsRaw) + let rhs = normalizeVersion(rhsRaw) + + if lhs.numeric != rhs.numeric { + let maxCount = max(lhs.numeric.count, rhs.numeric.count) + for index in 0.. r + } + } + } + + switch (lhs.hasPrerelease, rhs.hasPrerelease) { + case (false, true): + return true + case (true, false): + return false + default: + return lhs.raw > rhs.raw + } + } + + private func normalizeVersion(_ raw: String) -> (raw: String, numeric: [Int], hasPrerelease: Bool) { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "v", with: "", options: [.caseInsensitive], range: raw.hasPrefix("v") ? raw.startIndex.. 1 + return (raw: trimmed, numeric: numeric, hasPrerelease: hasPrerelease) + } +} + +private struct AppcastItem { + let version: String + let downloadURL: URL +} + +private final class AppcastFeedParser: NSObject, XMLParserDelegate { + private var currentElement = "" + private var inItem = false + private var capturedVersion: String? + private var capturedURL: URL? + private var textBuffer = "" + + func parseFirstItem(from data: Data) -> AppcastItem? { + currentElement = "" + inItem = false + capturedVersion = nil + capturedURL = nil + textBuffer = "" + + let parser = XMLParser(data: data) + parser.delegate = self + parser.parse() + + guard let version = capturedVersion, + let url = capturedURL else { + return nil + } + + return AppcastItem(version: version, downloadURL: url) + } + + func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String : String] = [:]) { + currentElement = qName ?? elementName + textBuffer = "" + + if currentElement == "item" { + inItem = true + return + } + + guard inItem, currentElement == "enclosure" else { return } + + if capturedURL == nil, + let rawURL = attributeDict["url"], + let url = URL(string: rawURL) { + capturedURL = url + } + + if capturedVersion == nil { + if let shortVersion = attributeDict["sparkle:shortVersionString"], !shortVersion.isEmpty { + capturedVersion = shortVersion + } else if let version = attributeDict["sparkle:version"], !version.isEmpty { + capturedVersion = version + } + } + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + guard inItem else { return } + textBuffer += string + } + + func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) { + let name = qName ?? elementName + + guard inItem else { return } + + let value = textBuffer.trimmingCharacters(in: .whitespacesAndNewlines) + if capturedVersion == nil, + (name == "sparkle:shortVersionString" || name == "sparkle:version"), + !value.isEmpty { + capturedVersion = value + } + + if name == "item" { + inItem = false + } + + textBuffer = "" + } +} diff --git a/idx0/UI/CommandPaletteOverlay.swift b/idx0/UI/CommandPaletteOverlay.swift index 8697932..4a88d9f 100644 --- a/idx0/UI/CommandPaletteOverlay.swift +++ b/idx0/UI/CommandPaletteOverlay.swift @@ -4,6 +4,7 @@ struct CommandPaletteOverlay: View { @EnvironmentObject private var coordinator: AppCoordinator @EnvironmentObject private var sessionService: SessionService @EnvironmentObject private var workflowService: WorkflowService + @EnvironmentObject private var appUpdateService: AppUpdateService @Environment(\.themeColors) private var tc @FocusState private var queryFocused: Bool @@ -317,6 +318,16 @@ struct CommandPaletteOverlay: View { isEnabled: true, run: { _ = coordinator.performCommand(.openSettings) } ), + PaletteAction( + id: "check-for-updates", + icon: "arrow.triangle.2.circlepath", + title: appUpdateService.primaryActionTitle ?? "Check for Updates", + detail: appUpdateService.statusDescription, + shortcut: shortcutLabel(.checkForUpdates), + searchText: "check updates download install retry", + isEnabled: appUpdateService.canPerformPrimaryAction, + run: { _ = coordinator.performCommand(.checkForUpdates) } + ), ] if niriMode { diff --git a/idx0/UI/MainWindow/TabBarOverlay.swift b/idx0/UI/MainWindow/TabBarOverlay.swift index 23cf1b4..9046f1b 100644 --- a/idx0/UI/MainWindow/TabBarOverlay.swift +++ b/idx0/UI/MainWindow/TabBarOverlay.swift @@ -5,6 +5,7 @@ import SwiftUI struct TabBarOverlay: View { @EnvironmentObject private var sessionService: SessionService @EnvironmentObject private var coordinator: AppCoordinator + @EnvironmentObject private var appUpdateService: AppUpdateService @Environment(\.themeColors) private var tc @State private var isHovering = false @@ -21,6 +22,34 @@ struct TabBarOverlay: View { .frame(width: 8, height: 28) } + Button { + appUpdateService.performPrimaryAction() + } label: { + HStack(spacing: 4) { + Circle() + .fill(updateIndicatorColor) + .frame(width: 7, height: 7) + + if appUpdateService.state.status == .checking || appUpdateService.state.status == .downloading { + ProgressView() + .controlSize(.mini) + .scaleEffect(0.55) + .frame(width: 8, height: 8) + } else { + Image(systemName: updateIconName) + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(isHovering ? tc.secondaryText : tc.mutedText) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(tc.surface0.opacity(0.8), in: Capsule()) + } + .buttonStyle(.plain) + .disabled(!appUpdateService.canPerformPrimaryAction) + .help(updateButtonHelp) + .padding(.trailing, 6) + // Session info if let session = sessionService.selectedSession { Text(Self.displayTitle(for: session)) @@ -143,5 +172,41 @@ struct TabBarOverlay: View { } } } -} + private var updateIndicatorColor: Color { + switch appUpdateService.state.status { + case .disabled: + return .gray.opacity(0.55) + case .idle, .upToDate: + return .mint.opacity(0.8) + case .checking: + return .blue.opacity(0.85) + case .available: + return .orange + case .downloading: + return .blue + case .downloaded: + return .green + case .error: + return .red + } + } + + private var updateIconName: String { + switch appUpdateService.state.status { + case .downloaded: + return "arrow.down.app.fill" + case .error: + return "exclamationmark.triangle" + default: + return "arrow.triangle.2.circlepath" + } + } + + private var updateButtonHelp: String { + guard let actionTitle = appUpdateService.primaryActionTitle else { + return appUpdateService.statusDescription + } + return "\(actionTitle) • \(appUpdateService.statusDescription)" + } +} diff --git a/idx0/UI/Settings/Inline/InlineAdvancedSettings.swift b/idx0/UI/Settings/Inline/InlineAdvancedSettings.swift index 074f76a..94cc674 100644 --- a/idx0/UI/Settings/Inline/InlineAdvancedSettings.swift +++ b/idx0/UI/Settings/Inline/InlineAdvancedSettings.swift @@ -4,6 +4,7 @@ import SwiftUI struct InlineAdvancedSettings: View { @ObservedObject var sessionService: SessionService + @EnvironmentObject private var appUpdateService: AppUpdateService @Environment(\.themeColors) private var tc @State private var onboardingResetPending = false @@ -66,6 +67,54 @@ struct InlineAdvancedSettings: View { .frame(maxWidth: 420) } + SettingDivider() + SettingSectionHeader(title: "Updates") + + SettingToggleRow( + label: "Auto-check for updates", + caption: "Check in the background after startup and every few hours.", + isOn: Binding( + get: { sessionService.settings.autoCheckForUpdates }, + set: { newValue in + withAnimation(.easeOut(duration: 0.15)) { + sessionService.saveSettings { settings in + settings.autoCheckForUpdates = newValue + } + appUpdateService.refreshPolicy() + } + } + ) + ) + + SettingRowView(label: "Status", caption: appUpdateService.statusDescription) { + HStack(spacing: 8) { + if let actionTitle = appUpdateService.primaryActionTitle { + Button { + appUpdateService.performPrimaryAction() + } label: { + Text(actionTitle) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(tc.secondaryText) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(tc.surface0, in: RoundedRectangle(cornerRadius: 4)) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(tc.surface2.opacity(0.5), lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + .disabled(!appUpdateService.canPerformPrimaryAction) + } + + if let lastChecked = appUpdateService.state.lastCheckedAt { + Text(lastChecked.formatted(date: .abbreviated, time: .shortened)) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(tc.tertiaryText) + } + } + } + SettingDivider() SettingSectionHeader(title: "Reset") diff --git a/idx0/UI/Settings/Tabs/AdvancedSettingsTab.swift b/idx0/UI/Settings/Tabs/AdvancedSettingsTab.swift index 0b90260..4797e60 100644 --- a/idx0/UI/Settings/Tabs/AdvancedSettingsTab.swift +++ b/idx0/UI/Settings/Tabs/AdvancedSettingsTab.swift @@ -4,6 +4,7 @@ import SwiftUI struct AdvancedSettingsTab: View { @ObservedObject var sessionService: SessionService + @EnvironmentObject private var appUpdateService: AppUpdateService var body: some View { Form { @@ -25,6 +26,38 @@ struct AdvancedSettingsTab: View { .foregroundStyle(.tertiary) } + Section("Updates") { + Toggle( + "Auto-check for updates", + isOn: Binding( + get: { sessionService.settings.autoCheckForUpdates }, + set: { newValue in + sessionService.saveSettings { settings in + settings.autoCheckForUpdates = newValue + } + appUpdateService.refreshPolicy() + } + ) + ) + + Text(appUpdateService.statusDescription) + .font(.caption) + .foregroundStyle(.tertiary) + + if let lastChecked = appUpdateService.state.lastCheckedAt { + Text("Last checked: \(lastChecked.formatted(date: .abbreviated, time: .shortened))") + .font(.caption2) + .foregroundStyle(.tertiary) + } + + if let actionTitle = appUpdateService.primaryActionTitle { + Button(actionTitle) { + appUpdateService.performPrimaryAction() + } + .disabled(!appUpdateService.canPerformPrimaryAction) + } + } + Section("Reset") { Button("Reset Niri Walkthrough (requires restart)") { sessionService.saveSettings { settings in diff --git a/idx0Tests/AppCommandRegistryTests.swift b/idx0Tests/AppCommandRegistryTests.swift index b6efe93..9f0842a 100644 --- a/idx0Tests/AppCommandRegistryTests.swift +++ b/idx0Tests/AppCommandRegistryTests.swift @@ -20,4 +20,12 @@ final class AppCommandRegistryTests: XCTestCase { XCTAssertEqual(all.count, Set(all).count) XCTAssertFalse(all.contains { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) } + + func testCheckForUpdatesCommandIsPresentOnAllSurfaces() { + let registry = AppCommandRegistry.shared + XCTAssertNotNil(registry.descriptor(for: .checkForUpdates)) + XCTAssertTrue(registry.shortcutCommandIDs.contains(.checkForUpdates)) + XCTAssertTrue(registry.menuCommandIDs.contains(.checkForUpdates)) + XCTAssertTrue(registry.paletteCommandIDs.contains(.checkForUpdates)) + } } diff --git a/idx0Tests/AppSettingsKeyboardTests.swift b/idx0Tests/AppSettingsKeyboardTests.swift index 7f8a172..698638e 100644 --- a/idx0Tests/AppSettingsKeyboardTests.swift +++ b/idx0Tests/AppSettingsKeyboardTests.swift @@ -20,6 +20,7 @@ final class AppSettingsKeyboardTests: XCTestCase { XCTAssertNil(decoded.terminalStartupCommandTemplate) XCTAssertNil(decoded.niri.defaultNewColumnWidth) XCTAssertNil(decoded.niri.defaultNewTileHeight) + XCTAssertTrue(decoded.autoCheckForUpdates) } func testRoundTripPersistsKeyboardSettings() throws { @@ -31,6 +32,7 @@ final class AppSettingsKeyboardTests: XCTestCase { settings.terminalStartupCommandTemplate = "cd ${WORKDIR} && echo ${SESSION_ID}" settings.niri.defaultNewColumnWidth = 920 settings.niri.defaultNewTileHeight = 540 + settings.autoCheckForUpdates = false settings.customKeybindings[ShortcutActionID.niriToggleOverview.rawValue] = KeyChord( key: .o, modifiers: [.option, .control] @@ -46,6 +48,7 @@ final class AppSettingsKeyboardTests: XCTestCase { XCTAssertEqual(decoded.terminalStartupCommandTemplate, "cd ${WORKDIR} && echo ${SESSION_ID}") XCTAssertEqual(decoded.niri.defaultNewColumnWidth, 920) XCTAssertEqual(decoded.niri.defaultNewTileHeight, 540) + XCTAssertFalse(decoded.autoCheckForUpdates) XCTAssertEqual( decoded.customKeybindings[ShortcutActionID.niriToggleOverview.rawValue], KeyChord(key: .o, modifiers: [.option, .control]) diff --git a/idx0Tests/AppUpdateActionMapperTests.swift b/idx0Tests/AppUpdateActionMapperTests.swift new file mode 100644 index 0000000..a4aa111 --- /dev/null +++ b/idx0Tests/AppUpdateActionMapperTests.swift @@ -0,0 +1,23 @@ +import XCTest +@testable import idx0 + +final class AppUpdateActionMapperTests: XCTestCase { + func testPrimaryActionMappingByStatus() { + XCTAssertNil(AppUpdateActionMapper.primaryAction(for: .disabled)) + XCTAssertEqual(AppUpdateActionMapper.primaryAction(for: .idle), .check) + XCTAssertNil(AppUpdateActionMapper.primaryAction(for: .checking)) + XCTAssertEqual(AppUpdateActionMapper.primaryAction(for: .upToDate), .check) + XCTAssertEqual(AppUpdateActionMapper.primaryAction(for: .available), .download) + XCTAssertNil(AppUpdateActionMapper.primaryAction(for: .downloading)) + XCTAssertEqual(AppUpdateActionMapper.primaryAction(for: .downloaded), .install) + XCTAssertEqual(AppUpdateActionMapper.primaryAction(for: .error), .retry) + } + + func testPrimaryActionTitlesMatchStatus() { + XCTAssertEqual(AppUpdateActionMapper.primaryActionTitle(for: .idle), "Check for Updates") + XCTAssertEqual(AppUpdateActionMapper.primaryActionTitle(for: .available), "Download Update") + XCTAssertEqual(AppUpdateActionMapper.primaryActionTitle(for: .downloaded), "Install Update") + XCTAssertEqual(AppUpdateActionMapper.primaryActionTitle(for: .error), "Retry") + XCTAssertNil(AppUpdateActionMapper.primaryActionTitle(for: .checking)) + } +} diff --git a/idx0Tests/AppUpdateReducerTests.swift b/idx0Tests/AppUpdateReducerTests.swift new file mode 100644 index 0000000..5ed14f1 --- /dev/null +++ b/idx0Tests/AppUpdateReducerTests.swift @@ -0,0 +1,96 @@ +import XCTest +@testable import idx0 + +final class AppUpdateReducerTests: XCTestCase { + func testCheckRequestTransitionsToChecking() { + var state = AppUpdateState(currentVersion: "1.0.0") + state.errorMessage = "old" + state.progress = 0.4 + + let next = AppUpdateReducer.reduce(state: state, event: .checkRequested(source: .manual)) + + XCTAssertEqual(next.status, .checking) + XCTAssertNil(next.errorMessage) + XCTAssertNil(next.progress) + } + + func testCheckSuccessWithAvailableVersionTransitionsToAvailable() { + let checkedAt = Date(timeIntervalSince1970: 10) + let state = AppUpdateState(currentVersion: "1.0.0", status: .checking) + + let next = AppUpdateReducer.reduce( + state: state, + event: .checkSucceeded(availableVersion: "1.1.0", checkedAt: checkedAt) + ) + + XCTAssertEqual(next.status, .available) + XCTAssertEqual(next.availableVersion, "1.1.0") + XCTAssertEqual(next.lastCheckedAt, checkedAt) + } + + func testCheckSuccessWithNoVersionTransitionsToUpToDate() { + let checkedAt = Date(timeIntervalSince1970: 11) + let state = AppUpdateState(currentVersion: "1.1.0", status: .checking) + + let next = AppUpdateReducer.reduce( + state: state, + event: .checkSucceeded(availableVersion: nil, checkedAt: checkedAt) + ) + + XCTAssertEqual(next.status, .upToDate) + XCTAssertNil(next.availableVersion) + XCTAssertEqual(next.lastCheckedAt, checkedAt) + } + + func testDownloadLifecycleTransitions() { + let base = AppUpdateState(currentVersion: "1.0.0", availableVersion: "1.1.0", status: .available) + + let started = AppUpdateReducer.reduce(state: base, event: .downloadStarted) + XCTAssertEqual(started.status, .downloading) + XCTAssertEqual(started.progress, 0) + + let progress = AppUpdateReducer.reduce(state: started, event: .downloadProgress(0.65)) + XCTAssertEqual(progress.status, .downloading) + XCTAssertEqual(try XCTUnwrap(progress.progress), 0.65, accuracy: 0.0001) + + let completed = AppUpdateReducer.reduce(state: progress, event: .downloadSucceeded) + XCTAssertEqual(completed.status, .downloaded) + XCTAssertEqual(completed.progress, 1) + + let failed = AppUpdateReducer.reduce(state: progress, event: .downloadFailed("network")) + XCTAssertEqual(failed.status, .error) + XCTAssertEqual(failed.errorMessage, "network") + } + + func testInstallFailureCanRetryToChecking() { + let errored = AppUpdateReducer.reduce( + state: AppUpdateState(currentVersion: "1.0.0", status: .downloaded), + event: .installFailed("failed") + ) + XCTAssertEqual(errored.status, .error) + + let retried = AppUpdateReducer.reduce(state: errored, event: .checkRequested(source: .retry)) + XCTAssertEqual(retried.status, .checking) + XCTAssertNil(retried.errorMessage) + } + + func testPolicyDisableAndReenableTransitions() { + let state = AppUpdateState(currentVersion: "1.0.0", status: .upToDate) + + let disabled = AppUpdateReducer.reduce(state: state, event: .policyChanged(enabled: false)) + XCTAssertEqual(disabled.status, .disabled) + XCTAssertFalse(disabled.enabled) + + let reenabled = AppUpdateReducer.reduce(state: disabled, event: .policyChanged(enabled: true)) + XCTAssertEqual(reenabled.status, .idle) + XCTAssertTrue(reenabled.enabled) + } + + func testCheckRequestIsIgnoredWhileDownloading() { + let state = AppUpdateState(currentVersion: "1.0.0", status: .downloading) + + let next = AppUpdateReducer.reduce(state: state, event: .checkRequested(source: .manual)) + + XCTAssertEqual(next, state) + } +} diff --git a/idx0Tests/AppUpdateServiceTests.swift b/idx0Tests/AppUpdateServiceTests.swift new file mode 100644 index 0000000..7da0ffa --- /dev/null +++ b/idx0Tests/AppUpdateServiceTests.swift @@ -0,0 +1,254 @@ +import Foundation +import XCTest +@testable import idx0 + +@MainActor +final class AppUpdateServiceTests: XCTestCase { + func testStartupAndRepeatingChecksAreScheduledWhenEnabled() async { + let driver = FakeUpdateDriver() + let scheduler = FakeScheduler() + let environment = FakeEnvironment() + + let service = AppUpdateService( + driver: driver, + scheduler: scheduler, + versionProvider: FakeVersionProvider(currentVersion: "1.0.0"), + environment: environment, + autoCheckEnabledProvider: { true } + ) + _ = service + + XCTAssertEqual(scheduler.oneShotIntervals, [AppUpdateService.startupDelay]) + XCTAssertEqual(scheduler.repeatingIntervals, [AppUpdateService.pollInterval]) + + scheduler.fireOneShot(at: 0) + XCTAssertEqual(driver.checkCalls.count, 1) + XCTAssertEqual(driver.checkCalls.first?.currentVersion, "1.0.0") + + scheduler.fireRepeating(at: 0) + XCTAssertEqual(driver.checkCalls.count, 1, "second check should be blocked while checking") + + driver.emit(.checkSucceeded(availableVersion: nil, downloadURL: nil)) + await flushMainActorTasks() + scheduler.fireRepeating(at: 0) + XCTAssertEqual(driver.checkCalls.count, 2) + } + + func testManualCheckAndPrimaryActionsFollowStateMachine() async { + let driver = FakeUpdateDriver() + let service = makeService(driver: driver) + + service.checkNow() + XCTAssertEqual(driver.checkCalls.count, 1) + XCTAssertEqual(service.state.status, .checking) + + driver.emit(.checkSucceeded(availableVersion: "1.2.0", downloadURL: URL(string: "https://example.com/idx0.zip"))) + await flushMainActorTasks() + XCTAssertEqual(service.state.status, .available) + + service.performPrimaryAction() + XCTAssertEqual(driver.downloadCount, 1) + XCTAssertEqual(service.state.status, .downloading) + + driver.emit(.downloadCompleted) + await flushMainActorTasks() + XCTAssertEqual(service.state.status, .downloaded) + + service.performPrimaryAction() + XCTAssertEqual(driver.installCount, 1) + } + + func testDisabledPolicySkipsSchedulingAndChecks() { + let driver = FakeUpdateDriver() + let scheduler = FakeScheduler() + let service = AppUpdateService( + driver: driver, + scheduler: scheduler, + versionProvider: FakeVersionProvider(currentVersion: "1.0.0"), + environment: FakeEnvironment(isRunningTests: true), + autoCheckEnabledProvider: { true } + ) + + XCTAssertEqual(service.state.status, .disabled) + XCTAssertTrue(scheduler.oneShotIntervals.isEmpty) + XCTAssertTrue(scheduler.repeatingIntervals.isEmpty) + + service.checkNow() + XCTAssertTrue(driver.checkCalls.isEmpty) + } + + func testAutoCheckToggleOffDisablesSchedulingButAllowsManualCheck() { + let driver = FakeUpdateDriver() + let scheduler = FakeScheduler() + let service = AppUpdateService( + driver: driver, + scheduler: scheduler, + versionProvider: FakeVersionProvider(currentVersion: "1.0.0"), + environment: FakeEnvironment(), + autoCheckEnabledProvider: { false } + ) + + XCTAssertEqual(service.state.status, .idle) + XCTAssertTrue(scheduler.oneShotIntervals.isEmpty) + XCTAssertTrue(scheduler.repeatingIntervals.isEmpty) + + service.checkNow() + XCTAssertEqual(driver.checkCalls.count, 1) + } + + func testCheckRequestsAreIgnoredDuringDownload() async { + let driver = FakeUpdateDriver() + let service = makeService(driver: driver) + + service.checkNow() + driver.emit(.checkSucceeded(availableVersion: "1.2.0", downloadURL: URL(string: "https://example.com/idx0.zip"))) + await flushMainActorTasks() + service.performPrimaryAction() // download + + service.checkNow() + + XCTAssertEqual(driver.downloadCount, 1) + XCTAssertEqual(driver.checkCalls.count, 1) + XCTAssertEqual(service.state.status, .downloading) + } + + func testContextualActionTitlesFollowState() async { + let driver = FakeUpdateDriver() + let service = makeService(driver: driver) + + XCTAssertNil(service.contextualMenuActionTitle) + + service.checkNow() + driver.emit(.checkSucceeded(availableVersion: "1.2.0", downloadURL: URL(string: "https://example.com/idx0.zip"))) + await flushMainActorTasks() + XCTAssertEqual(service.contextualMenuActionTitle, "Download Update") + + service.performPrimaryAction() + driver.emit(.downloadCompleted) + await flushMainActorTasks() + XCTAssertEqual(service.contextualMenuActionTitle, "Install Update") + + driver.emit(.installFailed(message: "nope")) + await flushMainActorTasks() + XCTAssertEqual(service.contextualMenuActionTitle, "Retry Update Check") + } + + private func makeService( + driver: FakeUpdateDriver, + scheduler: FakeScheduler = FakeScheduler(), + autoCheckEnabledProvider: @escaping () -> Bool = { true } + ) -> AppUpdateService { + AppUpdateService( + driver: driver, + scheduler: scheduler, + versionProvider: FakeVersionProvider(currentVersion: "1.0.0"), + environment: FakeEnvironment(), + autoCheckEnabledProvider: autoCheckEnabledProvider, + now: { Date(timeIntervalSince1970: 100) } + ) + } + + private func flushMainActorTasks() async { + await Task.yield() + await Task.yield() + } +} + +private struct FakeVersionProvider: AppVersionProviding { + let currentVersion: String +} + +private struct FakeEnvironment: EnvironmentProviding { + var isRunningTests: Bool = false + var isDebugBuild: Bool = false + var disableAutoUpdate: Bool = false + var updateFeedURLOverride: URL? = nil + var defaultUpdateFeedURL: URL? = URL(string: "https://example.com/appcast.xml") +} + +@MainActor +private final class FakeUpdateDriver: AppUpdateDriverProtocol { + struct CheckCall: Equatable { + let feedURLOverride: URL? + let currentVersion: String + } + + var onEvent: ((AppUpdateDriverEvent) -> Void)? + private(set) var checkCalls: [CheckCall] = [] + private(set) var downloadCount = 0 + private(set) var installCount = 0 + + func checkForUpdates(feedURLOverride: URL?, currentVersion: String) { + checkCalls.append(.init(feedURLOverride: feedURLOverride, currentVersion: currentVersion)) + } + + func downloadUpdate() { + downloadCount += 1 + } + + func installUpdate() { + installCount += 1 + } + + func emit(_ event: AppUpdateDriverEvent) { + onEvent?(event) + } +} + +@MainActor +private final class FakeScheduler: UpdateSchedulerProtocol { + private final class Token: UpdateSchedulerCancellable { + var isCancelled = false + + func cancel() { + isCancelled = true + } + } + + private var oneShots: [(interval: TimeInterval, action: @Sendable @MainActor () -> Void, token: Token)] = [] + private var repeatings: [(interval: TimeInterval, action: @Sendable @MainActor () -> Void, token: Token)] = [] + + var oneShotIntervals: [TimeInterval] { + oneShots.map(\.interval) + } + + var repeatingIntervals: [TimeInterval] { + repeatings.map(\.interval) + } + + @discardableResult + func schedule( + after interval: TimeInterval, + _ action: @escaping @Sendable @MainActor () -> Void + ) -> UpdateSchedulerCancellable { + let token = Token() + oneShots.append((interval, action, token)) + return token + } + + @discardableResult + func scheduleRepeating( + every interval: TimeInterval, + _ action: @escaping @Sendable @MainActor () -> Void + ) -> UpdateSchedulerCancellable { + let token = Token() + repeatings.append((interval, action, token)) + return token + } + + func fireOneShot(at index: Int) { + guard oneShots.indices.contains(index) else { return } + let job = oneShots[index] + if !job.token.isCancelled { + job.action() + } + } + + func fireRepeating(at index: Int) { + guard repeatings.indices.contains(index) else { return } + let job = repeatings[index] + if !job.token.isCancelled { + job.action() + } + } +} diff --git a/idx0Tests/AppcastScriptTests.swift b/idx0Tests/AppcastScriptTests.swift new file mode 100644 index 0000000..83a3fc4 --- /dev/null +++ b/idx0Tests/AppcastScriptTests.swift @@ -0,0 +1,44 @@ +import Foundation +import XCTest +@testable import idx0 + +final class AppcastScriptTests: XCTestCase { + func testBuildXMLExcludesPrereleaseByDefault() { + let entries = [ + makeEntry(version: "1.2.0", prerelease: false, publishedAt: Date(timeIntervalSince1970: 100)), + makeEntry(version: "1.3.0-beta.1", prerelease: true, publishedAt: Date(timeIntervalSince1970: 200)) + ] + + let xml = AppcastFeedBuilder.buildXML(entries: entries) + + XCTAssertTrue(xml.contains("IDX0 1.2.0")) + XCTAssertFalse(xml.contains("IDX0 1.3.0-beta.1")) + XCTAssertFalse(xml.contains("sparkle:shortVersionString=\"1.3.0-beta.1\"")) + } + + func testBuildXMLIncludesPrereleaseWhenRequested() { + let entries = [ + makeEntry(version: "1.2.0", prerelease: false, publishedAt: Date(timeIntervalSince1970: 100)), + makeEntry(version: "1.3.0-beta.1", prerelease: true, publishedAt: Date(timeIntervalSince1970: 200)) + ] + + let xml = AppcastFeedBuilder.buildXML(entries: entries, includePrerelease: true) + + XCTAssertTrue(xml.contains("IDX0 1.2.0")) + XCTAssertTrue(xml.contains("IDX0 1.3.0-beta.1")) + XCTAssertTrue(xml.contains("sparkle:shortVersionString=\"1.3.0-beta.1\"")) + } + + private func makeEntry(version: String, prerelease: Bool, publishedAt: Date) -> AppcastReleaseEntry { + AppcastReleaseEntry( + version: version, + downloadURL: URL(string: "https://example.com/IDX0-\(version)-mac.zip")!, + length: 1024, + publishedAt: publishedAt, + prerelease: prerelease, + signature: nil, + minimumSystemVersion: "14.0", + notesURL: nil + ) + } +} diff --git a/idx0Tests/Keyboard/ShortcutRegistryTests.swift b/idx0Tests/Keyboard/ShortcutRegistryTests.swift index f8ed972..0b913f9 100644 --- a/idx0Tests/Keyboard/ShortcutRegistryTests.swift +++ b/idx0Tests/Keyboard/ShortcutRegistryTests.swift @@ -107,4 +107,12 @@ final class ShortcutRegistryTests: XCTestCase { XCTAssertFalse(conflicts.isEmpty) } + + func testCheckForUpdatesActionIsRegistered() { + let registry = ShortcutRegistry.shared + let descriptor = registry.descriptor(for: .checkForUpdates) + + XCTAssertEqual(descriptor?.title, "Check for Updates") + XCTAssertNil(registry.primaryBinding(for: .checkForUpdates, settings: AppSettings())) + } } diff --git a/project.yml b/project.yml index de4bf13..b6503de 100644 --- a/project.yml +++ b/project.yml @@ -7,6 +7,11 @@ settings: SWIFT_VERSION: 6.0 MACOSX_DEPLOYMENT_TARGET: 14.0 +packages: + Sparkle: + url: https://github.com/sparkle-project/Sparkle + from: 2.7.0 + targets: idx0: type: application @@ -27,6 +32,7 @@ targets: dependencies: - framework: GhosttyKit.xcframework embed: false + - package: Sparkle settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.gal.idx0 @@ -38,6 +44,8 @@ targets: INFOPLIST_KEY_CFBundleIconFile: idx0.icns INFOPLIST_KEY_CFBundleDisplayName: IDX0 INFOPLIST_KEY_LSApplicationCategoryType: public.app-category.developer-tools + INFOPLIST_KEY_SUFeedURL: https://raw.githubusercontent.com/galz10/idx0-appcast/main/appcast.xml + INFOPLIST_KEY_SUPublicEDKey: CHANGE_ME_SPARKLE_PUBLIC_KEY SWIFT_OBJC_BRIDGING_HEADER: idx0-Bridging-Header.h HEADER_SEARCH_PATHS: - $(SRCROOT) diff --git a/scripts/generate-appcast.sh b/scripts/generate-appcast.sh new file mode 100755 index 0000000..f259ce5 --- /dev/null +++ b/scripts/generate-appcast.sh @@ -0,0 +1,240 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +RELEASES_JSON="" +OUTPUT_PATH="" +TITLE="IDX0" +INCLUDE_PRERELEASE=0 + +usage() { + cat <<'USAGE' +Generate a Sparkle-compatible appcast XML feed from release metadata. + +Usage: + ./scripts/generate-appcast.sh --releases-json --output [options] + +Options: + --releases-json JSON array of release entries. + --output Output appcast.xml file path. + --title Feed title. Default: IDX0 + --include-prerelease Include prerelease entries (default excludes them) + -h, --help Show help text + +Entry schema (JSON array): + version (required) + downloadURL (or download_url) (required) + length (required) + pubDate (or published_at) (required, RFC3339) + prerelease (optional, default false) + signature (optional) + minimumSystemVersion (optional) + notesURL (or notes_url) (optional) +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --releases-json) + RELEASES_JSON="${2:-}" + shift 2 + ;; + --output) + OUTPUT_PATH="${2:-}" + shift 2 + ;; + --title) + TITLE="${2:-}" + shift 2 + ;; + --include-prerelease) + INCLUDE_PRERELEASE=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "error: unknown option: $1" >&2 + usage + exit 1 + ;; + esac +done + +if [[ -z "$RELEASES_JSON" || -z "$OUTPUT_PATH" ]]; then + echo "error: --releases-json and --output are required" >&2 + usage + exit 1 +fi + +if [[ ! -f "$RELEASES_JSON" ]]; then + echo "error: releases json not found: $RELEASES_JSON" >&2 + exit 1 +fi + +if ! command -v python3 >/dev/null 2>&1; then + echo "error: python3 is required" >&2 + exit 1 +fi + +mkdir -p "$(dirname "$OUTPUT_PATH")" + +python3 - "$RELEASES_JSON" "$OUTPUT_PATH" "$TITLE" "$INCLUDE_PRERELEASE" <<'PY' +from __future__ import annotations + +import datetime as dt +import email.utils +import json +import pathlib +import re +import sys +import xml.etree.ElementTree as ET + +releases_path = pathlib.Path(sys.argv[1]) +output_path = pathlib.Path(sys.argv[2]) +title = sys.argv[3] +include_prerelease = sys.argv[4] == "1" + +raw = json.loads(releases_path.read_text(encoding="utf-8")) +if not isinstance(raw, list): + raise SystemExit("error: releases json must be an array") + + +def field(entry: dict, *names: str): + for name in names: + value = entry.get(name) + if value is not None: + return value + return None + + +def parse_iso8601(value: str) -> dt.datetime: + if value.endswith("Z"): + value = value[:-1] + "+00:00" + parsed = dt.datetime.fromisoformat(value) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=dt.timezone.utc) + return parsed.astimezone(dt.timezone.utc) + + +def semver_key(version: str): + v = version.strip().lstrip("vV") + if not v: + return ([], 1, "") + main, _, suffix = v.partition("-") + nums = [] + for token in main.split("."): + token = token.strip() + if token.isdigit(): + nums.append(int(token)) + else: + nums.append(0) + is_prerelease = 1 if suffix else 0 + # Lower is_prerelease should sort first for stable releases. + return (nums, -is_prerelease, suffix) + + +entries = [] +for item in raw: + if not isinstance(item, dict): + continue + + version = str(field(item, "version") or "").strip() + download_url = str(field(item, "downloadURL", "download_url") or "").strip() + length = field(item, "length") + pub_date_raw = field(item, "pubDate", "published_at") + prerelease = bool(field(item, "prerelease") or False) + + if not version or not download_url or length is None or not pub_date_raw: + continue + + if not include_prerelease and prerelease: + continue + + try: + length_int = int(length) + except Exception: + continue + + if length_int <= 0: + continue + + try: + pub_date = parse_iso8601(str(pub_date_raw)) + except Exception: + continue + + notes_url = field(item, "notesURL", "notes_url") + signature = field(item, "signature") + minimum_system_version = field(item, "minimumSystemVersion", "minimum_system_version") + build_version = field(item, "buildVersion", "build_version") + + if not build_version: + digits = re.findall(r"\d+", version) + build_version = "".join(digits[:4]) or version + + entries.append( + { + "version": version, + "download_url": download_url, + "length": str(length_int), + "pub_date": pub_date, + "pub_date_http": email.utils.format_datetime(pub_date), + "notes_url": str(notes_url) if notes_url else None, + "signature": str(signature) if signature else None, + "minimum_system_version": str(minimum_system_version) if minimum_system_version else None, + "build_version": str(build_version), + "prerelease": prerelease, + } + ) + +if not entries: + raise SystemExit("error: no qualifying releases found for appcast generation") + +entries.sort(key=lambda e: (semver_key(e["version"]), e["pub_date"]), reverse=True) + +rss = ET.Element( + "rss", + { + "version": "2.0", + "xmlns:sparkle": "http://www.andymatuschak.org/xml-namespaces/sparkle", + "xmlns:dc": "http://purl.org/dc/elements/1.1/", + }, +) +channel = ET.SubElement(rss, "channel") +ET.SubElement(channel, "title").text = f"{title} Updates" +ET.SubElement(channel, "description").text = f"Latest releases for {title}" + +for entry in entries: + item = ET.SubElement(channel, "item") + ET.SubElement(item, "title").text = f"{title} {entry['version']}" + ET.SubElement(item, "pubDate").text = entry["pub_date_http"] + + enclosure_attrs = { + "url": entry["download_url"], + "length": entry["length"], + "type": "application/octet-stream", + "sparkle:version": entry["build_version"], + "sparkle:shortVersionString": entry["version"], + } + + if entry["signature"]: + enclosure_attrs["sparkle:edSignature"] = entry["signature"] + + if entry["minimum_system_version"]: + enclosure_attrs["sparkle:minimumSystemVersion"] = entry["minimum_system_version"] + + ET.SubElement(item, "enclosure", enclosure_attrs) + + if entry["notes_url"]: + ET.SubElement(item, "sparkle:releaseNotesLink").text = entry["notes_url"] + +ET.indent(rss, space=" ") +xml_text = ET.tostring(rss, encoding="utf-8", xml_declaration=True) +output_path.write_bytes(xml_text) +PY + +echo "==> Wrote appcast: $OUTPUT_PATH" diff --git a/scripts/manual-release.sh b/scripts/manual-release.sh index 0fa4600..e4dd85d 100755 --- a/scripts/manual-release.sh +++ b/scripts/manual-release.sh @@ -17,6 +17,7 @@ RUN_TESTS=1 RUN_MAINTAINABILITY=1 NOTARIZE=0 NOTARY_PROFILE="${NOTARY_PROFILE:-}" +APPCAST_DOWNLOAD_BASE_URL="${APPCAST_DOWNLOAD_BASE_URL:-}" OUTPUT_DIR="$ROOT_DIR/dist" DERIVED_DATA_PATH="$ROOT_DIR/.build/derived-release" @@ -39,6 +40,9 @@ Options: --notarize Submit zip + dmg to Apple notarization and staple dmg --notary-profile Keychain profile for notarytool (required with --notarize if NOTARY_PROFILE is unset) -h, --help Show this help text + +Environment: + APPCAST_DOWNLOAD_BASE_URL Optional public base URL used to populate generated appcast entry metadata. EOF } @@ -191,6 +195,7 @@ ZIP_PATH="$OUTPUT_DIR/IDX0-${VERSION}-mac.zip" TAR_PATH="$OUTPUT_DIR/IDX0-${VERSION}-mac.tar.gz" DMG_PATH="$OUTPUT_DIR/IDX0-${VERSION}.dmg" CHECKSUM_PATH="$OUTPUT_DIR/SHA256SUMS.txt" +APPCAST_ENTRY_PATH="$OUTPUT_DIR/IDX0-${VERSION}-appcast-entry.json" echo "==> Packaging zip" rm -f "$ZIP_PATH" @@ -229,6 +234,28 @@ echo "==> Writing SHA256 checksums" shasum -a 256 "$(basename "$DMG_PATH")" "$(basename "$ZIP_PATH")" "$(basename "$TAR_PATH")" > "$(basename "$CHECKSUM_PATH")" ) +echo "==> Writing appcast entry metadata" +ZIP_SIZE="$(stat -f%z "$ZIP_PATH")" +APPCAST_PUB_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +if [[ -n "$APPCAST_DOWNLOAD_BASE_URL" ]]; then + APPCAST_DOWNLOAD_URL="${APPCAST_DOWNLOAD_BASE_URL%/}/$(basename "$ZIP_PATH")" +else + APPCAST_DOWNLOAD_URL="https://example.invalid/$(basename "$ZIP_PATH")" +fi + +cat > "$APPCAST_ENTRY_PATH" < Release artifacts ready" -ls -lh "$DMG_PATH" "$ZIP_PATH" "$TAR_PATH" "$CHECKSUM_PATH" +ls -lh "$DMG_PATH" "$ZIP_PATH" "$TAR_PATH" "$CHECKSUM_PATH" "$APPCAST_ENTRY_PATH" diff --git a/scripts/publish-appcast.sh b/scripts/publish-appcast.sh new file mode 100755 index 0000000..5cf141b --- /dev/null +++ b/scripts/publish-appcast.sh @@ -0,0 +1,207 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" + +VERSION="" +ZIP_PATH="" +APPCAST_REPO="" +DOWNLOAD_BASE_URL="" +BRANCH="main" +TITLE="IDX0" +MIN_SYSTEM_VERSION="14.0" +SIGNATURE="" +PRERELEASE=0 +NO_PUSH=0 + +usage() { + cat <<'USAGE' +Publish IDX0 appcast content to a dedicated appcast repository. + +Usage: + ./scripts/publish-appcast.sh --version --zip --appcast-repo --download-base-url [options] + +Options: + --version Version string (for example: 0.2.0) + --zip Notarized zip artifact path + --appcast-repo Destination git repository URL for appcast hosting + --download-base-url Public base URL used in appcast enclosure URLs + --branch Destination branch. Default: main + --title App title in appcast items. Default: IDX0 + --minimum-system-version Sparkle minimum system version. Default: 14.0 + --signature Optional Sparkle EdDSA signature for the zip + --prerelease Mark this entry as prerelease (excluded from stable appcast by default) + --no-push Commit locally in clone but skip push + -h, --help Show help text +USAGE +} + +require_cmd() { + local cmd="$1" + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "error: required command not found: $cmd" >&2 + exit 1 + fi +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --version) + VERSION="${2:-}" + shift 2 + ;; + --zip) + ZIP_PATH="${2:-}" + shift 2 + ;; + --appcast-repo) + APPCAST_REPO="${2:-}" + shift 2 + ;; + --download-base-url) + DOWNLOAD_BASE_URL="${2:-}" + shift 2 + ;; + --branch) + BRANCH="${2:-}" + shift 2 + ;; + --title) + TITLE="${2:-}" + shift 2 + ;; + --minimum-system-version) + MIN_SYSTEM_VERSION="${2:-}" + shift 2 + ;; + --signature) + SIGNATURE="${2:-}" + shift 2 + ;; + --prerelease) + PRERELEASE=1 + shift + ;; + --no-push) + NO_PUSH=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "error: unknown option: $1" >&2 + usage + exit 1 + ;; + esac +done + +if [[ -z "$VERSION" || -z "$ZIP_PATH" || -z "$APPCAST_REPO" || -z "$DOWNLOAD_BASE_URL" ]]; then + echo "error: --version, --zip, --appcast-repo, and --download-base-url are required" >&2 + usage + exit 1 +fi + +if [[ ! -f "$ZIP_PATH" ]]; then + echo "error: zip artifact not found: $ZIP_PATH" >&2 + exit 1 +fi + +require_cmd git +require_cmd python3 + +TMP_DIR="$(mktemp -d)" +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +echo "==> Cloning appcast repo" +git clone --branch "$BRANCH" --single-branch "$APPCAST_REPO" "$TMP_DIR/repo" + +REPO_DIR="$TMP_DIR/repo" +ARCHIVE_DIR="$REPO_DIR/archives" +mkdir -p "$ARCHIVE_DIR" + +ZIP_FILENAME="IDX0-${VERSION}-mac.zip" +TARGET_ZIP="$ARCHIVE_DIR/$ZIP_FILENAME" +cp "$ZIP_PATH" "$TARGET_ZIP" + +ZIP_SIZE="$(stat -f%z "$TARGET_ZIP")" +PUB_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +DOWNLOAD_URL="${DOWNLOAD_BASE_URL%/}/archives/$ZIP_FILENAME" +MANIFEST_PATH="$REPO_DIR/releases.json" + +if [[ ! -f "$MANIFEST_PATH" ]]; then + echo "[]" > "$MANIFEST_PATH" +fi + +python3 - "$MANIFEST_PATH" "$VERSION" "$DOWNLOAD_URL" "$ZIP_SIZE" "$PUB_DATE" "$PRERELEASE" "$MIN_SYSTEM_VERSION" "$SIGNATURE" <<'PY' +from __future__ import annotations + +import json +import pathlib +import sys + +manifest_path = pathlib.Path(sys.argv[1]) +version = sys.argv[2] +download_url = sys.argv[3] +length = int(sys.argv[4]) +pub_date = sys.argv[5] +prerelease = sys.argv[6] == "1" +minimum_system_version = sys.argv[7] +signature = sys.argv[8] + +entries = json.loads(manifest_path.read_text(encoding="utf-8")) +if not isinstance(entries, list): + entries = [] + +entry = { + "version": version, + "downloadURL": download_url, + "length": length, + "pubDate": pub_date, + "prerelease": prerelease, + "minimumSystemVersion": minimum_system_version, +} +if signature: + entry["signature"] = signature + +updated = False +for idx, existing in enumerate(entries): + if isinstance(existing, dict) and str(existing.get("version", "")).strip() == version: + entries[idx] = entry + updated = True + break + +if not updated: + entries.append(entry) + +manifest_path.write_text(json.dumps(entries, indent=2) + "\n", encoding="utf-8") +PY + +"$SCRIPT_DIR/generate-appcast.sh" \ + --releases-json "$MANIFEST_PATH" \ + --output "$REPO_DIR/appcast.xml" \ + --title "$TITLE" + +pushd "$REPO_DIR" >/dev/null +git add "appcast.xml" "releases.json" "archives/$ZIP_FILENAME" + +if git diff --cached --quiet; then + echo "==> No appcast changes to publish" + exit 0 +fi + +git commit -m "chore(release): publish appcast for v$VERSION" + +if [[ "$NO_PUSH" -eq 0 ]]; then + git push origin "$BRANCH" + echo "==> Appcast published to $APPCAST_REPO ($BRANCH)" +else + echo "==> Appcast committed locally (push skipped): $REPO_DIR" +fi +popd >/dev/null From 75634a4b0659b4b0f315b8381318908d2fa135ff Mon Sep 17 00:00:00 2001 From: galz10 Date: Sat, 28 Mar 2026 11:03:06 -0700 Subject: [PATCH 2/4] feat(release): publish appcast from github release script --- docs/release-runbook.md | 5 +- scripts/publish-github-release.sh | 124 ++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 1 deletion(-) diff --git a/docs/release-runbook.md b/docs/release-runbook.md index ab9e0a6..be4de6a 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -63,7 +63,7 @@ Artifacts are written to `dist/`: - `IDX0-X.Y.Z-mac.tar.gz` - `SHA256SUMS.txt` -## 4. Publish GitHub Release + Update Download Links +## 4. Publish GitHub Release + Appcast + Download Links Draft release (default): @@ -80,6 +80,9 @@ Published release: Notes: - `--version` is required. +- Script publishes appcast by default via `scripts/publish-appcast.sh`. +- Appcast defaults are inferred from `project.yml` `INFOPLIST_KEY_SUFeedURL` when it matches `raw.githubusercontent.com////appcast.xml`. +- If inference does not work for your feed URL, pass `--appcast-repo` and `--appcast-download-base-url`, or use `--skip-appcast`. - Download URL now follows the selected `--repo` (or current repo). - Default idx-web path is `/Users/gal/Documents/Github/idx-web/index.html`. - Override idx-web path with `--idx-web-index `. diff --git a/scripts/publish-github-release.sh b/scripts/publish-github-release.sh index 2ce0a2b..ee0cd6c 100755 --- a/scripts/publish-github-release.sh +++ b/scripts/publish-github-release.sh @@ -9,6 +9,7 @@ DEFAULT_VERSION="$(awk '/MARKETING_VERSION:/ {print $2; exit}' project.yml 2>/de if [[ -z "${DEFAULT_VERSION:-}" ]]; then DEFAULT_VERSION="0.0.1" fi +DEFAULT_APPCAST_FEED_URL="$(awk '/INFOPLIST_KEY_SUFeedURL:/ {print $2; exit}' project.yml 2>/dev/null || true)" VERSION="" VERSION_WAS_SET=0 @@ -26,6 +27,14 @@ IDX_WEB_INDEX_DEFAULT_PATH="/Users/gal/Documents/Github/idx-web/index.html" IDX_WEB_INDEX_PATH="${IDX_WEB_INDEX_PATH:-$IDX_WEB_INDEX_DEFAULT_PATH}" REQUIRE_IDX_WEB_UPDATE=0 README_PATH="$ROOT_DIR/README.md" +RUN_APPCAST=1 +APPCAST_REPO="${APPCAST_REPO:-}" +APPCAST_DOWNLOAD_BASE_URL="${APPCAST_DOWNLOAD_BASE_URL:-}" +APPCAST_BRANCH="${APPCAST_BRANCH:-}" +APPCAST_TITLE="${APPCAST_TITLE:-IDX0}" +APPCAST_MIN_SYSTEM_VERSION="${APPCAST_MIN_SYSTEM_VERSION:-14.0}" +APPCAST_SIGNATURE="${APPCAST_SIGNATURE:-}" +APPCAST_NO_PUSH=0 COMMITTED_REPOS=() PUSHED_REPOS=() @@ -52,6 +61,14 @@ Options: --no-push-tag Do not push tag to origin --idx-web-index Override idx-web download target (default: /Users/gal/Documents/Github/idx-web/index.html) --require-idx-web-update Fail if idx-web update cannot run + --skip-appcast Skip appcast publish automation + --appcast-repo Override appcast repository URL + --appcast-download-base-url Override appcast public download base URL + --appcast-branch Override appcast branch (default: inferred or main) + --appcast-title Appcast title. Default: IDX0 + --appcast-minimum-system-version Sparkle minimum system version. Default: 14.0 + --appcast-signature Optional Sparkle EdDSA signature for appcast enclosure + --appcast-no-push Publish appcast commit locally but skip push --open Open the release page in browser after create/update -h, --help Show this help text @@ -91,6 +108,32 @@ resolve_artifact_path() { return 1 } +infer_appcast_defaults_from_feed_url() { + local feed_url="$1" + if [[ -z "$feed_url" ]]; then + return 1 + fi + + if [[ "$feed_url" =~ ^https://raw\.githubusercontent\.com/([^/]+)/([^/]+)/([^/]+)/appcast\.xml$ ]]; then + local owner="${BASH_REMATCH[1]}" + local repo_name="${BASH_REMATCH[2]}" + local inferred_branch="${BASH_REMATCH[3]}" + + if [[ -z "$APPCAST_REPO" ]]; then + APPCAST_REPO="https://github.com/${owner}/${repo_name}.git" + fi + if [[ -z "$APPCAST_BRANCH" ]]; then + APPCAST_BRANCH="$inferred_branch" + fi + if [[ -z "$APPCAST_DOWNLOAD_BASE_URL" ]]; then + APPCAST_DOWNLOAD_BASE_URL="https://raw.githubusercontent.com/${owner}/${repo_name}/${APPCAST_BRANCH}" + fi + return 0 + fi + + return 1 +} + while [[ $# -gt 0 ]]; do case "$1" in --version) @@ -142,6 +185,38 @@ while [[ $# -gt 0 ]]; do REQUIRE_IDX_WEB_UPDATE=1 shift ;; + --skip-appcast) + RUN_APPCAST=0 + shift + ;; + --appcast-repo) + APPCAST_REPO="${2:-}" + shift 2 + ;; + --appcast-download-base-url) + APPCAST_DOWNLOAD_BASE_URL="${2:-}" + shift 2 + ;; + --appcast-branch) + APPCAST_BRANCH="${2:-}" + shift 2 + ;; + --appcast-title) + APPCAST_TITLE="${2:-}" + shift 2 + ;; + --appcast-minimum-system-version) + APPCAST_MIN_SYSTEM_VERSION="${2:-}" + shift 2 + ;; + --appcast-signature) + APPCAST_SIGNATURE="${2:-}" + shift 2 + ;; + --appcast-no-push) + APPCAST_NO_PUSH=1 + shift + ;; --open) OPEN_WEB=1 shift @@ -385,6 +460,22 @@ if [[ -z "$REPO" ]]; then REPO="$(gh repo view --json nameWithOwner -q .nameWithOwner)" fi +if [[ "$RUN_APPCAST" -eq 1 ]]; then + infer_appcast_defaults_from_feed_url "$DEFAULT_APPCAST_FEED_URL" || true + + if [[ -z "$APPCAST_BRANCH" ]]; then + APPCAST_BRANCH="main" + fi + + if [[ -z "$APPCAST_REPO" || -z "$APPCAST_DOWNLOAD_BASE_URL" ]]; then + echo "error: appcast publish is enabled but appcast config is incomplete." >&2 + echo "hint: pass --appcast-repo and --appcast-download-base-url." >&2 + echo "hint: or set project.yml INFOPLIST_KEY_SUFeedURL to raw.githubusercontent.com////appcast.xml so defaults can be inferred." >&2 + echo "hint: use --skip-appcast to skip appcast publishing." >&2 + exit 1 + fi +fi + DMG_PATH="$(resolve_artifact_path "$DIST_DIR/IDX0-${VERSION}.dmg" "$DIST_DIR/idx0-${VERSION}.dmg" || true)" ZIP_PATH="$(resolve_artifact_path "$DIST_DIR/IDX0-${VERSION}-mac.zip" "$DIST_DIR/idx0-${VERSION}-mac.zip" || true)" TAR_PATH="$(resolve_artifact_path "$DIST_DIR/IDX0-${VERSION}-mac.tar.gz" "$DIST_DIR/idx0-${VERSION}-mac.tar.gz" || true)" @@ -463,6 +554,33 @@ else gh release create "${CREATE_ARGS[@]}" fi +if [[ "$RUN_APPCAST" -eq 1 ]]; then + echo "==> Publishing appcast" + APPCAST_ARGS=( + --version "$VERSION" + --zip "$ZIP_PATH" + --appcast-repo "$APPCAST_REPO" + --download-base-url "$APPCAST_DOWNLOAD_BASE_URL" + --branch "$APPCAST_BRANCH" + --title "$APPCAST_TITLE" + --minimum-system-version "$APPCAST_MIN_SYSTEM_VERSION" + ) + + if [[ "$PRERELEASE" -eq 1 ]]; then + APPCAST_ARGS+=(--prerelease) + fi + + if [[ -n "$APPCAST_SIGNATURE" ]]; then + APPCAST_ARGS+=(--signature "$APPCAST_SIGNATURE") + fi + + if [[ "$APPCAST_NO_PUSH" -eq 1 ]]; then + APPCAST_ARGS+=(--no-push) + fi + + "$SCRIPT_DIR/publish-appcast.sh" "${APPCAST_ARGS[@]}" +fi + README_BEFORE_HASH="$(hash_file "$README_PATH")" patch_readme_download_link "$README_PATH" "$DOWNLOAD_URL" README_AFTER_HASH="$(hash_file "$README_PATH")" @@ -498,6 +616,12 @@ echo "==> Release ready" echo "Repo: $REPO" echo "Tag: $TAG" echo "Download URL: $DOWNLOAD_URL" +if [[ "$RUN_APPCAST" -eq 1 ]]; then + echo "Appcast repo: $APPCAST_REPO" + echo "Appcast base URL: $APPCAST_DOWNLOAD_BASE_URL" +else + echo "Appcast: skipped (--skip-appcast)" +fi echo "Assets:" printf ' - %s\n' "$DMG_PATH" "$ZIP_PATH" "$TAR_PATH" "$CHECKSUM_PATH" From 985c87e911c3f4880c0f19ef143670211abcaa46 Mon Sep 17 00:00:00 2001 From: galz10 Date: Sat, 28 Mar 2026 11:05:50 -0700 Subject: [PATCH 3/4] fix(docs): normalize release runbook ordered list numbering --- docs/release-runbook.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/release-runbook.md b/docs/release-runbook.md index be4de6a..02c30e5 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -10,13 +10,13 @@ Use this guide when publishing a new IDX0 desktop release. xcrun notarytool store-credentials "" --apple-id "" --team-id "" --password "" ``` -2. Confirm your DMG signing identity exists: +1. Confirm your DMG signing identity exists: ```bash security find-identity -v -p codesigning ``` -3. Confirm GitHub CLI auth: +1. Confirm GitHub CLI auth: ```bash gh auth status @@ -97,9 +97,9 @@ Notes: gh release view vX.Y.Z --repo galz10/IDX0 ``` -2. Open release page and verify download links. -3. Confirm `README.md` download link points to `vX.Y.Z`. -4. If idx-web update was enabled, confirm CTA URL and `data-release-version`. +1. Open release page and verify download links. +2. Confirm `README.md` download link points to `vX.Y.Z`. +3. If idx-web update was enabled, confirm CTA URL and `data-release-version`. ## 6. Recommended Command Template From ee7fbb83ca357cd3fe88ad1639ad97a7ef4da0e6 Mon Sep 17 00:00:00 2001 From: galz10 Date: Sat, 28 Mar 2026 11:07:19 -0700 Subject: [PATCH 4/4] style: format update-related files for presubmit --- ...oordinator+ShortcutCommandDispatcher.swift | 869 +++++------ idx0/App/AppCoordinator.swift | 649 ++++---- idx0/App/AppSettings.swift | 828 +++++----- idx0/Keyboard/ShortcutActionID.swift | 172 +-- idx0/Keyboard/ShortcutRegistry.swift | 1370 +++++++++-------- idx0/Services/Updates/AppUpdateModels.swift | 152 +- idx0/Services/Updates/AppUpdateReducer.swift | 126 +- idx0/Services/Updates/AppUpdateService.swift | 306 ++-- idx0/Services/Updates/AppUpdateSupport.swift | 211 +-- .../Services/Updates/AppcastFeedBuilder.swift | 248 +-- .../Updates/SparkleUpdateDriver.swift | 331 ++-- idx0/UI/CommandPaletteOverlay.swift | 963 ++++++------ idx0/UI/MainWindow/TabBarOverlay.swift | 395 ++--- .../Inline/InlineAdvancedSettings.swift | 380 ++--- .../Settings/Tabs/AdvancedSettingsTab.swift | 146 +- idx0Tests/AppCommandRegistryTests.swift | 48 +- idx0Tests/AppSettingsKeyboardTests.swift | 96 +- idx0Tests/AppUpdateActionMapperTests.swift | 36 +- idx0Tests/AppUpdateReducerTests.swift | 184 +-- idx0Tests/AppUpdateServiceTests.swift | 458 +++--- idx0Tests/AppcastScriptTests.swift | 66 +- .../Keyboard/ShortcutRegistryTests.swift | 228 +-- 22 files changed, 4136 insertions(+), 4126 deletions(-) diff --git a/idx0/App/AppCoordinator+ShortcutCommandDispatcher.swift b/idx0/App/AppCoordinator+ShortcutCommandDispatcher.swift index 550d688..e828cc7 100644 --- a/idx0/App/AppCoordinator+ShortcutCommandDispatcher.swift +++ b/idx0/App/AppCoordinator+ShortcutCommandDispatcher.swift @@ -2,474 +2,477 @@ import AppKit import Foundation extension AppCoordinator { - func performCommand(_ action: ShortcutActionID) -> Bool { - shortcutCommandDispatcher.perform(action, coordinator: self) - } - - /// Actions that the onboarding overlay intercepts so the real canvas is not affected. - private static let onboardingInterceptedActions: Set = [ - .niriAddTerminalRight, .niriAddTaskBelow, .niriAddBrowserTile, .niriOpenAddTileMenu, - .niriFocusLeft, .niriFocusDown, .niriFocusUp, .niriFocusRight, - .niriToggleOverview, .niriConfirmSelection, .niriToggleColumnTabbedDisplay, - .niriToggleSnap, .niriFocusWorkspaceUp, .niriFocusWorkspaceDown, - .niriMoveColumnToWorkspaceUp, .niriMoveColumnToWorkspaceDown, - .niriToggleFocusedTileZoom, - .splitRight, .splitDown, .closePane, .nextPane, .previousPane, - ] + func performCommand(_ action: ShortcutActionID) -> Bool { + shortcutCommandDispatcher.perform(action, coordinator: self) + } - func performShortcutAction(_ action: ShortcutActionID) -> Bool { - // While the onboarding walkthrough is active, intercept canvas actions - // so only the dummy practice canvas responds, not the real one. - if showingNiriOnboarding, Self.onboardingInterceptedActions.contains(action) { - postOnboardingAction(action) - return true - } + /// Actions that the onboarding overlay intercepts so the real canvas is not affected. + private static let onboardingInterceptedActions: Set = [ + .niriAddTerminalRight, .niriAddTaskBelow, .niriAddBrowserTile, .niriOpenAddTileMenu, + .niriFocusLeft, .niriFocusDown, .niriFocusUp, .niriFocusRight, + .niriToggleOverview, .niriConfirmSelection, .niriToggleColumnTabbedDisplay, + .niriToggleSnap, .niriFocusWorkspaceUp, .niriFocusWorkspaceDown, + .niriMoveColumnToWorkspaceUp, .niriMoveColumnToWorkspaceDown, + .niriToggleFocusedTileZoom, + .splitRight, .splitDown, .closePane, .nextPane, .previousPane, + ] - let selectedSessionID = sessionService.selectedSessionID - let niriEnabled = sessionService.settings.niriCanvasEnabled + func performShortcutAction(_ action: ShortcutActionID) -> Bool { + // While the onboarding walkthrough is active, intercept canvas actions + // so only the dummy practice canvas responds, not the real one. + if showingNiriOnboarding, Self.onboardingInterceptedActions.contains(action) { + postOnboardingAction(action) + return true + } - if let niriResult = handleNiriShortcutAction( - action, - selectedSessionID: selectedSessionID, - niriEnabled: niriEnabled - ) { - return niriResult - } - if let paneResult = handlePaneAndTabShortcutAction( - action, - selectedSessionID: selectedSessionID, - niriEnabled: niriEnabled - ) { - return paneResult - } + let selectedSessionID = sessionService.selectedSessionID + let niriEnabled = sessionService.settings.niriCanvasEnabled - switch action { - case .newSession: - triggerPrimaryNewSessionAction() - return true - case .newQuickSession: - sessionService.createQuickSession() - return true - case .newRepoWorktreeSession: - presentNewSessionSheet(preset: .repo) - return true - case .newWorktreeSession: - presentNewSessionSheet(preset: .worktree) - return true - case .quickSwitchSession: - showingQuickSwitch = true - return true - case .focusNextSession: - sessionService.focusNextSession() - return true - case .focusPreviousSession: - sessionService.focusPreviousSession() - return true - case .renameSession: - guard let selectedSessionID, - let session = sessionService.sessions.first(where: { $0.id == selectedSessionID }) else { - return false - } - presentRenameSessionSheet(session: session) - return true - case .closeSession: - guard let selectedSessionID else { return false } - sessionService.closeSession(selectedSessionID) - return true - case .relaunchSession: - guard let selectedSessionID else { return false } - sessionService.relaunchSession(selectedSessionID) - return true - case .commandPalette: - // In Niri mode, Cmd+K opens the unified tile spotlight instead - if sessionService.settings.niriCanvasEnabled, - let sessionID = sessionService.selectedSessionID { - niriQuickAddRequestSessionID = sessionID - return true - } - presentCommandPalette() - return true - case .keyboardShortcuts: - showingKeyboardShortcuts = true - return true - case .openSettings: - showingCommandPalette = false - showingQuickSwitch = false - showingSettings = true - return true - case .checkForUpdates: - appUpdateService.checkNow() - return true - case .toggleSidebar: - sessionService.saveSettings { $0.sidebarVisible.toggle() } - return true - case .toggleWorkflowRail: - sessionService.saveSettings { $0.inboxVisible.toggle() } - return true - case .toggleFocusMode: - workflowService.toggleFocusMode() - return true - case .focusNextQueueItem: - guard let item = workflowService.unresolvedQueueItems.first else { return false } - sessionService.focusSession(item.sessionID) - return true - case .showDiff: - showingDiffOverlay.toggle() - return true - case .showCheckpoints: - showingCheckpoints.toggle() - return true - case .quickApprove: - return quickApproveSelectedSession() - case .openClipboardURL, .newTab, .nextTab, .previousTab, .closeTab, - .splitRight, .splitDown, .closePane, .nextPane, .previousPane, .toggleBrowserSplit: - return false - case .niriAddTerminalRight, .niriAddTaskBelow, .niriAddBrowserTile, .niriOpenAddTileMenu, - .niriFocusLeft, .niriFocusDown, .niriFocusUp, .niriFocusRight, - .niriToggleOverview, .niriConfirmSelection, .niriToggleColumnTabbedDisplay, - .niriToggleSnap, .niriFocusWorkspaceUp, .niriFocusWorkspaceDown, - .niriMoveColumnToWorkspaceUp, .niriMoveColumnToWorkspaceDown, - .niriToggleFocusedTileZoom, - .niriZoomInFocusedWebTile, .niriZoomOutFocusedWebTile: - return false - } + if let niriResult = handleNiriShortcutAction( + action, + selectedSessionID: selectedSessionID, + niriEnabled: niriEnabled + ) { + return niriResult } - - func handlePaneAndTabShortcutAction( - _ action: ShortcutActionID, - selectedSessionID: UUID?, - niriEnabled: Bool - ) -> Bool? { - switch action { - case .openClipboardURL: - return sessionService.openClipboardURLInSplit(for: selectedSessionID) - case .newTab: - guard let selectedSessionID else { return false } - _ = sessionService.createTab(in: selectedSessionID) - return true - case .nextTab: - guard let selectedSessionID else { return false } - sessionService.focusNextTab(in: selectedSessionID) - return true - case .previousTab: - guard let selectedSessionID else { return false } - sessionService.focusPreviousTab(in: selectedSessionID) - return true - case .closeTab: - guard let selectedSessionID else { return false } - sessionService.closeActiveTab(in: selectedSessionID) - return true - case .splitRight: - guard let selectedSessionID else { return false } - if niriEnabled { - _ = sessionService.niriAddTerminalRight(in: selectedSessionID) - } else { - sessionService.splitPane(sessionID: selectedSessionID, direction: .vertical) - } - postOnboardingAction(action) - return true - case .splitDown: - guard let selectedSessionID else { return false } - if niriEnabled { - _ = sessionService.niriAddTaskBelow(in: selectedSessionID) - } else { - sessionService.splitPane(sessionID: selectedSessionID, direction: .horizontal) - } - postOnboardingAction(action) - return true - case .closePane: - guard let selectedSessionID else { return false } - if niriEnabled { - sessionService.closeNiriFocusedItem(in: selectedSessionID) - } else { - sessionService.closePane(sessionID: selectedSessionID) - } - postOnboardingAction(action) - return true - case .nextPane: - guard let selectedSessionID else { return false } - sessionService.focusNextPane(sessionID: selectedSessionID) - return true - case .previousPane: - guard let selectedSessionID else { return false } - sessionService.focusPreviousPane(sessionID: selectedSessionID) - return true - case .toggleBrowserSplit: - guard let selectedSessionID else { return false } - sessionService.toggleBrowserSplit(for: selectedSessionID) - return true - default: - return nil - } + if let paneResult = handlePaneAndTabShortcutAction( + action, + selectedSessionID: selectedSessionID, + niriEnabled: niriEnabled + ) { + return paneResult } - private func postOnboardingAction(_ action: ShortcutActionID) { - NotificationCenter.default.post( - name: .niriOnboardingActionPerformed, - object: nil, - userInfo: ["action": action.rawValue] - ) + switch action { + case .newSession: + triggerPrimaryNewSessionAction() + return true + case .newQuickSession: + sessionService.createQuickSession() + return true + case .newRepoWorktreeSession: + presentNewSessionSheet(preset: .repo) + return true + case .newWorktreeSession: + presentNewSessionSheet(preset: .worktree) + return true + case .quickSwitchSession: + showingQuickSwitch = true + return true + case .focusNextSession: + sessionService.focusNextSession() + return true + case .focusPreviousSession: + sessionService.focusPreviousSession() + return true + case .renameSession: + guard let selectedSessionID, + let session = sessionService.sessions.first(where: { $0.id == selectedSessionID }) + else { + return false + } + presentRenameSessionSheet(session: session) + return true + case .closeSession: + guard let selectedSessionID else { return false } + sessionService.closeSession(selectedSessionID) + return true + case .relaunchSession: + guard let selectedSessionID else { return false } + sessionService.relaunchSession(selectedSessionID) + return true + case .commandPalette: + // In Niri mode, Cmd+K opens the unified tile spotlight instead + if sessionService.settings.niriCanvasEnabled, + let sessionID = sessionService.selectedSessionID + { + niriQuickAddRequestSessionID = sessionID + return true + } + presentCommandPalette() + return true + case .keyboardShortcuts: + showingKeyboardShortcuts = true + return true + case .openSettings: + showingCommandPalette = false + showingQuickSwitch = false + showingSettings = true + return true + case .checkForUpdates: + appUpdateService.checkNow() + return true + case .toggleSidebar: + sessionService.saveSettings { $0.sidebarVisible.toggle() } + return true + case .toggleWorkflowRail: + sessionService.saveSettings { $0.inboxVisible.toggle() } + return true + case .toggleFocusMode: + workflowService.toggleFocusMode() + return true + case .focusNextQueueItem: + guard let item = workflowService.unresolvedQueueItems.first else { return false } + sessionService.focusSession(item.sessionID) + return true + case .showDiff: + showingDiffOverlay.toggle() + return true + case .showCheckpoints: + showingCheckpoints.toggle() + return true + case .quickApprove: + return quickApproveSelectedSession() + case .openClipboardURL, .newTab, .nextTab, .previousTab, .closeTab, + .splitRight, .splitDown, .closePane, .nextPane, .previousPane, .toggleBrowserSplit: + return false + case .niriAddTerminalRight, .niriAddTaskBelow, .niriAddBrowserTile, .niriOpenAddTileMenu, + .niriFocusLeft, .niriFocusDown, .niriFocusUp, .niriFocusRight, + .niriToggleOverview, .niriConfirmSelection, .niriToggleColumnTabbedDisplay, + .niriToggleSnap, .niriFocusWorkspaceUp, .niriFocusWorkspaceDown, + .niriMoveColumnToWorkspaceUp, .niriMoveColumnToWorkspaceDown, + .niriToggleFocusedTileZoom, + .niriZoomInFocusedWebTile, .niriZoomOutFocusedWebTile: + return false } + } - func handleNiriShortcutAction( - _ action: ShortcutActionID, - selectedSessionID: UUID?, - niriEnabled: Bool - ) -> Bool? { - switch action { - case .niriAddTerminalRight: - guard niriEnabled, let selectedSessionID else { return false } - _ = sessionService.niriAddTerminalRight(in: selectedSessionID) - postOnboardingAction(action) - return true - case .niriAddTaskBelow: - guard niriEnabled, let selectedSessionID else { return false } - _ = sessionService.niriAddTaskBelow(in: selectedSessionID) - postOnboardingAction(action) - return true - case .niriAddBrowserTile: - guard niriEnabled, let selectedSessionID else { return false } - _ = sessionService.niriAddBrowserRight(in: selectedSessionID) - return true - case .niriOpenAddTileMenu: - return requestNiriAddTileMenu(for: selectedSessionID) - case .niriFocusLeft: - guard niriEnabled, let selectedSessionID else { return false } - sessionService.niriFocusNeighbor(sessionID: selectedSessionID, horizontal: -1) - postOnboardingAction(action) - return true - case .niriFocusDown: - guard niriEnabled, let selectedSessionID else { return false } - sessionService.niriFocusNeighbor(sessionID: selectedSessionID, vertical: 1) - postOnboardingAction(action) - return true - case .niriFocusUp: - guard niriEnabled, let selectedSessionID else { return false } - sessionService.niriFocusNeighbor(sessionID: selectedSessionID, vertical: -1) - postOnboardingAction(action) - return true - case .niriFocusRight: - guard niriEnabled, let selectedSessionID else { return false } - sessionService.niriFocusNeighbor(sessionID: selectedSessionID, horizontal: 1) - postOnboardingAction(action) - return true - case .niriToggleOverview: - guard niriEnabled, let selectedSessionID else { return false } - sessionService.toggleNiriOverview(sessionID: selectedSessionID) - postOnboardingAction(action) - return true - case .niriConfirmSelection: - guard niriEnabled, let selectedSessionID else { return false } - let layout = sessionService.niriLayout(for: selectedSessionID) - guard layout.isOverviewOpen else { return false } - sessionService.toggleNiriOverview(sessionID: selectedSessionID) - return true - case .niriToggleColumnTabbedDisplay: - guard niriEnabled, let selectedSessionID else { return false } - sessionService.toggleNiriColumnTabbedDisplay(sessionID: selectedSessionID) - return true - case .niriToggleSnap: - guard niriEnabled else { return false } - sessionService.saveSettings { $0.niri.snapEnabled.toggle() } - return true - case .niriFocusWorkspaceUp: - guard niriEnabled, let selectedSessionID else { return false } - sessionService.focusNiriWorkspaceUp(sessionID: selectedSessionID) - return true - case .niriFocusWorkspaceDown: - guard niriEnabled, let selectedSessionID else { return false } - sessionService.focusNiriWorkspaceDown(sessionID: selectedSessionID) - return true - case .niriMoveColumnToWorkspaceUp: - guard niriEnabled, let selectedSessionID else { return false } - sessionService.moveNiriColumnToWorkspaceUp(sessionID: selectedSessionID) - return true - case .niriMoveColumnToWorkspaceDown: - guard niriEnabled, let selectedSessionID else { return false } - sessionService.moveNiriColumnToWorkspaceDown(sessionID: selectedSessionID) - return true - case .niriToggleFocusedTileZoom: - guard niriEnabled, let selectedSessionID else { return false } - return sessionService.toggleNiriFocusedTileZoom(sessionID: selectedSessionID) - case .niriZoomInFocusedWebTile: - guard niriEnabled, let selectedSessionID else { return false } - return sessionService.adjustNiriFocusedWebTileZoom(for: selectedSessionID, delta: 0.1) - case .niriZoomOutFocusedWebTile: - guard niriEnabled, let selectedSessionID else { return false } - return sessionService.adjustNiriFocusedWebTileZoom(for: selectedSessionID, delta: -0.1) - default: - return nil - } + func handlePaneAndTabShortcutAction( + _ action: ShortcutActionID, + selectedSessionID: UUID?, + niriEnabled: Bool + ) -> Bool? { + switch action { + case .openClipboardURL: + return sessionService.openClipboardURLInSplit(for: selectedSessionID) + case .newTab: + guard let selectedSessionID else { return false } + _ = sessionService.createTab(in: selectedSessionID) + return true + case .nextTab: + guard let selectedSessionID else { return false } + sessionService.focusNextTab(in: selectedSessionID) + return true + case .previousTab: + guard let selectedSessionID else { return false } + sessionService.focusPreviousTab(in: selectedSessionID) + return true + case .closeTab: + guard let selectedSessionID else { return false } + sessionService.closeActiveTab(in: selectedSessionID) + return true + case .splitRight: + guard let selectedSessionID else { return false } + if niriEnabled { + _ = sessionService.niriAddTerminalRight(in: selectedSessionID) + } else { + sessionService.splitPane(sessionID: selectedSessionID, direction: .vertical) + } + postOnboardingAction(action) + return true + case .splitDown: + guard let selectedSessionID else { return false } + if niriEnabled { + _ = sessionService.niriAddTaskBelow(in: selectedSessionID) + } else { + sessionService.splitPane(sessionID: selectedSessionID, direction: .horizontal) + } + postOnboardingAction(action) + return true + case .closePane: + guard let selectedSessionID else { return false } + if niriEnabled { + sessionService.closeNiriFocusedItem(in: selectedSessionID) + } else { + sessionService.closePane(sessionID: selectedSessionID) + } + postOnboardingAction(action) + return true + case .nextPane: + guard let selectedSessionID else { return false } + sessionService.focusNextPane(sessionID: selectedSessionID) + return true + case .previousPane: + guard let selectedSessionID else { return false } + sessionService.focusPreviousPane(sessionID: selectedSessionID) + return true + case .toggleBrowserSplit: + guard let selectedSessionID else { return false } + sessionService.toggleBrowserSplit(for: selectedSessionID) + return true + default: + return nil } + } - func fetchDiffStats(for sessionID: UUID) { - guard let session = sessionService.sessions.first(where: { $0.id == sessionID }) else { return } - let path = session.worktreePath ?? session.repoPath - guard let path else { return } - Task { - let git = GitService() - if let stat = try? await git.diffStat(path: path) { - sessionService.setDiffStat(for: sessionID, stat: stat) - } - } - } + private func postOnboardingAction(_ action: ShortcutActionID) { + NotificationCenter.default.post( + name: .niriOnboardingActionPerformed, + object: nil, + userInfo: ["action": action.rawValue] + ) + } - func presentNewSessionSheet(preset: NewSessionPreset) { - newSessionPreset = preset - showingNewSessionSheet = true + func handleNiriShortcutAction( + _ action: ShortcutActionID, + selectedSessionID: UUID?, + niriEnabled: Bool + ) -> Bool? { + switch action { + case .niriAddTerminalRight: + guard niriEnabled, let selectedSessionID else { return false } + _ = sessionService.niriAddTerminalRight(in: selectedSessionID) + postOnboardingAction(action) + return true + case .niriAddTaskBelow: + guard niriEnabled, let selectedSessionID else { return false } + _ = sessionService.niriAddTaskBelow(in: selectedSessionID) + postOnboardingAction(action) + return true + case .niriAddBrowserTile: + guard niriEnabled, let selectedSessionID else { return false } + _ = sessionService.niriAddBrowserRight(in: selectedSessionID) + return true + case .niriOpenAddTileMenu: + return requestNiriAddTileMenu(for: selectedSessionID) + case .niriFocusLeft: + guard niriEnabled, let selectedSessionID else { return false } + sessionService.niriFocusNeighbor(sessionID: selectedSessionID, horizontal: -1) + postOnboardingAction(action) + return true + case .niriFocusDown: + guard niriEnabled, let selectedSessionID else { return false } + sessionService.niriFocusNeighbor(sessionID: selectedSessionID, vertical: 1) + postOnboardingAction(action) + return true + case .niriFocusUp: + guard niriEnabled, let selectedSessionID else { return false } + sessionService.niriFocusNeighbor(sessionID: selectedSessionID, vertical: -1) + postOnboardingAction(action) + return true + case .niriFocusRight: + guard niriEnabled, let selectedSessionID else { return false } + sessionService.niriFocusNeighbor(sessionID: selectedSessionID, horizontal: 1) + postOnboardingAction(action) + return true + case .niriToggleOverview: + guard niriEnabled, let selectedSessionID else { return false } + sessionService.toggleNiriOverview(sessionID: selectedSessionID) + postOnboardingAction(action) + return true + case .niriConfirmSelection: + guard niriEnabled, let selectedSessionID else { return false } + let layout = sessionService.niriLayout(for: selectedSessionID) + guard layout.isOverviewOpen else { return false } + sessionService.toggleNiriOverview(sessionID: selectedSessionID) + return true + case .niriToggleColumnTabbedDisplay: + guard niriEnabled, let selectedSessionID else { return false } + sessionService.toggleNiriColumnTabbedDisplay(sessionID: selectedSessionID) + return true + case .niriToggleSnap: + guard niriEnabled else { return false } + sessionService.saveSettings { $0.niri.snapEnabled.toggle() } + return true + case .niriFocusWorkspaceUp: + guard niriEnabled, let selectedSessionID else { return false } + sessionService.focusNiriWorkspaceUp(sessionID: selectedSessionID) + return true + case .niriFocusWorkspaceDown: + guard niriEnabled, let selectedSessionID else { return false } + sessionService.focusNiriWorkspaceDown(sessionID: selectedSessionID) + return true + case .niriMoveColumnToWorkspaceUp: + guard niriEnabled, let selectedSessionID else { return false } + sessionService.moveNiriColumnToWorkspaceUp(sessionID: selectedSessionID) + return true + case .niriMoveColumnToWorkspaceDown: + guard niriEnabled, let selectedSessionID else { return false } + sessionService.moveNiriColumnToWorkspaceDown(sessionID: selectedSessionID) + return true + case .niriToggleFocusedTileZoom: + guard niriEnabled, let selectedSessionID else { return false } + return sessionService.toggleNiriFocusedTileZoom(sessionID: selectedSessionID) + case .niriZoomInFocusedWebTile: + guard niriEnabled, let selectedSessionID else { return false } + return sessionService.adjustNiriFocusedWebTileZoom(for: selectedSessionID, delta: 0.1) + case .niriZoomOutFocusedWebTile: + guard niriEnabled, let selectedSessionID else { return false } + return sessionService.adjustNiriFocusedWebTileZoom(for: selectedSessionID, delta: -0.1) + default: + return nil } + } - func presentCommandPalette() { - showingCommandPalette = true + func fetchDiffStats(for sessionID: UUID) { + guard let session = sessionService.sessions.first(where: { $0.id == sessionID }) else { return } + let path = session.worktreePath ?? session.repoPath + guard let path else { return } + Task { + let git = GitService() + if let stat = try? await git.diffStat(path: path) { + sessionService.setDiffStat(for: sessionID, stat: stat) + } } + } - func dismissCommandPalette() { - showingCommandPalette = false - } + func presentNewSessionSheet(preset: NewSessionPreset) { + newSessionPreset = preset + showingNewSessionSheet = true + } + + func presentCommandPalette() { + showingCommandPalette = true + } + + func dismissCommandPalette() { + showingCommandPalette = false + } + + func presentNiriOnboardingNow() { + showingNiriOnboarding = true + } - func presentNiriOnboardingNow() { - showingNiriOnboarding = true + @discardableResult + func requestNiriAddTileMenu(for sessionID: UUID?) -> Bool { + guard sessionService.settings.niriCanvasEnabled, + let sessionID + else { + return false } + niriQuickAddRequestSessionID = sessionID + return true + } - @discardableResult - func requestNiriAddTileMenu(for sessionID: UUID?) -> Bool { - guard sessionService.settings.niriCanvasEnabled, - let sessionID else { - return false - } - niriQuickAddRequestSessionID = sessionID - return true + func triggerPrimaryNewSessionAction() { + // In Niri mode, Cmd+N should add a new right-side tile in the current session. + if triggerNiriPrimaryNewTileAction() { + return } - func triggerPrimaryNewSessionAction() { - // In Niri mode, Cmd+N should add a new right-side tile in the current session. - if triggerNiriPrimaryNewTileAction() { - return - } + // Non-Niri fallback: create a fresh session. + triggerDefaultVibeNewSessionAction() + } - // Non-Niri fallback: create a fresh session. - triggerDefaultVibeNewSessionAction() + @discardableResult + func triggerNiriPrimaryNewTileAction() -> Bool { + guard sessionService.settings.niriCanvasEnabled, + let sessionID = sessionService.selectedSessionID + else { + return false } - @discardableResult - func triggerNiriPrimaryNewTileAction() -> Bool { - guard sessionService.settings.niriCanvasEnabled, - let sessionID = sessionService.selectedSessionID else { - return false - } + guard sessionService.niriAddTerminalRight(in: sessionID) != nil else { + return false + } - guard sessionService.niriAddTerminalRight(in: sessionID) != nil else { - return false - } + // In Niri mode, Cmd+N should open the default vibe tool in the new tile + // when one is configured, regardless of the global auto-launch toggle. + workflowService.launchDefaultToolIfConfigured( + in: sessionID, + settings: sessionService.settings, + respectToggle: false + ) + return true + } - // In Niri mode, Cmd+N should open the default vibe tool in the new tile - // when one is configured, regardless of the global auto-launch toggle. - workflowService.launchDefaultToolIfConfigured( - in: sessionID, - settings: sessionService.settings, - respectToggle: false - ) - return true + @discardableResult + func quickApproveSelectedSession() -> Bool { + guard let selectedID = sessionService.selectedSessionID else { + return false + } + guard let result = terminalMonitor.agentStates[selectedID], + result.hasDetectedAgent, + result.isApprovalPrompt + else { + return false } - @discardableResult - func quickApproveSelectedSession() -> Bool { - guard let selectedID = sessionService.selectedSessionID else { - return false - } - guard let result = terminalMonitor.agentStates[selectedID], - result.hasDetectedAgent, - result.isApprovalPrompt else { - return false - } - - let response: String - switch result.detectedAgent { - case .codex: - response = "yes\n" - default: - response = "y\n" - } - sessionService.ensureController(for: selectedID)?.send(text: response) - return true + let response = switch result.detectedAgent { + case .codex: + "yes\n" + default: + "y\n" } + sessionService.ensureController(for: selectedID)?.send(text: response) + return true + } - func triggerSidebarNewTerminalAction() { - Task { @MainActor in - do { - let currentCwd = sessionService.selectedSession?.lastKnownCwd - ?? sessionService.selectedSession?.repoPath - _ = try await sessionService.createSession( - from: SessionCreationRequest( - title: nil, - repoPath: currentCwd, - createWorktree: false, - branchName: nil, - existingWorktreePath: nil, - shellPath: nil, - launchToolID: nil - ) - ) - } catch { - Logger.error("Sidebar new terminal failed: \(error.localizedDescription)") - } - } + func triggerSidebarNewTerminalAction() { + Task { @MainActor in + do { + let currentCwd = sessionService.selectedSession?.lastKnownCwd + ?? sessionService.selectedSession?.repoPath + _ = try await sessionService.createSession( + from: SessionCreationRequest( + title: nil, + repoPath: currentCwd, + createWorktree: false, + branchName: nil, + existingWorktreePath: nil, + shellPath: nil, + launchToolID: nil + ) + ) + } catch { + Logger.error("Sidebar new terminal failed: \(error.localizedDescription)") + } } + } - func triggerOpenFolderSession() { - let panel = NSOpenPanel() - panel.canChooseDirectories = true - panel.canChooseFiles = false - panel.allowsMultipleSelection = false - panel.prompt = "Open" - panel.message = "Choose a folder to open as a new session" + func triggerOpenFolderSession() { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + panel.prompt = "Open" + panel.message = "Choose a folder to open as a new session" - guard panel.runModal() == .OK, let folder = panel.url?.path else { return } + guard panel.runModal() == .OK, let folder = panel.url?.path else { return } - Task { @MainActor in - do { - _ = try await sessionService.createSession( - from: SessionCreationRequest( - title: nil, - repoPath: folder, - createWorktree: false, - branchName: nil, - existingWorktreePath: nil, - shellPath: nil, - launchToolID: nil - ) - ) - } catch { - Logger.error("Open folder session failed: \(error.localizedDescription)") - } - } + Task { @MainActor in + do { + _ = try await sessionService.createSession( + from: SessionCreationRequest( + title: nil, + repoPath: folder, + createWorktree: false, + branchName: nil, + existingWorktreePath: nil, + shellPath: nil, + launchToolID: nil + ) + ) + } catch { + Logger.error("Open folder session failed: \(error.localizedDescription)") + } } + } - func triggerDefaultVibeNewSessionAction() { - Task { @MainActor in - do { - // Inherit the current session's working directory so the new tab starts in the same place - let currentCwd = sessionService.selectedSession?.lastKnownCwd - ?? sessionService.selectedSession?.repoPath - let request = SessionCreationRequest( - title: nil, - repoPath: currentCwd, - createWorktree: false, - branchName: nil, - existingWorktreePath: nil, - shellPath: nil, - sandboxProfile: nil, - networkPolicy: nil, - launchToolID: sessionService.settings.autoLaunchDefaultVibeToolOnCmdN - ? sessionService.settings.defaultVibeToolID - : nil - ) - let result = try await sessionService.createSession(from: request) - workflowService.launchDefaultToolIfConfigured(in: result.session.id, settings: sessionService.settings) - } catch { - Logger.error("Default new session failed: \(error.localizedDescription)") - } - } + func triggerDefaultVibeNewSessionAction() { + Task { @MainActor in + do { + // Inherit the current session's working directory so the new tab starts in the same place + let currentCwd = sessionService.selectedSession?.lastKnownCwd + ?? sessionService.selectedSession?.repoPath + let request = SessionCreationRequest( + title: nil, + repoPath: currentCwd, + createWorktree: false, + branchName: nil, + existingWorktreePath: nil, + shellPath: nil, + sandboxProfile: nil, + networkPolicy: nil, + launchToolID: sessionService.settings.autoLaunchDefaultVibeToolOnCmdN + ? sessionService.settings.defaultVibeToolID + : nil + ) + let result = try await sessionService.createSession(from: request) + workflowService.launchDefaultToolIfConfigured(in: result.session.id, settings: sessionService.settings) + } catch { + Logger.error("Default new session failed: \(error.localizedDescription)") + } } - + } } diff --git a/idx0/App/AppCoordinator.swift b/idx0/App/AppCoordinator.swift index 34f5957..e51ad40 100644 --- a/idx0/App/AppCoordinator.swift +++ b/idx0/App/AppCoordinator.swift @@ -2,356 +2,357 @@ import AppKit import Foundation import SwiftUI - @MainActor final class AppCoordinator: ObservableObject { - @Published var showingNewSessionSheet = false - @Published var newSessionPreset: NewSessionPreset = .quick - @Published var showingRenameSessionSheet = false - @Published var showingCommandPalette = false - @Published var showingQuickSwitch = false - @Published var showingKeyboardShortcuts = false - @Published var showingNiriOnboarding = false - @Published var showingCheckpoints = false - @Published var showingDiffOverlay = false - @Published var showingSettings = false - @Published var niriQuickAddRequestSessionID: UUID? - @Published var renameSessionID: UUID? - @Published var renameDraftTitle = "" - - let paths: FileSystemPaths - let sessionService: SessionService - let workflowService: WorkflowService - let terminalMonitor = TerminalMonitorService() - let autoCheckpointService: AutoCheckpointService - let shellPool = ShellPoolService() - let appUpdateService: AppUpdateService - - private var ipcServer: IPCServer? - private let ipcCommandRouter: IPCCommandRouter - private var gitMonitor: GitMonitor? - private var localKeyMonitor: Any? - private let shortcutDispatcher = ShortcutDispatcher() - let shortcutCommandDispatcher = ShortcutCommandDispatcher() - - init() { - do { - let paths = try BootstrapCoordinator.makePaths() - self.paths = paths - - let sessionStore = SessionStore(url: paths.sessionsFile) - let projectStore = ProjectStore(url: paths.projectsFile) - let inboxStore = InboxStore(url: paths.inboxFile) - let settingsStore = SettingsStore(url: paths.settingsFile) - // Write theme config before GhosttyAppHost.shared initializes - let earlySettings = settingsStore.load() - GhosttyAppHost.writeThemeConfig(themeID: earlySettings.terminalThemeID) - - let gitService = GitService() - let worktreeService = WorktreeService(gitService: gitService, paths: paths) - let launcherRoot = FileManager.default.temporaryDirectory - .appendingPathComponent("idx0-launchers", isDirectory: true) - - self.sessionService = SessionService( - sessionStore: sessionStore, - projectStore: projectStore, - inboxStore: inboxStore, - settingsStore: settingsStore, - worktreeService: worktreeService, - launcherDirectory: launcherRoot, - ipcSocketPath: paths.runDirectory.appendingPathComponent("idx0.sock", isDirectory: false).path, - host: .shared - ) - - self.workflowService = WorkflowService( - sessionService: self.sessionService, - checkpointStore: CheckpointStore(url: paths.checkpointsFile), - handoffStore: HandoffStore(url: paths.handoffsFile), - reviewStore: ReviewStore(url: paths.reviewsFile), - approvalStore: ApprovalStore(url: paths.approvalsFile), - queueStore: QueueStore(url: paths.queueFile), - timelineStore: TimelineStore(url: paths.timelineFile), - layoutStore: LayoutStore(url: paths.layoutFile), - agentEventStore: AgentEventStore(url: paths.agentEventsFile), - legacyAttentionItems: self.sessionService.attentionItems - ) - self.ipcCommandRouter = IPCCommandRouter( - sessionService: self.sessionService, - workflowService: self.workflowService - ) - - self.autoCheckpointService = AutoCheckpointService( - gitService: gitService, - storageURL: paths.appSupportDirectory.appendingPathComponent("auto-checkpoints.json", isDirectory: false) - ) - let environmentProvider = ProcessEnvironmentProvider() - let updateDriver = SparkleUpdateDriver(environment: environmentProvider) - let sessionServiceForUpdates = self.sessionService - self.appUpdateService = AppUpdateService( - driver: updateDriver, - scheduler: TimerUpdateScheduler(), - versionProvider: BundleAppVersionProvider(), - environment: environmentProvider, - autoCheckEnabledProvider: { - sessionServiceForUpdates.settings.autoCheckForUpdates - } - ) - - // Configure terminal monitor - self.terminalMonitor.configure( - host: .shared, - surfaceProvider: { [weak self] sessionID in - self?.sessionService.controller(for: sessionID)?.terminalSurface - }, - sessionInfoProvider: { [weak self] sessionID in - guard let self, let session = self.sessionService.sessions.first(where: { $0.id == sessionID }) else { - return nil - } - return (title: session.title, isFocused: self.sessionService.selectedSessionID == sessionID) - } - ) - - // Terminal monitor callbacks - self.terminalMonitor.onStateChanged = { [weak self] sessionID, result in - guard let self else { return } - let hasDetectedAgent = result.hasDetectedAgent - // Update session's agent activity from scan result - let activity: AgentActivity? = { - guard hasDetectedAgent else { return nil } - switch result.state { - case .thinking, .working: - return .active(description: result.stateDescription ?? "Working...") - case .waitingForInput: - return .waiting(description: result.stateDescription ?? "Waiting for input") - case .completed: - return .completed(description: result.stateDescription ?? "Finished") - case .error: - return .error(description: result.stateDescription ?? "Error") - case .idle: - return nil - } - }() - self.sessionService.setAgentActivity(for: sessionID, activity: activity) - - // Fetch diff stats on completion - if hasDetectedAgent, result.state == .completed { - self.fetchDiffStats(for: sessionID) - } - - // Clear diff stats when agent starts working again - if hasDetectedAgent, result.state == .thinking || result.state == .working { - self.sessionService.setDiffStat(for: sessionID, stat: nil) - } - } - - // Auto-checkpoint when agent starts - self.terminalMonitor.onAgentStarted = { [weak self] sessionID in - guard let self else { return } - guard let session = self.sessionService.sessions.first(where: { $0.id == sessionID }) else { return } - let path = session.worktreePath ?? session.repoPath - guard let path else { return } - Task { - await self.autoCheckpointService.createCheckpoint(sessionID: sessionID, repoPath: path) - } - } - - self.terminalMonitor.startMonitoring() - - // Track existing sessions for monitoring - for session in self.sessionService.sessions { - self.terminalMonitor.trackSession(session.id) - } - - self.sessionService.onSessionCreated = { [weak workflowService = self.workflowService, weak terminalMonitor = self.terminalMonitor] session in - workflowService?.recordSessionCreated(session) - terminalMonitor?.trackSession(session.id) - } - self.sessionService.onSessionLaunched = { [weak workflowService = self.workflowService] sessionID in - workflowService?.recordSessionLaunched(sessionID) - } - self.sessionService.onSessionClosed = { [weak workflowService = self.workflowService, weak terminalMonitor = self.terminalMonitor, weak autoCheckpointService = self.autoCheckpointService] sessionID in - workflowService?.recordSessionClosed(sessionID) - terminalMonitor?.untrackSession(sessionID) - autoCheckpointService?.removeCheckpoints(for: sessionID) - } - self.sessionService.onSessionCompleted = { [weak workflowService = self.workflowService] sessionID, message in - workflowService?.recordSessionCompleted(sessionID, message: message) - } - self.sessionService.onSessionErrored = { [weak workflowService = self.workflowService] sessionID, message in - workflowService?.recordSessionError(sessionID, message: message) - } - self.sessionService.onSessionNeedsInput = { [weak workflowService = self.workflowService, weak terminalMonitor = self.terminalMonitor] sessionID, message in - workflowService?.recordSessionNeedsInput(sessionID, message: message) - terminalMonitor?.notifyActivity(for: sessionID) - } - self.sessionService.onSessionFocused = { [weak workflowService = self.workflowService] sessionID in - workflowService?.resolveApprovalItems(for: sessionID) - } - - let socketPath = paths.runDirectory.appendingPathComponent("idx0.sock", isDirectory: false).path - let server = IPCServer( - socketPath: socketPath, - handler: { [weak self] request in - self?.handleIPCRequestFromBackground(request) ?? IPCResponse(success: false, message: "App unavailable", data: nil) - } - ) - self.ipcServer = server - server.start() - - let monitor = GitMonitor( - sessionService: self.sessionService, - workflowService: self.workflowService - ) - self.gitMonitor = monitor - monitor.start() - - // Pre-warm shell pool and launcher scripts in background - self.shellPool.warmUp(preferredShell: self.sessionService.settings.preferredShellPath) - self.sessionService.prewarmLauncherScripts() - self.sessionService.shellPool = self.shellPool - self.workflowService.setShellPool(self.shellPool) - installLocalKeyMonitor() - } catch { - fatalError("Failed to initialize app coordinator: \(error)") + @Published var showingNewSessionSheet = false + @Published var newSessionPreset: NewSessionPreset = .quick + @Published var showingRenameSessionSheet = false + @Published var showingCommandPalette = false + @Published var showingQuickSwitch = false + @Published var showingKeyboardShortcuts = false + @Published var showingNiriOnboarding = false + @Published var showingCheckpoints = false + @Published var showingDiffOverlay = false + @Published var showingSettings = false + @Published var niriQuickAddRequestSessionID: UUID? + @Published var renameSessionID: UUID? + @Published var renameDraftTitle = "" + + let paths: FileSystemPaths + let sessionService: SessionService + let workflowService: WorkflowService + let terminalMonitor = TerminalMonitorService() + let autoCheckpointService: AutoCheckpointService + let shellPool = ShellPoolService() + let appUpdateService: AppUpdateService + + private var ipcServer: IPCServer? + private let ipcCommandRouter: IPCCommandRouter + private var gitMonitor: GitMonitor? + private var localKeyMonitor: Any? + private let shortcutDispatcher = ShortcutDispatcher() + let shortcutCommandDispatcher = ShortcutCommandDispatcher() + + init() { + do { + let paths = try BootstrapCoordinator.makePaths() + self.paths = paths + + let sessionStore = SessionStore(url: paths.sessionsFile) + let projectStore = ProjectStore(url: paths.projectsFile) + let inboxStore = InboxStore(url: paths.inboxFile) + let settingsStore = SettingsStore(url: paths.settingsFile) + // Write theme config before GhosttyAppHost.shared initializes + let earlySettings = settingsStore.load() + GhosttyAppHost.writeThemeConfig(themeID: earlySettings.terminalThemeID) + + let gitService = GitService() + let worktreeService = WorktreeService(gitService: gitService, paths: paths) + let launcherRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("idx0-launchers", isDirectory: true) + + sessionService = SessionService( + sessionStore: sessionStore, + projectStore: projectStore, + inboxStore: inboxStore, + settingsStore: settingsStore, + worktreeService: worktreeService, + launcherDirectory: launcherRoot, + ipcSocketPath: paths.runDirectory.appendingPathComponent("idx0.sock", isDirectory: false).path, + host: .shared + ) + + workflowService = WorkflowService( + sessionService: sessionService, + checkpointStore: CheckpointStore(url: paths.checkpointsFile), + handoffStore: HandoffStore(url: paths.handoffsFile), + reviewStore: ReviewStore(url: paths.reviewsFile), + approvalStore: ApprovalStore(url: paths.approvalsFile), + queueStore: QueueStore(url: paths.queueFile), + timelineStore: TimelineStore(url: paths.timelineFile), + layoutStore: LayoutStore(url: paths.layoutFile), + agentEventStore: AgentEventStore(url: paths.agentEventsFile), + legacyAttentionItems: sessionService.attentionItems + ) + ipcCommandRouter = IPCCommandRouter( + sessionService: sessionService, + workflowService: workflowService + ) + + autoCheckpointService = AutoCheckpointService( + gitService: gitService, + storageURL: paths.appSupportDirectory.appendingPathComponent("auto-checkpoints.json", isDirectory: false) + ) + let environmentProvider = ProcessEnvironmentProvider() + let updateDriver = SparkleUpdateDriver(environment: environmentProvider) + let sessionServiceForUpdates = sessionService + appUpdateService = AppUpdateService( + driver: updateDriver, + scheduler: TimerUpdateScheduler(), + versionProvider: BundleAppVersionProvider(), + environment: environmentProvider, + autoCheckEnabledProvider: { + sessionServiceForUpdates.settings.autoCheckForUpdates } - } - - func prepareForTermination() { - terminalMonitor.stopMonitoring() - gitMonitor?.stop() - ipcServer?.stop() - if let localKeyMonitor { - NSEvent.removeMonitor(localKeyMonitor) - self.localKeyMonitor = nil + ) + + // Configure terminal monitor + terminalMonitor.configure( + host: .shared, + surfaceProvider: { [weak self] sessionID in + self?.sessionService.controller(for: sessionID)?.terminalSurface + }, + sessionInfoProvider: { [weak self] sessionID in + guard let self, let session = sessionService.sessions.first(where: { $0.id == sessionID }) else { + return nil + } + return (title: session.title, isFocused: sessionService.selectedSessionID == sessionID) } - workflowService.prepareForTermination() - sessionService.prepareForTermination() - } + ) + + // Terminal monitor callbacks + terminalMonitor.onStateChanged = { [weak self] sessionID, result in + guard let self else { return } + let hasDetectedAgent = result.hasDetectedAgent + // Update session's agent activity from scan result + let activity: AgentActivity? = { + guard hasDetectedAgent else { return nil } + switch result.state { + case .thinking, .working: + return .active(description: result.stateDescription ?? "Working...") + case .waitingForInput: + return .waiting(description: result.stateDescription ?? "Waiting for input") + case .completed: + return .completed(description: result.stateDescription ?? "Finished") + case .error: + return .error(description: result.stateDescription ?? "Error") + case .idle: + return nil + } + }() + sessionService.setAgentActivity(for: sessionID, activity: activity) - private func installLocalKeyMonitor() { - localKeyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in - guard let self else { return event } - return self.handleLocalKeyEvent(event) + // Fetch diff stats on completion + if hasDetectedAgent, result.state == .completed { + fetchDiffStats(for: sessionID) } - } - private func handleLocalKeyEvent(_ event: NSEvent) -> NSEvent? { - if handleInlineSettingsEscape(event) { - return nil + // Clear diff stats when agent starts working again + if hasDetectedAgent, result.state == .thinking || result.state == .working { + sessionService.setDiffStat(for: sessionID, stat: nil) } - guard !isEditableTextInputFocused else { return event } - if handleNiriOverviewArrowKeyNavigation(event) { - return nil + } + + // Auto-checkpoint when agent starts + terminalMonitor.onAgentStarted = { [weak self] sessionID in + guard let self else { return } + guard let session = sessionService.sessions.first(where: { $0.id == sessionID }) else { return } + let path = session.worktreePath ?? session.repoPath + guard let path else { return } + Task { + await self.autoCheckpointService.createCheckpoint(sessionID: sessionID, repoPath: path) } - guard let action = shortcutDispatcher.resolveAction(for: event, settings: sessionService.settings) else { - return event + } + + terminalMonitor.startMonitoring() + + // Track existing sessions for monitoring + for session in sessionService.sessions { + terminalMonitor.trackSession(session.id) + } + + sessionService.onSessionCreated = { [weak workflowService = self.workflowService, weak terminalMonitor = self.terminalMonitor] session in + workflowService?.recordSessionCreated(session) + terminalMonitor?.trackSession(session.id) + } + sessionService.onSessionLaunched = { [weak workflowService = self.workflowService] sessionID in + workflowService?.recordSessionLaunched(sessionID) + } + sessionService.onSessionClosed = { [weak workflowService = self.workflowService, weak terminalMonitor = self.terminalMonitor, weak autoCheckpointService = self.autoCheckpointService] sessionID in + workflowService?.recordSessionClosed(sessionID) + terminalMonitor?.untrackSession(sessionID) + autoCheckpointService?.removeCheckpoints(for: sessionID) + } + sessionService.onSessionCompleted = { [weak workflowService = self.workflowService] sessionID, message in + workflowService?.recordSessionCompleted(sessionID, message: message) + } + sessionService.onSessionErrored = { [weak workflowService = self.workflowService] sessionID, message in + workflowService?.recordSessionError(sessionID, message: message) + } + sessionService.onSessionNeedsInput = { [weak workflowService = self.workflowService, weak terminalMonitor = self.terminalMonitor] sessionID, message in + workflowService?.recordSessionNeedsInput(sessionID, message: message) + terminalMonitor?.notifyActivity(for: sessionID) + } + sessionService.onSessionFocused = { [weak workflowService = self.workflowService] sessionID in + workflowService?.resolveApprovalItems(for: sessionID) + } + + let socketPath = paths.runDirectory.appendingPathComponent("idx0.sock", isDirectory: false).path + let server = IPCServer( + socketPath: socketPath, + handler: { [weak self] request in + self?.handleIPCRequestFromBackground(request) ?? IPCResponse(success: false, message: "App unavailable", data: nil) } - guard shortcutCommandDispatcher.perform(action, coordinator: self) else { return event } - return nil + ) + ipcServer = server + server.start() + + let monitor = GitMonitor( + sessionService: sessionService, + workflowService: workflowService + ) + gitMonitor = monitor + monitor.start() + + // Pre-warm shell pool and launcher scripts in background + shellPool.warmUp(preferredShell: sessionService.settings.preferredShellPath) + sessionService.prewarmLauncherScripts() + sessionService.shellPool = shellPool + workflowService.setShellPool(shellPool) + installLocalKeyMonitor() + } catch { + fatalError("Failed to initialize app coordinator: \(error)") } - - private func handleInlineSettingsEscape(_ event: NSEvent) -> Bool { - guard showingSettings else { return false } - guard event.keyCode == 53 else { return false } // Escape - let modifiers = event.modifierFlags.intersection([.command, .option, .shift, .control]) - guard modifiers.isEmpty else { return false } - showingSettings = false - return true + } + + func prepareForTermination() { + terminalMonitor.stopMonitoring() + gitMonitor?.stop() + ipcServer?.stop() + if let localKeyMonitor { + NSEvent.removeMonitor(localKeyMonitor) + self.localKeyMonitor = nil } + workflowService.prepareForTermination() + sessionService.prepareForTermination() + } + + private func installLocalKeyMonitor() { + localKeyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard let self else { return event } + return handleLocalKeyEvent(event) + } + } - private func handleNiriOverviewArrowKeyNavigation(_ event: NSEvent) -> Bool { - guard sessionService.settings.niriCanvasEnabled, - let selectedSessionID = sessionService.selectedSessionID else { - return false - } - - let modifiers = event.modifierFlags.intersection([.command, .option, .shift, .control]) - guard modifiers.isEmpty else { return false } - - // Escape exits focused-tile zoom mode. - if event.keyCode == 53, - sessionService.niriFocusedTileZoomItemID(for: selectedSessionID) != nil { - sessionService.clearNiriFocusedTileZoom(sessionID: selectedSessionID) - return true - } - - let layout = sessionService.niriLayout(for: selectedSessionID) - guard layout.isOverviewOpen else { return false } + private func handleLocalKeyEvent(_ event: NSEvent) -> NSEvent? { + if handleInlineSettingsEscape(event) { + return nil + } + guard !isEditableTextInputFocused else { return event } + if handleNiriOverviewArrowKeyNavigation(event) { + return nil + } + guard let action = shortcutDispatcher.resolveAction(for: event, settings: sessionService.settings) else { + return event + } + guard shortcutCommandDispatcher.perform(action, coordinator: self) else { return event } + return nil + } + + private func handleInlineSettingsEscape(_ event: NSEvent) -> Bool { + guard showingSettings else { return false } + guard event.keyCode == 53 else { return false } // Escape + let modifiers = event.modifierFlags.intersection([.command, .option, .shift, .control]) + guard modifiers.isEmpty else { return false } + showingSettings = false + return true + } + + private func handleNiriOverviewArrowKeyNavigation(_ event: NSEvent) -> Bool { + guard sessionService.settings.niriCanvasEnabled, + let selectedSessionID = sessionService.selectedSessionID + else { + return false + } - // Escape exits overview mode. - if event.keyCode == 53 { - sessionService.toggleNiriOverview(sessionID: selectedSessionID) - return true - } + let modifiers = event.modifierFlags.intersection([.command, .option, .shift, .control]) + guard modifiers.isEmpty else { return false } - guard let key = ShortcutKey.from(event: event) else { return false } - switch key { - case .upArrow: - sessionService.niriFocusNeighbor(sessionID: selectedSessionID, vertical: -1) - return true - case .downArrow: - sessionService.niriFocusNeighbor(sessionID: selectedSessionID, vertical: 1) - return true - case .leftArrow: - sessionService.niriFocusNeighbor(sessionID: selectedSessionID, horizontal: -1) - return true - case .rightArrow: - sessionService.niriFocusNeighbor(sessionID: selectedSessionID, horizontal: 1) - return true - case .returnKey: - sessionService.toggleNiriOverview(sessionID: selectedSessionID) - return true - default: - return false - } + // Escape exits focused-tile zoom mode. + if event.keyCode == 53, + sessionService.niriFocusedTileZoomItemID(for: selectedSessionID) != nil + { + sessionService.clearNiriFocusedTileZoom(sessionID: selectedSessionID) + return true } - private var isEditableTextInputFocused: Bool { - guard let firstResponder = NSApp.keyWindow?.firstResponder else { - return false - } - guard let textView = firstResponder as? NSTextView else { - return false - } - return textView.isEditable - } + let layout = sessionService.niriLayout(for: selectedSessionID) + guard layout.isOverviewOpen else { return false } - func presentRenameSessionSheet(session: Session) { - renameSessionID = session.id - renameDraftTitle = session.title - showingRenameSessionSheet = true + // Escape exits overview mode. + if event.keyCode == 53 { + sessionService.toggleNiriOverview(sessionID: selectedSessionID) + return true } - func commitRenameSession() { - guard let renameSessionID else { return } - sessionService.renameSession(renameSessionID, title: renameDraftTitle) - cancelRenameSession() + guard let key = ShortcutKey.from(event: event) else { return false } + switch key { + case .upArrow: + sessionService.niriFocusNeighbor(sessionID: selectedSessionID, vertical: -1) + return true + case .downArrow: + sessionService.niriFocusNeighbor(sessionID: selectedSessionID, vertical: 1) + return true + case .leftArrow: + sessionService.niriFocusNeighbor(sessionID: selectedSessionID, horizontal: -1) + return true + case .rightArrow: + sessionService.niriFocusNeighbor(sessionID: selectedSessionID, horizontal: 1) + return true + case .returnKey: + sessionService.toggleNiriOverview(sessionID: selectedSessionID) + return true + default: + return false } + } - func cancelRenameSession() { - showingRenameSessionSheet = false - renameSessionID = nil - renameDraftTitle = "" + private var isEditableTextInputFocused: Bool { + guard let firstResponder = NSApp.keyWindow?.firstResponder else { + return false } - - private nonisolated func handleIPCRequestFromBackground(_ request: IPCRequest) -> IPCResponse { - var response = IPCResponse(success: false, message: "Coordinator unavailable", data: nil) - DispatchQueue.main.sync { - response = MainActor.assumeIsolated { [weak self] in - self?.handleIPCRequest(request) ?? IPCResponse(success: false, message: "Coordinator unavailable", data: nil) - } - } - return response + guard let textView = firstResponder as? NSTextView else { + return false } - - @MainActor - private func handleIPCRequest(_ request: IPCRequest) -> IPCResponse { - ipcCommandRouter.handle(request) + return textView.isEditable + } + + func presentRenameSessionSheet(session: Session) { + renameSessionID = session.id + renameDraftTitle = session.title + showingRenameSessionSheet = true + } + + func commitRenameSession() { + guard let renameSessionID else { return } + sessionService.renameSession(renameSessionID, title: renameDraftTitle) + cancelRenameSession() + } + + func cancelRenameSession() { + showingRenameSessionSheet = false + renameSessionID = nil + renameDraftTitle = "" + } + + private nonisolated func handleIPCRequestFromBackground(_ request: IPCRequest) -> IPCResponse { + var response = IPCResponse(success: false, message: "Coordinator unavailable", data: nil) + DispatchQueue.main.sync { + response = MainActor.assumeIsolated { [weak self] in + self?.handleIPCRequest(request) ?? IPCResponse(success: false, message: "Coordinator unavailable", data: nil) + } } + return response + } + + @MainActor + private func handleIPCRequest(_ request: IPCRequest) -> IPCResponse { + ipcCommandRouter.handle(request) + } } enum NewSessionPreset { - case quick - case repo - case worktree + case quick + case repo + case worktree } diff --git a/idx0/App/AppSettings.swift b/idx0/App/AppSettings.swift index 768d50b..b2de75e 100644 --- a/idx0/App/AppSettings.swift +++ b/idx0/App/AppSettings.swift @@ -1,453 +1,453 @@ import Foundation enum RestoreBehavior: String, Codable, CaseIterable { - case restoreMetadataOnly - case relaunchSelectedSession - case relaunchAllSessions - - var displayLabel: String { - switch self { - case .restoreMetadataOnly: - return "Restore Metadata Only" - case .relaunchSelectedSession: - return "Relaunch Selected Session" - case .relaunchAllSessions: - return "Relaunch All Sessions" - } + case restoreMetadataOnly + case relaunchSelectedSession + case relaunchAllSessions + + var displayLabel: String { + switch self { + case .restoreMetadataOnly: + "Restore Metadata Only" + case .relaunchSelectedSession: + "Relaunch Selected Session" + case .relaunchAllSessions: + "Relaunch All Sessions" } + } } enum ExternalLinkRouting: String, Codable, CaseIterable { - case defaultBrowser - case embeddedBrowser - - var displayLabel: String { - switch self { - case .defaultBrowser: - return "Default Browser" - case .embeddedBrowser: - return "Embedded Browser" - } + case defaultBrowser + case embeddedBrowser + + var displayLabel: String { + switch self { + case .defaultBrowser: + "Default Browser" + case .embeddedBrowser: + "Embedded Browser" } + } } enum NewSessionBehavior: String, Codable, CaseIterable { - case quick - case structured - - var displayLabel: String { - switch self { - case .quick: - return "Quick Session" - case .structured: - return "Structured Setup" - } + case quick + case structured + + var displayLabel: String { + switch self { + case .quick: + "Quick Session" + case .structured: + "Structured Setup" } + } } enum AppMode: String, Codable, CaseIterable { - case terminal - case hybrid - case vibeStudio - - var displayLabel: String { - switch self { - case .terminal: - return "Terminal" - case .hybrid: - return "Hybrid" - case .vibeStudio: - return "Vibe Studio" - } + case terminal + case hybrid + case vibeStudio + + var displayLabel: String { + switch self { + case .terminal: + "Terminal" + case .hybrid: + "Hybrid" + case .vibeStudio: + "Vibe Studio" } + } - var showsVibeFeatures: Bool { - self != .terminal - } + var showsVibeFeatures: Bool { + self != .terminal + } - var showsWorkflowRail: Bool { - self == .vibeStudio - } + var showsWorkflowRail: Bool { + self == .vibeStudio + } } enum NiriHotCorner: String, Codable, CaseIterable { - case topLeft - case topRight - case bottomLeft - case bottomRight + case topLeft + case topRight + case bottomLeft + case bottomRight } struct NiriEdgeViewScrollSettings: Codable, Equatable { - var triggerWidth: Double - var delayMs: Int - var maxSpeed: Double - - init( - triggerWidth: Double = 30, - delayMs: Int = 100, - maxSpeed: Double = 1500 - ) { - self.triggerWidth = triggerWidth - self.delayMs = delayMs - self.maxSpeed = maxSpeed - } + var triggerWidth: Double + var delayMs: Int + var maxSpeed: Double + + init( + triggerWidth: Double = 30, + delayMs: Int = 100, + maxSpeed: Double = 1500 + ) { + self.triggerWidth = triggerWidth + self.delayMs = delayMs + self.maxSpeed = maxSpeed + } } struct NiriEdgeWorkspaceSwitchSettings: Codable, Equatable { - var triggerHeight: Double - var delayMs: Int - var maxSpeed: Double - - init( - triggerHeight: Double = 50, - delayMs: Int = 100, - maxSpeed: Double = 1500 - ) { - self.triggerHeight = triggerHeight - self.delayMs = delayMs - self.maxSpeed = maxSpeed - } + var triggerHeight: Double + var delayMs: Int + var maxSpeed: Double + + init( + triggerHeight: Double = 50, + delayMs: Int = 100, + maxSpeed: Double = 1500 + ) { + self.triggerHeight = triggerHeight + self.delayMs = delayMs + self.maxSpeed = maxSpeed + } } struct NiriGestureSettings: Codable, Equatable { - var decisionThresholdPx: Double - var swipeHistoryMs: Int - var decelerationTouchpad: Double - var snapVelocityThresholdPxPerSec: Double - var horizontalSpringStiffness: Double - var horizontalSpringDamping: Double - var verticalSpringStiffness: Double - var verticalSpringDamping: Double - - init( - decisionThresholdPx: Double = 16, - swipeHistoryMs: Int = 150, - decelerationTouchpad: Double = 0.997, - snapVelocityThresholdPxPerSec: Double = 900, - horizontalSpringStiffness: Double = 800, - horizontalSpringDamping: Double = 1.0, - verticalSpringStiffness: Double = 1000, - verticalSpringDamping: Double = 1.0 - ) { - self.decisionThresholdPx = decisionThresholdPx - self.swipeHistoryMs = swipeHistoryMs - self.decelerationTouchpad = decelerationTouchpad - self.snapVelocityThresholdPxPerSec = snapVelocityThresholdPxPerSec - self.horizontalSpringStiffness = horizontalSpringStiffness - self.horizontalSpringDamping = horizontalSpringDamping - self.verticalSpringStiffness = verticalSpringStiffness - self.verticalSpringDamping = verticalSpringDamping - } - - private enum CodingKeys: String, CodingKey { - case decisionThresholdPx - case swipeHistoryMs - case decelerationTouchpad - case snapVelocityThresholdPxPerSec - case horizontalSpringStiffness - case horizontalSpringDamping - case verticalSpringStiffness - case verticalSpringDamping - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - decisionThresholdPx = try container.decodeIfPresent(Double.self, forKey: .decisionThresholdPx) ?? 16 - swipeHistoryMs = try container.decodeIfPresent(Int.self, forKey: .swipeHistoryMs) ?? 150 - decelerationTouchpad = try container.decodeIfPresent(Double.self, forKey: .decelerationTouchpad) ?? 0.997 - snapVelocityThresholdPxPerSec = try container.decodeIfPresent(Double.self, forKey: .snapVelocityThresholdPxPerSec) ?? 900 - horizontalSpringStiffness = try container.decodeIfPresent(Double.self, forKey: .horizontalSpringStiffness) ?? 800 - horizontalSpringDamping = try container.decodeIfPresent(Double.self, forKey: .horizontalSpringDamping) ?? 1.0 - verticalSpringStiffness = try container.decodeIfPresent(Double.self, forKey: .verticalSpringStiffness) ?? 1000 - verticalSpringDamping = try container.decodeIfPresent(Double.self, forKey: .verticalSpringDamping) ?? 1.0 - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(decisionThresholdPx, forKey: .decisionThresholdPx) - try container.encode(swipeHistoryMs, forKey: .swipeHistoryMs) - try container.encode(decelerationTouchpad, forKey: .decelerationTouchpad) - try container.encode(snapVelocityThresholdPxPerSec, forKey: .snapVelocityThresholdPxPerSec) - try container.encode(horizontalSpringStiffness, forKey: .horizontalSpringStiffness) - try container.encode(horizontalSpringDamping, forKey: .horizontalSpringDamping) - try container.encode(verticalSpringStiffness, forKey: .verticalSpringStiffness) - try container.encode(verticalSpringDamping, forKey: .verticalSpringDamping) - } + var decisionThresholdPx: Double + var swipeHistoryMs: Int + var decelerationTouchpad: Double + var snapVelocityThresholdPxPerSec: Double + var horizontalSpringStiffness: Double + var horizontalSpringDamping: Double + var verticalSpringStiffness: Double + var verticalSpringDamping: Double + + init( + decisionThresholdPx: Double = 16, + swipeHistoryMs: Int = 150, + decelerationTouchpad: Double = 0.997, + snapVelocityThresholdPxPerSec: Double = 900, + horizontalSpringStiffness: Double = 800, + horizontalSpringDamping: Double = 1.0, + verticalSpringStiffness: Double = 1000, + verticalSpringDamping: Double = 1.0 + ) { + self.decisionThresholdPx = decisionThresholdPx + self.swipeHistoryMs = swipeHistoryMs + self.decelerationTouchpad = decelerationTouchpad + self.snapVelocityThresholdPxPerSec = snapVelocityThresholdPxPerSec + self.horizontalSpringStiffness = horizontalSpringStiffness + self.horizontalSpringDamping = horizontalSpringDamping + self.verticalSpringStiffness = verticalSpringStiffness + self.verticalSpringDamping = verticalSpringDamping + } + + private enum CodingKeys: String, CodingKey { + case decisionThresholdPx + case swipeHistoryMs + case decelerationTouchpad + case snapVelocityThresholdPxPerSec + case horizontalSpringStiffness + case horizontalSpringDamping + case verticalSpringStiffness + case verticalSpringDamping + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + decisionThresholdPx = try container.decodeIfPresent(Double.self, forKey: .decisionThresholdPx) ?? 16 + swipeHistoryMs = try container.decodeIfPresent(Int.self, forKey: .swipeHistoryMs) ?? 150 + decelerationTouchpad = try container.decodeIfPresent(Double.self, forKey: .decelerationTouchpad) ?? 0.997 + snapVelocityThresholdPxPerSec = try container.decodeIfPresent(Double.self, forKey: .snapVelocityThresholdPxPerSec) ?? 900 + horizontalSpringStiffness = try container.decodeIfPresent(Double.self, forKey: .horizontalSpringStiffness) ?? 800 + horizontalSpringDamping = try container.decodeIfPresent(Double.self, forKey: .horizontalSpringDamping) ?? 1.0 + verticalSpringStiffness = try container.decodeIfPresent(Double.self, forKey: .verticalSpringStiffness) ?? 1000 + verticalSpringDamping = try container.decodeIfPresent(Double.self, forKey: .verticalSpringDamping) ?? 1.0 + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(decisionThresholdPx, forKey: .decisionThresholdPx) + try container.encode(swipeHistoryMs, forKey: .swipeHistoryMs) + try container.encode(decelerationTouchpad, forKey: .decelerationTouchpad) + try container.encode(snapVelocityThresholdPxPerSec, forKey: .snapVelocityThresholdPxPerSec) + try container.encode(horizontalSpringStiffness, forKey: .horizontalSpringStiffness) + try container.encode(horizontalSpringDamping, forKey: .horizontalSpringDamping) + try container.encode(verticalSpringStiffness, forKey: .verticalSpringStiffness) + try container.encode(verticalSpringDamping, forKey: .verticalSpringDamping) + } } struct NiriSettings: Codable, Equatable { - var snapEnabled: Bool - var resizeCameraVisualizerEnabled: Bool - var gestures: NiriGestureSettings - var edgeViewScroll: NiriEdgeViewScrollSettings - var edgeWorkspaceSwitch: NiriEdgeWorkspaceSwitchSettings - var hotCorners: [NiriHotCorner] - var defaultColumnDisplayMode: NiriColumnDisplayMode - var defaultNewColumnWidth: Double? - var defaultNewTileHeight: Double? - - init( - snapEnabled: Bool = true, - resizeCameraVisualizerEnabled: Bool = true, - gestures: NiriGestureSettings = NiriGestureSettings(), - edgeViewScroll: NiriEdgeViewScrollSettings = NiriEdgeViewScrollSettings(), - edgeWorkspaceSwitch: NiriEdgeWorkspaceSwitchSettings = NiriEdgeWorkspaceSwitchSettings(), - hotCorners: [NiriHotCorner] = [.topLeft], - defaultColumnDisplayMode: NiriColumnDisplayMode = .normal, - defaultNewColumnWidth: Double? = nil, - defaultNewTileHeight: Double? = nil - ) { - self.snapEnabled = snapEnabled - self.resizeCameraVisualizerEnabled = resizeCameraVisualizerEnabled - self.gestures = gestures - self.edgeViewScroll = edgeViewScroll - self.edgeWorkspaceSwitch = edgeWorkspaceSwitch - self.hotCorners = hotCorners - self.defaultColumnDisplayMode = defaultColumnDisplayMode - self.defaultNewColumnWidth = defaultNewColumnWidth - self.defaultNewTileHeight = defaultNewTileHeight - } - - private enum CodingKeys: String, CodingKey { - case snapEnabled - case resizeCameraVisualizerEnabled - case gestures - case edgeViewScroll - case edgeWorkspaceSwitch - case hotCorners - case defaultColumnDisplayMode - case defaultNewColumnWidth - case defaultNewTileHeight - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - snapEnabled = try container.decodeIfPresent(Bool.self, forKey: .snapEnabled) ?? true - resizeCameraVisualizerEnabled = try container.decodeIfPresent(Bool.self, forKey: .resizeCameraVisualizerEnabled) ?? true - gestures = try container.decodeIfPresent(NiriGestureSettings.self, forKey: .gestures) ?? NiriGestureSettings() - edgeViewScroll = try container.decodeIfPresent(NiriEdgeViewScrollSettings.self, forKey: .edgeViewScroll) ?? NiriEdgeViewScrollSettings() - edgeWorkspaceSwitch = try container.decodeIfPresent(NiriEdgeWorkspaceSwitchSettings.self, forKey: .edgeWorkspaceSwitch) ?? NiriEdgeWorkspaceSwitchSettings() - hotCorners = try container.decodeIfPresent([NiriHotCorner].self, forKey: .hotCorners) ?? [.topLeft] - defaultColumnDisplayMode = try container.decodeIfPresent(NiriColumnDisplayMode.self, forKey: .defaultColumnDisplayMode) ?? .normal - defaultNewColumnWidth = Self.clampWidth( - try container.decodeIfPresent(Double.self, forKey: .defaultNewColumnWidth) - ) - defaultNewTileHeight = Self.clampHeight( - try container.decodeIfPresent(Double.self, forKey: .defaultNewTileHeight) - ) - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(snapEnabled, forKey: .snapEnabled) - try container.encode(resizeCameraVisualizerEnabled, forKey: .resizeCameraVisualizerEnabled) - try container.encode(gestures, forKey: .gestures) - try container.encode(edgeViewScroll, forKey: .edgeViewScroll) - try container.encode(edgeWorkspaceSwitch, forKey: .edgeWorkspaceSwitch) - try container.encode(hotCorners, forKey: .hotCorners) - try container.encode(defaultColumnDisplayMode, forKey: .defaultColumnDisplayMode) - try container.encodeIfPresent(Self.clampWidth(defaultNewColumnWidth), forKey: .defaultNewColumnWidth) - try container.encodeIfPresent(Self.clampHeight(defaultNewTileHeight), forKey: .defaultNewTileHeight) - } - - private static func clampWidth(_ value: Double?) -> Double? { - guard let value else { return nil } - return max(180, min(value, 2400)) - } - - private static func clampHeight(_ value: Double?) -> Double? { - guard let value else { return nil } - return max(120, min(value, 2400)) - } + var snapEnabled: Bool + var resizeCameraVisualizerEnabled: Bool + var gestures: NiriGestureSettings + var edgeViewScroll: NiriEdgeViewScrollSettings + var edgeWorkspaceSwitch: NiriEdgeWorkspaceSwitchSettings + var hotCorners: [NiriHotCorner] + var defaultColumnDisplayMode: NiriColumnDisplayMode + var defaultNewColumnWidth: Double? + var defaultNewTileHeight: Double? + + init( + snapEnabled: Bool = true, + resizeCameraVisualizerEnabled: Bool = true, + gestures: NiriGestureSettings = NiriGestureSettings(), + edgeViewScroll: NiriEdgeViewScrollSettings = NiriEdgeViewScrollSettings(), + edgeWorkspaceSwitch: NiriEdgeWorkspaceSwitchSettings = NiriEdgeWorkspaceSwitchSettings(), + hotCorners: [NiriHotCorner] = [.topLeft], + defaultColumnDisplayMode: NiriColumnDisplayMode = .normal, + defaultNewColumnWidth: Double? = nil, + defaultNewTileHeight: Double? = nil + ) { + self.snapEnabled = snapEnabled + self.resizeCameraVisualizerEnabled = resizeCameraVisualizerEnabled + self.gestures = gestures + self.edgeViewScroll = edgeViewScroll + self.edgeWorkspaceSwitch = edgeWorkspaceSwitch + self.hotCorners = hotCorners + self.defaultColumnDisplayMode = defaultColumnDisplayMode + self.defaultNewColumnWidth = defaultNewColumnWidth + self.defaultNewTileHeight = defaultNewTileHeight + } + + private enum CodingKeys: String, CodingKey { + case snapEnabled + case resizeCameraVisualizerEnabled + case gestures + case edgeViewScroll + case edgeWorkspaceSwitch + case hotCorners + case defaultColumnDisplayMode + case defaultNewColumnWidth + case defaultNewTileHeight + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + snapEnabled = try container.decodeIfPresent(Bool.self, forKey: .snapEnabled) ?? true + resizeCameraVisualizerEnabled = try container.decodeIfPresent(Bool.self, forKey: .resizeCameraVisualizerEnabled) ?? true + gestures = try container.decodeIfPresent(NiriGestureSettings.self, forKey: .gestures) ?? NiriGestureSettings() + edgeViewScroll = try container.decodeIfPresent(NiriEdgeViewScrollSettings.self, forKey: .edgeViewScroll) ?? NiriEdgeViewScrollSettings() + edgeWorkspaceSwitch = try container.decodeIfPresent(NiriEdgeWorkspaceSwitchSettings.self, forKey: .edgeWorkspaceSwitch) ?? NiriEdgeWorkspaceSwitchSettings() + hotCorners = try container.decodeIfPresent([NiriHotCorner].self, forKey: .hotCorners) ?? [.topLeft] + defaultColumnDisplayMode = try container.decodeIfPresent(NiriColumnDisplayMode.self, forKey: .defaultColumnDisplayMode) ?? .normal + defaultNewColumnWidth = try Self.clampWidth( + container.decodeIfPresent(Double.self, forKey: .defaultNewColumnWidth) + ) + defaultNewTileHeight = try Self.clampHeight( + container.decodeIfPresent(Double.self, forKey: .defaultNewTileHeight) + ) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(snapEnabled, forKey: .snapEnabled) + try container.encode(resizeCameraVisualizerEnabled, forKey: .resizeCameraVisualizerEnabled) + try container.encode(gestures, forKey: .gestures) + try container.encode(edgeViewScroll, forKey: .edgeViewScroll) + try container.encode(edgeWorkspaceSwitch, forKey: .edgeWorkspaceSwitch) + try container.encode(hotCorners, forKey: .hotCorners) + try container.encode(defaultColumnDisplayMode, forKey: .defaultColumnDisplayMode) + try container.encodeIfPresent(Self.clampWidth(defaultNewColumnWidth), forKey: .defaultNewColumnWidth) + try container.encodeIfPresent(Self.clampHeight(defaultNewTileHeight), forKey: .defaultNewTileHeight) + } + + private static func clampWidth(_ value: Double?) -> Double? { + guard let value else { return nil } + return max(180, min(value, 2400)) + } + + private static func clampHeight(_ value: Double?) -> Double? { + guard let value else { return nil } + return max(120, min(value, 2400)) + } } struct AppSettings: Codable, Equatable { - static let schemaVersion = 8 - - var schemaVersion: Int - var sidebarVisible: Bool - var inboxVisible: Bool - var defaultCreateWorktreeForRepoSessions: Bool - var preferredShellPath: String? - var terminalStartupCommandTemplate: String? - var hasSeenFirstRun: Bool - var hasSeenNiriOnboarding: Bool - var defaultSandboxProfile: SandboxProfile - var defaultNetworkPolicy: NetworkPolicy - var externalLinkRouting: ExternalLinkRouting - var browserSplitDefaultSide: SplitSide - var restoreBehavior: RestoreBehavior - var cleanupOnClose: Bool - var newSessionBehavior: NewSessionBehavior - var defaultVibeToolID: String? - var autoLaunchDefaultVibeToolOnCmdN: Bool - var appMode: AppMode - var niriCanvasEnabled: Bool - var niri: NiriSettings - var keybindingMode: KeybindingMode - var modKeySetting: ModKeySetting - var customKeybindings: [String: KeyChord] - var workflowRailWidth: Double - var terminalThemeID: String? - var autoCheckForUpdates: Bool - - init( - schemaVersion: Int = AppSettings.schemaVersion, - sidebarVisible: Bool = true, - inboxVisible: Bool = false, - defaultCreateWorktreeForRepoSessions: Bool = true, - preferredShellPath: String? = nil, - terminalStartupCommandTemplate: String? = nil, - hasSeenFirstRun: Bool = false, - hasSeenNiriOnboarding: Bool = false, - defaultSandboxProfile: SandboxProfile = .fullAccess, - defaultNetworkPolicy: NetworkPolicy = .inherited, - externalLinkRouting: ExternalLinkRouting = .defaultBrowser, - browserSplitDefaultSide: SplitSide = .right, - restoreBehavior: RestoreBehavior = .relaunchAllSessions, - cleanupOnClose: Bool = false, - newSessionBehavior: NewSessionBehavior = .quick, - defaultVibeToolID: String? = nil, - autoLaunchDefaultVibeToolOnCmdN: Bool = true, - appMode: AppMode = .hybrid, - niriCanvasEnabled: Bool = true, - niri: NiriSettings = NiriSettings(), - keybindingMode: KeybindingMode = .both, - modKeySetting: ModKeySetting = .commandOption, - customKeybindings: [String: KeyChord] = [:], - workflowRailWidth: Double = 300, - terminalThemeID: String? = nil, - autoCheckForUpdates: Bool = true - ) { - self.schemaVersion = schemaVersion - self.sidebarVisible = sidebarVisible - self.inboxVisible = inboxVisible - self.defaultCreateWorktreeForRepoSessions = defaultCreateWorktreeForRepoSessions - self.preferredShellPath = preferredShellPath - self.terminalStartupCommandTemplate = terminalStartupCommandTemplate - self.hasSeenFirstRun = hasSeenFirstRun - self.hasSeenNiriOnboarding = hasSeenNiriOnboarding - self.defaultSandboxProfile = defaultSandboxProfile - self.defaultNetworkPolicy = defaultNetworkPolicy - self.externalLinkRouting = externalLinkRouting - self.browserSplitDefaultSide = browserSplitDefaultSide - self.restoreBehavior = restoreBehavior - self.cleanupOnClose = cleanupOnClose - self.newSessionBehavior = newSessionBehavior - self.defaultVibeToolID = defaultVibeToolID - self.autoLaunchDefaultVibeToolOnCmdN = autoLaunchDefaultVibeToolOnCmdN - self.appMode = appMode - self.niriCanvasEnabled = niriCanvasEnabled - self.niri = niri - self.keybindingMode = keybindingMode - self.modKeySetting = modKeySetting - self.customKeybindings = customKeybindings - self.workflowRailWidth = workflowRailWidth - self.terminalThemeID = terminalThemeID - self.autoCheckForUpdates = autoCheckForUpdates + static let schemaVersion = 8 + + var schemaVersion: Int + var sidebarVisible: Bool + var inboxVisible: Bool + var defaultCreateWorktreeForRepoSessions: Bool + var preferredShellPath: String? + var terminalStartupCommandTemplate: String? + var hasSeenFirstRun: Bool + var hasSeenNiriOnboarding: Bool + var defaultSandboxProfile: SandboxProfile + var defaultNetworkPolicy: NetworkPolicy + var externalLinkRouting: ExternalLinkRouting + var browserSplitDefaultSide: SplitSide + var restoreBehavior: RestoreBehavior + var cleanupOnClose: Bool + var newSessionBehavior: NewSessionBehavior + var defaultVibeToolID: String? + var autoLaunchDefaultVibeToolOnCmdN: Bool + var appMode: AppMode + var niriCanvasEnabled: Bool + var niri: NiriSettings + var keybindingMode: KeybindingMode + var modKeySetting: ModKeySetting + var customKeybindings: [String: KeyChord] + var workflowRailWidth: Double + var terminalThemeID: String? + var autoCheckForUpdates: Bool + + init( + schemaVersion: Int = AppSettings.schemaVersion, + sidebarVisible: Bool = true, + inboxVisible: Bool = false, + defaultCreateWorktreeForRepoSessions: Bool = true, + preferredShellPath: String? = nil, + terminalStartupCommandTemplate: String? = nil, + hasSeenFirstRun: Bool = false, + hasSeenNiriOnboarding: Bool = false, + defaultSandboxProfile: SandboxProfile = .fullAccess, + defaultNetworkPolicy: NetworkPolicy = .inherited, + externalLinkRouting: ExternalLinkRouting = .defaultBrowser, + browserSplitDefaultSide: SplitSide = .right, + restoreBehavior: RestoreBehavior = .relaunchAllSessions, + cleanupOnClose: Bool = false, + newSessionBehavior: NewSessionBehavior = .quick, + defaultVibeToolID: String? = nil, + autoLaunchDefaultVibeToolOnCmdN: Bool = true, + appMode: AppMode = .hybrid, + niriCanvasEnabled: Bool = true, + niri: NiriSettings = NiriSettings(), + keybindingMode: KeybindingMode = .both, + modKeySetting: ModKeySetting = .commandOption, + customKeybindings: [String: KeyChord] = [:], + workflowRailWidth: Double = 300, + terminalThemeID: String? = nil, + autoCheckForUpdates: Bool = true + ) { + self.schemaVersion = schemaVersion + self.sidebarVisible = sidebarVisible + self.inboxVisible = inboxVisible + self.defaultCreateWorktreeForRepoSessions = defaultCreateWorktreeForRepoSessions + self.preferredShellPath = preferredShellPath + self.terminalStartupCommandTemplate = terminalStartupCommandTemplate + self.hasSeenFirstRun = hasSeenFirstRun + self.hasSeenNiriOnboarding = hasSeenNiriOnboarding + self.defaultSandboxProfile = defaultSandboxProfile + self.defaultNetworkPolicy = defaultNetworkPolicy + self.externalLinkRouting = externalLinkRouting + self.browserSplitDefaultSide = browserSplitDefaultSide + self.restoreBehavior = restoreBehavior + self.cleanupOnClose = cleanupOnClose + self.newSessionBehavior = newSessionBehavior + self.defaultVibeToolID = defaultVibeToolID + self.autoLaunchDefaultVibeToolOnCmdN = autoLaunchDefaultVibeToolOnCmdN + self.appMode = appMode + self.niriCanvasEnabled = niriCanvasEnabled + self.niri = niri + self.keybindingMode = keybindingMode + self.modKeySetting = modKeySetting + self.customKeybindings = customKeybindings + self.workflowRailWidth = workflowRailWidth + self.terminalThemeID = terminalThemeID + self.autoCheckForUpdates = autoCheckForUpdates + } + + private enum CodingKeys: String, CodingKey { + case schemaVersion + case sidebarVisible + case inboxVisible + case openLinksInDefaultBrowser + case defaultCreateWorktreeForRepoSessions + case preferredShellPath + case terminalStartupCommandTemplate + case hasSeenFirstRun + case hasSeenNiriOnboarding + case defaultSandboxProfile + case defaultNetworkPolicy + case externalLinkRouting + case browserSplitDefaultSide + case restoreBehavior + case cleanupOnClose + case newSessionBehavior + case defaultVibeToolID + case autoLaunchDefaultVibeToolOnCmdN + case appMode + case niriCanvasEnabled + case niri + case keybindingMode + case modKeySetting + case customKeybindings + case workflowRailWidth + case terminalThemeID + case autoCheckForUpdates + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + schemaVersion = AppSettings.schemaVersion + sidebarVisible = try container.decodeIfPresent(Bool.self, forKey: .sidebarVisible) ?? true + inboxVisible = try container.decodeIfPresent(Bool.self, forKey: .inboxVisible) ?? false + defaultCreateWorktreeForRepoSessions = try container.decodeIfPresent(Bool.self, forKey: .defaultCreateWorktreeForRepoSessions) ?? true + preferredShellPath = try container.decodeIfPresent(String.self, forKey: .preferredShellPath) + terminalStartupCommandTemplate = try container.decodeIfPresent(String.self, forKey: .terminalStartupCommandTemplate) + hasSeenFirstRun = try container.decodeIfPresent(Bool.self, forKey: .hasSeenFirstRun) ?? false + hasSeenNiriOnboarding = try container.decodeIfPresent(Bool.self, forKey: .hasSeenNiriOnboarding) ?? false + defaultSandboxProfile = try container.decodeIfPresent(SandboxProfile.self, forKey: .defaultSandboxProfile) ?? .fullAccess + defaultNetworkPolicy = try container.decodeIfPresent(NetworkPolicy.self, forKey: .defaultNetworkPolicy) ?? .inherited + + if let routing = try container.decodeIfPresent(ExternalLinkRouting.self, forKey: .externalLinkRouting) { + externalLinkRouting = routing + } else { + let openLinks = try container.decodeIfPresent(Bool.self, forKey: .openLinksInDefaultBrowser) ?? true + externalLinkRouting = openLinks ? .defaultBrowser : .embeddedBrowser } - private enum CodingKeys: String, CodingKey { - case schemaVersion - case sidebarVisible - case inboxVisible - case openLinksInDefaultBrowser - case defaultCreateWorktreeForRepoSessions - case preferredShellPath - case terminalStartupCommandTemplate - case hasSeenFirstRun - case hasSeenNiriOnboarding - case defaultSandboxProfile - case defaultNetworkPolicy - case externalLinkRouting - case browserSplitDefaultSide - case restoreBehavior - case cleanupOnClose - case newSessionBehavior - case defaultVibeToolID - case autoLaunchDefaultVibeToolOnCmdN - case appMode - case niriCanvasEnabled - case niri - case keybindingMode - case modKeySetting - case customKeybindings - case workflowRailWidth - case terminalThemeID - case autoCheckForUpdates - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - schemaVersion = AppSettings.schemaVersion - sidebarVisible = try container.decodeIfPresent(Bool.self, forKey: .sidebarVisible) ?? true - inboxVisible = try container.decodeIfPresent(Bool.self, forKey: .inboxVisible) ?? false - defaultCreateWorktreeForRepoSessions = try container.decodeIfPresent(Bool.self, forKey: .defaultCreateWorktreeForRepoSessions) ?? true - preferredShellPath = try container.decodeIfPresent(String.self, forKey: .preferredShellPath) - terminalStartupCommandTemplate = try container.decodeIfPresent(String.self, forKey: .terminalStartupCommandTemplate) - hasSeenFirstRun = try container.decodeIfPresent(Bool.self, forKey: .hasSeenFirstRun) ?? false - hasSeenNiriOnboarding = try container.decodeIfPresent(Bool.self, forKey: .hasSeenNiriOnboarding) ?? false - defaultSandboxProfile = try container.decodeIfPresent(SandboxProfile.self, forKey: .defaultSandboxProfile) ?? .fullAccess - defaultNetworkPolicy = try container.decodeIfPresent(NetworkPolicy.self, forKey: .defaultNetworkPolicy) ?? .inherited - - if let routing = try container.decodeIfPresent(ExternalLinkRouting.self, forKey: .externalLinkRouting) { - externalLinkRouting = routing - } else { - let openLinks = try container.decodeIfPresent(Bool.self, forKey: .openLinksInDefaultBrowser) ?? true - externalLinkRouting = openLinks ? .defaultBrowser : .embeddedBrowser - } - - browserSplitDefaultSide = try container.decodeIfPresent(SplitSide.self, forKey: .browserSplitDefaultSide) ?? .right - restoreBehavior = try container.decodeIfPresent(RestoreBehavior.self, forKey: .restoreBehavior) ?? .relaunchAllSessions - cleanupOnClose = try container.decodeIfPresent(Bool.self, forKey: .cleanupOnClose) ?? false - newSessionBehavior = try container.decodeIfPresent(NewSessionBehavior.self, forKey: .newSessionBehavior) ?? .quick - defaultVibeToolID = try container.decodeIfPresent(String.self, forKey: .defaultVibeToolID) - autoLaunchDefaultVibeToolOnCmdN = try container.decodeIfPresent(Bool.self, forKey: .autoLaunchDefaultVibeToolOnCmdN) ?? true - appMode = try container.decodeIfPresent(AppMode.self, forKey: .appMode) ?? .hybrid - niriCanvasEnabled = try container.decodeIfPresent(Bool.self, forKey: .niriCanvasEnabled) ?? true - niri = try container.decodeIfPresent(NiriSettings.self, forKey: .niri) ?? NiriSettings() - keybindingMode = try container.decodeIfPresent(KeybindingMode.self, forKey: .keybindingMode) ?? .both - modKeySetting = try container.decodeIfPresent(ModKeySetting.self, forKey: .modKeySetting) ?? .commandOption - customKeybindings = try container.decodeIfPresent([String: KeyChord].self, forKey: .customKeybindings) ?? [:] - workflowRailWidth = try container.decodeIfPresent(Double.self, forKey: .workflowRailWidth) ?? 300 - terminalThemeID = try container.decodeIfPresent(String.self, forKey: .terminalThemeID) - autoCheckForUpdates = try container.decodeIfPresent(Bool.self, forKey: .autoCheckForUpdates) ?? true - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(AppSettings.schemaVersion, forKey: .schemaVersion) - try container.encode(sidebarVisible, forKey: .sidebarVisible) - try container.encode(inboxVisible, forKey: .inboxVisible) - try container.encode(defaultCreateWorktreeForRepoSessions, forKey: .defaultCreateWorktreeForRepoSessions) - try container.encodeIfPresent(preferredShellPath, forKey: .preferredShellPath) - try container.encodeIfPresent(terminalStartupCommandTemplate, forKey: .terminalStartupCommandTemplate) - try container.encode(hasSeenFirstRun, forKey: .hasSeenFirstRun) - try container.encode(hasSeenNiriOnboarding, forKey: .hasSeenNiriOnboarding) - try container.encode(defaultSandboxProfile, forKey: .defaultSandboxProfile) - try container.encode(defaultNetworkPolicy, forKey: .defaultNetworkPolicy) - try container.encode(externalLinkRouting, forKey: .externalLinkRouting) - try container.encode(browserSplitDefaultSide, forKey: .browserSplitDefaultSide) - try container.encode(restoreBehavior, forKey: .restoreBehavior) - try container.encode(cleanupOnClose, forKey: .cleanupOnClose) - try container.encode(newSessionBehavior, forKey: .newSessionBehavior) - try container.encodeIfPresent(defaultVibeToolID, forKey: .defaultVibeToolID) - try container.encode(autoLaunchDefaultVibeToolOnCmdN, forKey: .autoLaunchDefaultVibeToolOnCmdN) - try container.encode(appMode, forKey: .appMode) - try container.encode(niriCanvasEnabled, forKey: .niriCanvasEnabled) - try container.encode(niri, forKey: .niri) - try container.encode(keybindingMode, forKey: .keybindingMode) - try container.encode(modKeySetting, forKey: .modKeySetting) - try container.encode(customKeybindings, forKey: .customKeybindings) - try container.encode(workflowRailWidth, forKey: .workflowRailWidth) - try container.encodeIfPresent(terminalThemeID, forKey: .terminalThemeID) - try container.encode(autoCheckForUpdates, forKey: .autoCheckForUpdates) - // Keep legacy key populated to avoid older dev builds misreading link behavior. - try container.encode(openLinksInDefaultBrowser, forKey: .openLinksInDefaultBrowser) - } - - var openLinksInDefaultBrowser: Bool { - get { externalLinkRouting == .defaultBrowser } - set { externalLinkRouting = newValue ? .defaultBrowser : .embeddedBrowser } - } + browserSplitDefaultSide = try container.decodeIfPresent(SplitSide.self, forKey: .browserSplitDefaultSide) ?? .right + restoreBehavior = try container.decodeIfPresent(RestoreBehavior.self, forKey: .restoreBehavior) ?? .relaunchAllSessions + cleanupOnClose = try container.decodeIfPresent(Bool.self, forKey: .cleanupOnClose) ?? false + newSessionBehavior = try container.decodeIfPresent(NewSessionBehavior.self, forKey: .newSessionBehavior) ?? .quick + defaultVibeToolID = try container.decodeIfPresent(String.self, forKey: .defaultVibeToolID) + autoLaunchDefaultVibeToolOnCmdN = try container.decodeIfPresent(Bool.self, forKey: .autoLaunchDefaultVibeToolOnCmdN) ?? true + appMode = try container.decodeIfPresent(AppMode.self, forKey: .appMode) ?? .hybrid + niriCanvasEnabled = try container.decodeIfPresent(Bool.self, forKey: .niriCanvasEnabled) ?? true + niri = try container.decodeIfPresent(NiriSettings.self, forKey: .niri) ?? NiriSettings() + keybindingMode = try container.decodeIfPresent(KeybindingMode.self, forKey: .keybindingMode) ?? .both + modKeySetting = try container.decodeIfPresent(ModKeySetting.self, forKey: .modKeySetting) ?? .commandOption + customKeybindings = try container.decodeIfPresent([String: KeyChord].self, forKey: .customKeybindings) ?? [:] + workflowRailWidth = try container.decodeIfPresent(Double.self, forKey: .workflowRailWidth) ?? 300 + terminalThemeID = try container.decodeIfPresent(String.self, forKey: .terminalThemeID) + autoCheckForUpdates = try container.decodeIfPresent(Bool.self, forKey: .autoCheckForUpdates) ?? true + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(AppSettings.schemaVersion, forKey: .schemaVersion) + try container.encode(sidebarVisible, forKey: .sidebarVisible) + try container.encode(inboxVisible, forKey: .inboxVisible) + try container.encode(defaultCreateWorktreeForRepoSessions, forKey: .defaultCreateWorktreeForRepoSessions) + try container.encodeIfPresent(preferredShellPath, forKey: .preferredShellPath) + try container.encodeIfPresent(terminalStartupCommandTemplate, forKey: .terminalStartupCommandTemplate) + try container.encode(hasSeenFirstRun, forKey: .hasSeenFirstRun) + try container.encode(hasSeenNiriOnboarding, forKey: .hasSeenNiriOnboarding) + try container.encode(defaultSandboxProfile, forKey: .defaultSandboxProfile) + try container.encode(defaultNetworkPolicy, forKey: .defaultNetworkPolicy) + try container.encode(externalLinkRouting, forKey: .externalLinkRouting) + try container.encode(browserSplitDefaultSide, forKey: .browserSplitDefaultSide) + try container.encode(restoreBehavior, forKey: .restoreBehavior) + try container.encode(cleanupOnClose, forKey: .cleanupOnClose) + try container.encode(newSessionBehavior, forKey: .newSessionBehavior) + try container.encodeIfPresent(defaultVibeToolID, forKey: .defaultVibeToolID) + try container.encode(autoLaunchDefaultVibeToolOnCmdN, forKey: .autoLaunchDefaultVibeToolOnCmdN) + try container.encode(appMode, forKey: .appMode) + try container.encode(niriCanvasEnabled, forKey: .niriCanvasEnabled) + try container.encode(niri, forKey: .niri) + try container.encode(keybindingMode, forKey: .keybindingMode) + try container.encode(modKeySetting, forKey: .modKeySetting) + try container.encode(customKeybindings, forKey: .customKeybindings) + try container.encode(workflowRailWidth, forKey: .workflowRailWidth) + try container.encodeIfPresent(terminalThemeID, forKey: .terminalThemeID) + try container.encode(autoCheckForUpdates, forKey: .autoCheckForUpdates) + // Keep legacy key populated to avoid older dev builds misreading link behavior. + try container.encode(openLinksInDefaultBrowser, forKey: .openLinksInDefaultBrowser) + } + + var openLinksInDefaultBrowser: Bool { + get { externalLinkRouting == .defaultBrowser } + set { externalLinkRouting = newValue ? .defaultBrowser : .embeddedBrowser } + } } diff --git a/idx0/Keyboard/ShortcutActionID.swift b/idx0/Keyboard/ShortcutActionID.swift index 11ff0ad..7ca4bd7 100644 --- a/idx0/Keyboard/ShortcutActionID.swift +++ b/idx0/Keyboard/ShortcutActionID.swift @@ -1,104 +1,104 @@ import Foundation enum ShortcutActionID: String, Codable, CaseIterable, Hashable { - // Sessions - case newSession - case newQuickSession - case newRepoWorktreeSession - case newWorktreeSession - case quickSwitchSession - case focusNextSession - case focusPreviousSession - case renameSession - case closeSession - case relaunchSession - case commandPalette - case keyboardShortcuts - case openSettings - case checkForUpdates + // Sessions + case newSession + case newQuickSession + case newRepoWorktreeSession + case newWorktreeSession + case quickSwitchSession + case focusNextSession + case focusPreviousSession + case renameSession + case closeSession + case relaunchSession + case commandPalette + case keyboardShortcuts + case openSettings + case checkForUpdates - // Navigation - case toggleSidebar - case toggleWorkflowRail - case toggleFocusMode - case focusNextQueueItem - case showDiff - case showCheckpoints - case openClipboardURL + // Navigation + case toggleSidebar + case toggleWorkflowRail + case toggleFocusMode + case focusNextQueueItem + case showDiff + case showCheckpoints + case openClipboardURL - // Tabs and panes - case newTab - case nextTab - case previousTab - case closeTab - case splitRight - case splitDown - case closePane - case nextPane - case previousPane - case toggleBrowserSplit + // Tabs and panes + case newTab + case nextTab + case previousTab + case closeTab + case splitRight + case splitDown + case closePane + case nextPane + case previousPane + case toggleBrowserSplit - // Niri - case niriAddTerminalRight - case niriAddTaskBelow - case niriAddBrowserTile - case niriOpenAddTileMenu - case niriFocusLeft - case niriFocusDown - case niriFocusUp - case niriFocusRight - case niriToggleOverview - case niriConfirmSelection - case niriToggleColumnTabbedDisplay - case niriToggleSnap - case niriFocusWorkspaceUp - case niriFocusWorkspaceDown - case niriMoveColumnToWorkspaceUp - case niriMoveColumnToWorkspaceDown - case niriToggleFocusedTileZoom - case niriZoomInFocusedWebTile - case niriZoomOutFocusedWebTile + // Niri + case niriAddTerminalRight + case niriAddTaskBelow + case niriAddBrowserTile + case niriOpenAddTileMenu + case niriFocusLeft + case niriFocusDown + case niriFocusUp + case niriFocusRight + case niriToggleOverview + case niriConfirmSelection + case niriToggleColumnTabbedDisplay + case niriToggleSnap + case niriFocusWorkspaceUp + case niriFocusWorkspaceDown + case niriMoveColumnToWorkspaceUp + case niriMoveColumnToWorkspaceDown + case niriToggleFocusedTileZoom + case niriZoomInFocusedWebTile + case niriZoomOutFocusedWebTile - // Workflow - case quickApprove + /// Workflow + case quickApprove } enum ShortcutSection: String, CaseIterable { - case sessions - case navigation - case panes - case niri - case workflow + case sessions + case navigation + case panes + case niri + case workflow - var title: String { - switch self { - case .sessions: - return "Sessions" - case .navigation: - return "Navigation" - case .panes: - return "Panes" - case .niri: - return "Niri Canvas" - case .workflow: - return "Workflow" - } + var title: String { + switch self { + case .sessions: + "Sessions" + case .navigation: + "Navigation" + case .panes: + "Panes" + case .niri: + "Niri Canvas" + case .workflow: + "Workflow" } + } } enum NiriShortcutCompatibility: String, CaseIterable { - case exact - case adapted - case unsupported + case exact + case adapted + case unsupported - var displayLabel: String { - switch self { - case .exact: - return "exact" - case .adapted: - return "adapted" - case .unsupported: - return "unsupported" - } + var displayLabel: String { + switch self { + case .exact: + "exact" + case .adapted: + "adapted" + case .unsupported: + "unsupported" } + } } diff --git a/idx0/Keyboard/ShortcutRegistry.swift b/idx0/Keyboard/ShortcutRegistry.swift index e2151d5..c6a0cf2 100644 --- a/idx0/Keyboard/ShortcutRegistry.swift +++ b/idx0/Keyboard/ShortcutRegistry.swift @@ -1,727 +1,729 @@ import Foundation struct ShortcutBindingTemplate: Hashable { - let key: ShortcutKey - let fixedModifiers: Set - let includesModKey: Bool + let key: ShortcutKey + let fixedModifiers: Set + let includesModKey: Bool - init( - key: ShortcutKey, - fixedModifiers: Set = [], - includesModKey: Bool = false - ) { - self.key = key - self.fixedModifiers = fixedModifiers - self.includesModKey = includesModKey - } + init( + key: ShortcutKey, + fixedModifiers: Set = [], + includesModKey: Bool = false + ) { + self.key = key + self.fixedModifiers = fixedModifiers + self.includesModKey = includesModKey + } - func resolved(modSetting: ModKeySetting) -> KeyChord { - var modifiers = fixedModifiers - if includesModKey { - modifiers.formUnion(modSetting.modifiers) - } - return KeyChord(key: key, modifiers: modifiers) + func resolved(modSetting: ModKeySetting) -> KeyChord { + var modifiers = fixedModifiers + if includesModKey { + modifiers.formUnion(modSetting.modifiers) } + return KeyChord(key: key, modifiers: modifiers) + } } struct ShortcutDescriptor: Identifiable, Hashable { - let id: ShortcutActionID - let title: String - let detail: String - let section: ShortcutSection - let niriActionName: String? - let niriCompatibility: NiriShortcutCompatibility - let remappable: Bool - let macBindings: [ShortcutBindingTemplate] - let niriBindings: [ShortcutBindingTemplate] + let id: ShortcutActionID + let title: String + let detail: String + let section: ShortcutSection + let niriActionName: String? + let niriCompatibility: NiriShortcutCompatibility + let remappable: Bool + let macBindings: [ShortcutBindingTemplate] + let niriBindings: [ShortcutBindingTemplate] - var isNiriOnly: Bool { - section == .niri - } + var isNiriOnly: Bool { + section == .niri + } } struct ShortcutRegistry { - static let shared = ShortcutRegistry() + static let shared = ShortcutRegistry() - private(set) var descriptors: [ShortcutDescriptor] - private let descriptorByID: [ShortcutActionID: ShortcutDescriptor] + private(set) var descriptors: [ShortcutDescriptor] + private let descriptorByID: [ShortcutActionID: ShortcutDescriptor] - init(descriptors: [ShortcutDescriptor] = ShortcutRegistry.defaultDescriptors) { - self.descriptors = descriptors - self.descriptorByID = Dictionary(uniqueKeysWithValues: descriptors.map { ($0.id, $0) }) - } + init(descriptors: [ShortcutDescriptor] = ShortcutRegistry.defaultDescriptors) { + self.descriptors = descriptors + descriptorByID = Dictionary(uniqueKeysWithValues: descriptors.map { ($0.id, $0) }) + } - func descriptor(for action: ShortcutActionID) -> ShortcutDescriptor? { - descriptorByID[action] - } + func descriptor(for action: ShortcutActionID) -> ShortcutDescriptor? { + descriptorByID[action] + } - func descriptors(in section: ShortcutSection) -> [ShortcutDescriptor] { - descriptors.filter { $0.section == section } - } + func descriptors(in section: ShortcutSection) -> [ShortcutDescriptor] { + descriptors.filter { $0.section == section } + } + + func customBinding(for action: ShortcutActionID, settings: AppSettings) -> KeyChord? { + settings.customKeybindings[action.rawValue] + } - func customBinding(for action: ShortcutActionID, settings: AppSettings) -> KeyChord? { - settings.customKeybindings[action.rawValue] + func resetBindingsForMode(_ mode: KeybindingMode, modSetting: ModKeySetting) -> [String: KeyChord] { + var map: [String: KeyChord] = [:] + for descriptor in descriptors where descriptor.remappable { + let bindings = baseBindings(for: descriptor, mode: mode, modSetting: modSetting) + if let first = primaryBinding(from: bindings, fallback: []) { + map[descriptor.id.rawValue] = first + } } + return map + } - func resetBindingsForMode(_ mode: KeybindingMode, modSetting: ModKeySetting) -> [String: KeyChord] { - var map: [String: KeyChord] = [:] - for descriptor in descriptors where descriptor.remappable { - let bindings = baseBindings(for: descriptor, mode: mode, modSetting: modSetting) - if let first = primaryBinding(from: bindings, fallback: []) { - map[descriptor.id.rawValue] = first - } - } - return map + func activeBindings(for action: ShortcutActionID, settings: AppSettings) -> [KeyChord] { + guard let descriptor = descriptorByID[action] else { + return [] } - func activeBindings(for action: ShortcutActionID, settings: AppSettings) -> [KeyChord] { - guard let descriptor = descriptorByID[action] else { - return [] - } + if settings.keybindingMode == .custom, + let custom = customBinding(for: action, settings: settings) + { + return [custom] + } - if settings.keybindingMode == .custom, - let custom = customBinding(for: action, settings: settings) { - return [custom] - } + return baseBindings( + for: descriptor, + mode: settings.keybindingMode, + modSetting: settings.modKeySetting + ) + } - return baseBindings( - for: descriptor, - mode: settings.keybindingMode, - modSetting: settings.modKeySetting - ) + func primaryBinding(for action: ShortcutActionID, settings: AppSettings) -> KeyChord? { + guard let descriptor = descriptorByID[action] else { + return nil } - func primaryBinding(for action: ShortcutActionID, settings: AppSettings) -> KeyChord? { - guard let descriptor = descriptorByID[action] else { - return nil - } - - if settings.keybindingMode == .custom, - let custom = customBinding(for: action, settings: settings) { - return custom - } + if settings.keybindingMode == .custom, + let custom = customBinding(for: action, settings: settings) + { + return custom + } - let macBindings = resolveBindings(descriptor.macBindings, modSetting: settings.modKeySetting) - let niriBindings = resolveBindings(descriptor.niriBindings, modSetting: settings.modKeySetting) + let macBindings = resolveBindings(descriptor.macBindings, modSetting: settings.modKeySetting) + let niriBindings = resolveBindings(descriptor.niriBindings, modSetting: settings.modKeySetting) - switch settings.keybindingMode { - case .both, .macOSFirst, .custom: - return primaryBinding(from: macBindings, fallback: niriBindings) - case .niriFirst: - return primaryBinding(from: niriBindings, fallback: macBindings) - } + switch settings.keybindingMode { + case .both, .macOSFirst, .custom: + return primaryBinding(from: macBindings, fallback: niriBindings) + case .niriFirst: + return primaryBinding(from: niriBindings, fallback: macBindings) } + } - func displayLabel(for action: ShortcutActionID, settings: AppSettings) -> String? { - primaryBinding(for: action, settings: settings)?.displayString - } + func displayLabel(for action: ShortcutActionID, settings: AppSettings) -> String? { + primaryBinding(for: action, settings: settings)?.displayString + } - private func primaryBinding(from primary: [KeyChord], fallback: [KeyChord]) -> KeyChord? { - primary.first ?? fallback.first - } + private func primaryBinding(from primary: [KeyChord], fallback: [KeyChord]) -> KeyChord? { + primary.first ?? fallback.first + } - private func baseBindings(for descriptor: ShortcutDescriptor, mode: KeybindingMode, modSetting: ModKeySetting) -> [KeyChord] { - let macBindings = resolveBindings(descriptor.macBindings, modSetting: modSetting) - let niriBindings = resolveBindings(descriptor.niriBindings, modSetting: modSetting) + private func baseBindings(for descriptor: ShortcutDescriptor, mode: KeybindingMode, modSetting: ModKeySetting) -> [KeyChord] { + let macBindings = resolveBindings(descriptor.macBindings, modSetting: modSetting) + let niriBindings = resolveBindings(descriptor.niriBindings, modSetting: modSetting) - switch mode { - case .both, .custom: - return dedupe(macBindings + niriBindings) - case .macOSFirst: - return macBindings.isEmpty ? niriBindings : macBindings - case .niriFirst: - return niriBindings.isEmpty ? macBindings : niriBindings - } + switch mode { + case .both, .custom: + return dedupe(macBindings + niriBindings) + case .macOSFirst: + return macBindings.isEmpty ? niriBindings : macBindings + case .niriFirst: + return niriBindings.isEmpty ? macBindings : niriBindings } + } - private func resolveBindings(_ templates: [ShortcutBindingTemplate], modSetting: ModKeySetting) -> [KeyChord] { - templates.map { $0.resolved(modSetting: modSetting) } - } + private func resolveBindings(_ templates: [ShortcutBindingTemplate], modSetting: ModKeySetting) -> [KeyChord] { + templates.map { $0.resolved(modSetting: modSetting) } + } - private func dedupe(_ chords: [KeyChord]) -> [KeyChord] { - var seen: Set = [] - var ordered: [KeyChord] = [] - for chord in chords where !seen.contains(chord) { - seen.insert(chord) - ordered.append(chord) - } - return ordered + private func dedupe(_ chords: [KeyChord]) -> [KeyChord] { + var seen: Set = [] + var ordered: [KeyChord] = [] + for chord in chords where !seen.contains(chord) { + seen.insert(chord) + ordered.append(chord) } + return ordered + } - private static let defaultDescriptors: [ShortcutDescriptor] = [ - ShortcutDescriptor( - id: .newSession, - title: "New Session (Default)", - detail: "Create a new session using the default flow", - section: .sessions, - niriActionName: nil, - niriCompatibility: .adapted, - remappable: true, - macBindings: [.mac(.n, [.command])], - niriBindings: [] - ), - ShortcutDescriptor( - id: .newQuickSession, - title: "New Quick Session", - detail: "Create an instant terminal session", - section: .sessions, - niriActionName: nil, - niriCompatibility: .adapted, - remappable: true, - macBindings: [.mac(.t, [.command])], - niriBindings: [] - ), - ShortcutDescriptor( - id: .newRepoWorktreeSession, - title: "New Repo/Worktree Session", - detail: "Open structured setup for repo or worktree", - section: .sessions, - niriActionName: nil, - niriCompatibility: .adapted, - remappable: true, - macBindings: [.mac(.n, [.command, .option])], - niriBindings: [] - ), - ShortcutDescriptor( - id: .newWorktreeSession, - title: "New Worktree Session", - detail: "Create a new session from an existing worktree", - section: .sessions, - niriActionName: nil, - niriCompatibility: .adapted, - remappable: true, - macBindings: [.mac(.n, [.command, .shift])], - niriBindings: [] - ), - ShortcutDescriptor( - id: .quickSwitchSession, - title: "Quick Switch Session", - detail: "Jump to another session quickly", - section: .sessions, - niriActionName: nil, - niriCompatibility: .adapted, - remappable: true, - macBindings: [.mac(.a, [.command, .shift])], - niriBindings: [] - ), - ShortcutDescriptor( - id: .focusNextSession, - title: "Next Session", - detail: "Focus next session in the list", - section: .sessions, - niriActionName: nil, - niriCompatibility: .adapted, - remappable: true, - macBindings: [.mac(.tab, [.control])], - niriBindings: [] - ), - ShortcutDescriptor( - id: .focusPreviousSession, - title: "Previous Session", - detail: "Focus previous session in the list", - section: .sessions, - niriActionName: nil, - niriCompatibility: .adapted, - remappable: true, - macBindings: [.mac(.tab, [.control, .shift])], - niriBindings: [] - ), - ShortcutDescriptor( - id: .renameSession, - title: "Rename Session", - detail: "Rename the currently focused session", - section: .sessions, - niriActionName: nil, - niriCompatibility: .adapted, - remappable: true, - macBindings: [.mac(.e, [.command, .shift])], - niriBindings: [] - ), - ShortcutDescriptor( - id: .closeSession, - title: "Close Session", - detail: "Close the currently focused session", - section: .sessions, - niriActionName: "Close window/session", - niriCompatibility: .adapted, - remappable: true, - macBindings: [.mac(.w, [.command])], - niriBindings: [.niri(.q)] - ), - ShortcutDescriptor( - id: .relaunchSession, - title: "Relaunch Session", - detail: "Relaunch the currently focused session", - section: .sessions, - niriActionName: nil, - niriCompatibility: .adapted, - remappable: true, - macBindings: [.mac(.r, [.command, .shift])], - niriBindings: [] - ), - ShortcutDescriptor( - id: .commandPalette, - title: "Command Palette", - detail: "Open command palette", - section: .navigation, - niriActionName: nil, - niriCompatibility: .adapted, - remappable: true, - macBindings: [.mac(.k, [.command])], - niriBindings: [.niri(.p)] - ), - ShortcutDescriptor( - id: .keyboardShortcuts, - title: "Keyboard Shortcuts", - detail: "Open the keyboard shortcut reference", - section: .navigation, - niriActionName: nil, - niriCompatibility: .adapted, - remappable: true, - macBindings: [], - niriBindings: [] - ), - ShortcutDescriptor( - id: .openSettings, - title: "Open Settings", - detail: "Open IDX0 settings", - section: .navigation, - niriActionName: nil, - niriCompatibility: .adapted, - remappable: true, - macBindings: [.mac(.comma, [.command])], - niriBindings: [] - ), - ShortcutDescriptor( - id: .checkForUpdates, - title: "Check for Updates", - detail: "Check whether a newer IDX0 version is available", - section: .navigation, - niriActionName: nil, - niriCompatibility: .adapted, - remappable: true, - macBindings: [], - niriBindings: [] - ), - ShortcutDescriptor( - id: .toggleSidebar, - title: "Toggle Sidebar", - detail: "Show or hide sidebar", - section: .navigation, - niriActionName: nil, - niriCompatibility: .adapted, - remappable: true, - macBindings: [.mac(.b, [.command])], - niriBindings: [] - ), - ShortcutDescriptor( - id: .toggleWorkflowRail, - title: "Toggle Workflow Rail", - detail: "Show or hide workflow rail", - section: .navigation, - niriActionName: nil, - niriCompatibility: .adapted, - remappable: true, - macBindings: [.mac(.i, [.command])], - niriBindings: [] - ), - ShortcutDescriptor( - id: .toggleFocusMode, - title: "Toggle Focus Mode", - detail: "Hide side panels for focus mode", - section: .navigation, - niriActionName: nil, - niriCompatibility: .adapted, - remappable: true, - macBindings: [.mac(.f, [.command, .shift])], - niriBindings: [] - ), - ShortcutDescriptor( - id: .focusNextQueueItem, - title: "Focus Next Queue Item", - detail: "Jump to highest-priority unresolved queue item", - section: .navigation, - niriActionName: nil, - niriCompatibility: .adapted, - remappable: true, - macBindings: [.mac(.downArrow, [.command, .option, .shift])], - niriBindings: [] - ), - ShortcutDescriptor( - id: .showDiff, - title: "Show Diff", - detail: "Toggle diff overlay", - section: .navigation, - niriActionName: nil, - niriCompatibility: .adapted, - remappable: true, - macBindings: [.mac(.d, [.command])], - niriBindings: [] - ), - ShortcutDescriptor( - id: .showCheckpoints, - title: "Checkpoints", - detail: "Toggle checkpoints sidebar", - section: .navigation, - niriActionName: nil, - niriCompatibility: .adapted, - remappable: true, - macBindings: [.mac(.c, [.command, .shift])], - niriBindings: [] - ), - ShortcutDescriptor( - id: .openClipboardURL, - title: "Open Clipboard URL", - detail: "Open clipboard URL in browser split", - section: .navigation, - niriActionName: nil, - niriCompatibility: .adapted, - remappable: true, - macBindings: [.mac(.o, [.command, .shift])], - niriBindings: [] - ), - ShortcutDescriptor( - id: .newTab, - title: "New Tab", - detail: "Create a new tab in current session", - section: .panes, - niriActionName: nil, - niriCompatibility: .adapted, - remappable: true, - macBindings: [.mac(.t, [.command, .shift])], - niriBindings: [] - ), - ShortcutDescriptor( - id: .nextTab, - title: "Next Tab", - detail: "Focus next tab", - section: .panes, - niriActionName: nil, - niriCompatibility: .adapted, - remappable: true, - macBindings: [.mac(.closeBracket, [.command, .shift])], - niriBindings: [] - ), - ShortcutDescriptor( - id: .previousTab, - title: "Previous Tab", - detail: "Focus previous tab", - section: .panes, - niriActionName: nil, - niriCompatibility: .adapted, - remappable: true, - macBindings: [.mac(.openBracket, [.command, .shift])], - niriBindings: [] - ), - ShortcutDescriptor( - id: .closeTab, - title: "Close Tab", - detail: "Close active tab", - section: .panes, - niriActionName: nil, - niriCompatibility: .adapted, - remappable: true, - macBindings: [.mac(.w, [.command, .option, .shift])], - niriBindings: [] - ), - ShortcutDescriptor( - id: .splitRight, - title: "Split Right", - detail: "Split right (or add terminal right in Niri mode)", - section: .panes, - niriActionName: "Consume or expand column right", - niriCompatibility: .adapted, - remappable: true, - macBindings: [.mac(.backslash, [.command])], - niriBindings: [] - ), - ShortcutDescriptor( - id: .splitDown, - title: "Split Down", - detail: "Split down (or add task below in Niri mode)", - section: .panes, - niriActionName: "Consume or expand window down", - niriCompatibility: .adapted, - remappable: true, - macBindings: [.mac(.backslash, [.command, .shift])], - niriBindings: [] - ), - ShortcutDescriptor( - id: .closePane, - title: "Close Pane / Tile", - detail: "Close focused pane or tile", - section: .panes, - niriActionName: "Close column/window", - niriCompatibility: .adapted, - remappable: true, - macBindings: [.mac(.w, [.command, .shift])], - niriBindings: [.niri(.w), .niri(.q, fixed: [.shift])] - ), - ShortcutDescriptor( - id: .nextPane, - title: "Next Pane", - detail: "Focus next pane", - section: .panes, - niriActionName: nil, - niriCompatibility: .adapted, - remappable: true, - macBindings: [.mac(.closeBracket, [.command])], - niriBindings: [] - ), - ShortcutDescriptor( - id: .previousPane, - title: "Previous Pane", - detail: "Focus previous pane", - section: .panes, - niriActionName: nil, - niriCompatibility: .adapted, - remappable: true, - macBindings: [.mac(.openBracket, [.command])], - niriBindings: [] - ), - ShortcutDescriptor( - id: .toggleBrowserSplit, - title: "Toggle Browser Split", - detail: "Show or hide browser split", - section: .panes, - niriActionName: nil, - niriCompatibility: .adapted, - remappable: true, - macBindings: [.mac(.b, [.command, .shift])], - niriBindings: [] - ), - ShortcutDescriptor( - id: .niriAddTerminalRight, - title: "Add Terminal Right", - detail: "Add terminal tile to the right", - section: .niri, - niriActionName: "Spawn window right", - niriCompatibility: .exact, - remappable: true, - macBindings: [ - .mac(.t, [.command, .option]), - .mac(.backslash, [.command, .option]), - ], - niriBindings: [.niri(.t), .niri(.backslash)] - ), - ShortcutDescriptor( - id: .niriAddTaskBelow, - title: "Add Task Below", - detail: "Add terminal tile below in current stack", - section: .niri, - niriActionName: "Spawn window down", - niriCompatibility: .exact, - remappable: true, - macBindings: [.mac(.backslash, [.command, .option, .shift])], - niriBindings: [.niri(.backslash, fixed: [.shift])] - ), - ShortcutDescriptor( - id: .niriAddBrowserTile, - title: "Add Browser Tile", - detail: "Add browser tile in current column", - section: .niri, - niriActionName: "Spawn browser helper tile", - niriCompatibility: .adapted, - remappable: true, - macBindings: [.mac(.b, [.command, .option])], - niriBindings: [.niri(.b)] - ), - ShortcutDescriptor( - id: .niriOpenAddTileMenu, - title: "Open Add Tile Menu", - detail: "Open the Add Tile quick menu", - section: .niri, - niriActionName: "Open quick-add menu", - niriCompatibility: .adapted, - remappable: true, - macBindings: [.mac(.a, [.command, .option])], - niriBindings: [.niri(.a)] - ), - ShortcutDescriptor( - id: .niriFocusLeft, - title: "Focus Left", - detail: "Move focus to left tile", - section: .niri, - niriActionName: "Focus column left", - niriCompatibility: .exact, - remappable: true, - macBindings: [.mac(.leftArrow, [.command, .option])], - niriBindings: [.niri(.h)] - ), - ShortcutDescriptor( - id: .niriFocusDown, - title: "Focus Down", - detail: "Move focus to tile below", - section: .niri, - niriActionName: "Focus window down", - niriCompatibility: .exact, - remappable: true, - macBindings: [.mac(.downArrow, [.command, .option])], - niriBindings: [.niri(.j)] - ), - ShortcutDescriptor( - id: .niriFocusUp, - title: "Focus Up", - detail: "Move focus to tile above", - section: .niri, - niriActionName: "Focus window up", - niriCompatibility: .exact, - remappable: true, - macBindings: [.mac(.upArrow, [.command, .option])], - niriBindings: [.niri(.k)] - ), - ShortcutDescriptor( - id: .niriFocusRight, - title: "Focus Right", - detail: "Move focus to right tile", - section: .niri, - niriActionName: "Focus column right", - niriCompatibility: .exact, - remappable: true, - macBindings: [.mac(.rightArrow, [.command, .option])], - niriBindings: [.niri(.l)] - ), - ShortcutDescriptor( - id: .niriToggleOverview, - title: "Toggle Overview", - detail: "Open or close overview", - section: .niri, - niriActionName: "Toggle overview", - niriCompatibility: .exact, - remappable: true, - macBindings: [.mac(.o, [.command, .option])], - niriBindings: [.niri(.o)] - ), - ShortcutDescriptor( - id: .niriConfirmSelection, - title: "Confirm Overview Selection", - detail: "Confirm selected tile in overview", - section: .niri, - niriActionName: "Confirm overview selection", - niriCompatibility: .exact, - remappable: true, - macBindings: [.mac(.returnKey, [])], - niriBindings: [] - ), - ShortcutDescriptor( - id: .niriToggleColumnTabbedDisplay, - title: "Toggle Column Tabbed Display", - detail: "Switch focused column between normal and tabbed", - section: .niri, - niriActionName: "Toggle tabbed column", - niriCompatibility: .exact, - remappable: true, - macBindings: [.mac(.t, [.command, .option, .shift])], - niriBindings: [.niri(.t, fixed: [.shift])] - ), - ShortcutDescriptor( - id: .niriToggleSnap, - title: "Toggle Snap", - detail: "Toggle niri snap behavior", - section: .niri, - niriActionName: "Toggle edge/snap behavior", - niriCompatibility: .adapted, - remappable: true, - macBindings: [.mac(.s, [.command, .option])], - niriBindings: [.niri(.s)] - ), - ShortcutDescriptor( - id: .niriFocusWorkspaceUp, - title: "Focus Workspace Up", - detail: "Move to previous workspace", - section: .niri, - niriActionName: "Focus workspace up", - niriCompatibility: .exact, - remappable: true, - macBindings: [.mac(.upArrow, [.command, .option, .control])], - niriBindings: [.niri(.u), .niri(.pageUp)] - ), - ShortcutDescriptor( - id: .niriFocusWorkspaceDown, - title: "Focus Workspace Down", - detail: "Move to next workspace", - section: .niri, - niriActionName: "Focus workspace down", - niriCompatibility: .exact, - remappable: true, - macBindings: [.mac(.downArrow, [.command, .option, .control])], - niriBindings: [.niri(.i), .niri(.pageDown)] - ), - ShortcutDescriptor( - id: .niriMoveColumnToWorkspaceUp, - title: "Move Column To Workspace Up", - detail: "Move focused column to previous workspace", - section: .niri, - niriActionName: "Move column to workspace up", - niriCompatibility: .exact, - remappable: true, - macBindings: [], - niriBindings: [.niri(.u, fixed: [.shift]), .niri(.pageUp, fixed: [.shift])] - ), - ShortcutDescriptor( - id: .niriMoveColumnToWorkspaceDown, - title: "Move Column To Workspace Down", - detail: "Move focused column to next workspace", - section: .niri, - niriActionName: "Move column to workspace down", - niriCompatibility: .exact, - remappable: true, - macBindings: [], - niriBindings: [.niri(.i, fixed: [.shift]), .niri(.pageDown, fixed: [.shift])] - ), - ShortcutDescriptor( - id: .niriToggleFocusedTileZoom, - title: "Toggle Focused Tile Zoom", - detail: "Toggle focused tile max-zoom mode", - section: .niri, - niriActionName: "Toggle focused tile max zoom", - niriCompatibility: .adapted, - remappable: true, - macBindings: [.mac(.f, [.command, .option])], - niriBindings: [.niri(.f)] - ), - ShortcutDescriptor( - id: .niriZoomInFocusedWebTile, - title: "Zoom In Focused Web Tile", - detail: "Increase zoom for focused browser-like tile", - section: .niri, - niriActionName: "Adjust web zoom", - niriCompatibility: .adapted, - remappable: true, - macBindings: [.mac(.equal, [.command])], - niriBindings: [.niri(.equal)] - ), - ShortcutDescriptor( - id: .niriZoomOutFocusedWebTile, - title: "Zoom Out Focused Web Tile", - detail: "Decrease zoom for focused browser-like tile", - section: .niri, - niriActionName: "Adjust web zoom", - niriCompatibility: .adapted, - remappable: true, - macBindings: [.mac(.minus, [.command])], - niriBindings: [.niri(.minus)] - ), - ShortcutDescriptor( - id: .quickApprove, - title: "Quick Approve", - detail: "Send approve/yes input when prompt is detected", - section: .workflow, - niriActionName: nil, - niriCompatibility: .adapted, - remappable: true, - macBindings: [.mac(.y, [.command])], - niriBindings: [] - ), - ] + private static let defaultDescriptors: [ShortcutDescriptor] = [ + ShortcutDescriptor( + id: .newSession, + title: "New Session (Default)", + detail: "Create a new session using the default flow", + section: .sessions, + niriActionName: nil, + niriCompatibility: .adapted, + remappable: true, + macBindings: [.mac(.n, [.command])], + niriBindings: [] + ), + ShortcutDescriptor( + id: .newQuickSession, + title: "New Quick Session", + detail: "Create an instant terminal session", + section: .sessions, + niriActionName: nil, + niriCompatibility: .adapted, + remappable: true, + macBindings: [.mac(.t, [.command])], + niriBindings: [] + ), + ShortcutDescriptor( + id: .newRepoWorktreeSession, + title: "New Repo/Worktree Session", + detail: "Open structured setup for repo or worktree", + section: .sessions, + niriActionName: nil, + niriCompatibility: .adapted, + remappable: true, + macBindings: [.mac(.n, [.command, .option])], + niriBindings: [] + ), + ShortcutDescriptor( + id: .newWorktreeSession, + title: "New Worktree Session", + detail: "Create a new session from an existing worktree", + section: .sessions, + niriActionName: nil, + niriCompatibility: .adapted, + remappable: true, + macBindings: [.mac(.n, [.command, .shift])], + niriBindings: [] + ), + ShortcutDescriptor( + id: .quickSwitchSession, + title: "Quick Switch Session", + detail: "Jump to another session quickly", + section: .sessions, + niriActionName: nil, + niriCompatibility: .adapted, + remappable: true, + macBindings: [.mac(.a, [.command, .shift])], + niriBindings: [] + ), + ShortcutDescriptor( + id: .focusNextSession, + title: "Next Session", + detail: "Focus next session in the list", + section: .sessions, + niriActionName: nil, + niriCompatibility: .adapted, + remappable: true, + macBindings: [.mac(.tab, [.control])], + niriBindings: [] + ), + ShortcutDescriptor( + id: .focusPreviousSession, + title: "Previous Session", + detail: "Focus previous session in the list", + section: .sessions, + niriActionName: nil, + niriCompatibility: .adapted, + remappable: true, + macBindings: [.mac(.tab, [.control, .shift])], + niriBindings: [] + ), + ShortcutDescriptor( + id: .renameSession, + title: "Rename Session", + detail: "Rename the currently focused session", + section: .sessions, + niriActionName: nil, + niriCompatibility: .adapted, + remappable: true, + macBindings: [.mac(.e, [.command, .shift])], + niriBindings: [] + ), + ShortcutDescriptor( + id: .closeSession, + title: "Close Session", + detail: "Close the currently focused session", + section: .sessions, + niriActionName: "Close window/session", + niriCompatibility: .adapted, + remappable: true, + macBindings: [.mac(.w, [.command])], + niriBindings: [.niri(.q)] + ), + ShortcutDescriptor( + id: .relaunchSession, + title: "Relaunch Session", + detail: "Relaunch the currently focused session", + section: .sessions, + niriActionName: nil, + niriCompatibility: .adapted, + remappable: true, + macBindings: [.mac(.r, [.command, .shift])], + niriBindings: [] + ), + ShortcutDescriptor( + id: .commandPalette, + title: "Command Palette", + detail: "Open command palette", + section: .navigation, + niriActionName: nil, + niriCompatibility: .adapted, + remappable: true, + macBindings: [.mac(.k, [.command])], + niriBindings: [.niri(.p)] + ), + ShortcutDescriptor( + id: .keyboardShortcuts, + title: "Keyboard Shortcuts", + detail: "Open the keyboard shortcut reference", + section: .navigation, + niriActionName: nil, + niriCompatibility: .adapted, + remappable: true, + macBindings: [], + niriBindings: [] + ), + ShortcutDescriptor( + id: .openSettings, + title: "Open Settings", + detail: "Open IDX0 settings", + section: .navigation, + niriActionName: nil, + niriCompatibility: .adapted, + remappable: true, + macBindings: [.mac(.comma, [.command])], + niriBindings: [] + ), + ShortcutDescriptor( + id: .checkForUpdates, + title: "Check for Updates", + detail: "Check whether a newer IDX0 version is available", + section: .navigation, + niriActionName: nil, + niriCompatibility: .adapted, + remappable: true, + macBindings: [], + niriBindings: [] + ), + ShortcutDescriptor( + id: .toggleSidebar, + title: "Toggle Sidebar", + detail: "Show or hide sidebar", + section: .navigation, + niriActionName: nil, + niriCompatibility: .adapted, + remappable: true, + macBindings: [.mac(.b, [.command])], + niriBindings: [] + ), + ShortcutDescriptor( + id: .toggleWorkflowRail, + title: "Toggle Workflow Rail", + detail: "Show or hide workflow rail", + section: .navigation, + niriActionName: nil, + niriCompatibility: .adapted, + remappable: true, + macBindings: [.mac(.i, [.command])], + niriBindings: [] + ), + ShortcutDescriptor( + id: .toggleFocusMode, + title: "Toggle Focus Mode", + detail: "Hide side panels for focus mode", + section: .navigation, + niriActionName: nil, + niriCompatibility: .adapted, + remappable: true, + macBindings: [.mac(.f, [.command, .shift])], + niriBindings: [] + ), + ShortcutDescriptor( + id: .focusNextQueueItem, + title: "Focus Next Queue Item", + detail: "Jump to highest-priority unresolved queue item", + section: .navigation, + niriActionName: nil, + niriCompatibility: .adapted, + remappable: true, + macBindings: [.mac(.downArrow, [.command, .option, .shift])], + niriBindings: [] + ), + ShortcutDescriptor( + id: .showDiff, + title: "Show Diff", + detail: "Toggle diff overlay", + section: .navigation, + niriActionName: nil, + niriCompatibility: .adapted, + remappable: true, + macBindings: [.mac(.d, [.command])], + niriBindings: [] + ), + ShortcutDescriptor( + id: .showCheckpoints, + title: "Checkpoints", + detail: "Toggle checkpoints sidebar", + section: .navigation, + niriActionName: nil, + niriCompatibility: .adapted, + remappable: true, + macBindings: [.mac(.c, [.command, .shift])], + niriBindings: [] + ), + ShortcutDescriptor( + id: .openClipboardURL, + title: "Open Clipboard URL", + detail: "Open clipboard URL in browser split", + section: .navigation, + niriActionName: nil, + niriCompatibility: .adapted, + remappable: true, + macBindings: [.mac(.o, [.command, .shift])], + niriBindings: [] + ), + ShortcutDescriptor( + id: .newTab, + title: "New Tab", + detail: "Create a new tab in current session", + section: .panes, + niriActionName: nil, + niriCompatibility: .adapted, + remappable: true, + macBindings: [.mac(.t, [.command, .shift])], + niriBindings: [] + ), + ShortcutDescriptor( + id: .nextTab, + title: "Next Tab", + detail: "Focus next tab", + section: .panes, + niriActionName: nil, + niriCompatibility: .adapted, + remappable: true, + macBindings: [.mac(.closeBracket, [.command, .shift])], + niriBindings: [] + ), + ShortcutDescriptor( + id: .previousTab, + title: "Previous Tab", + detail: "Focus previous tab", + section: .panes, + niriActionName: nil, + niriCompatibility: .adapted, + remappable: true, + macBindings: [.mac(.openBracket, [.command, .shift])], + niriBindings: [] + ), + ShortcutDescriptor( + id: .closeTab, + title: "Close Tab", + detail: "Close active tab", + section: .panes, + niriActionName: nil, + niriCompatibility: .adapted, + remappable: true, + macBindings: [.mac(.w, [.command, .option, .shift])], + niriBindings: [] + ), + ShortcutDescriptor( + id: .splitRight, + title: "Split Right", + detail: "Split right (or add terminal right in Niri mode)", + section: .panes, + niriActionName: "Consume or expand column right", + niriCompatibility: .adapted, + remappable: true, + macBindings: [.mac(.backslash, [.command])], + niriBindings: [] + ), + ShortcutDescriptor( + id: .splitDown, + title: "Split Down", + detail: "Split down (or add task below in Niri mode)", + section: .panes, + niriActionName: "Consume or expand window down", + niriCompatibility: .adapted, + remappable: true, + macBindings: [.mac(.backslash, [.command, .shift])], + niriBindings: [] + ), + ShortcutDescriptor( + id: .closePane, + title: "Close Pane / Tile", + detail: "Close focused pane or tile", + section: .panes, + niriActionName: "Close column/window", + niriCompatibility: .adapted, + remappable: true, + macBindings: [.mac(.w, [.command, .shift])], + niriBindings: [.niri(.w), .niri(.q, fixed: [.shift])] + ), + ShortcutDescriptor( + id: .nextPane, + title: "Next Pane", + detail: "Focus next pane", + section: .panes, + niriActionName: nil, + niriCompatibility: .adapted, + remappable: true, + macBindings: [.mac(.closeBracket, [.command])], + niriBindings: [] + ), + ShortcutDescriptor( + id: .previousPane, + title: "Previous Pane", + detail: "Focus previous pane", + section: .panes, + niriActionName: nil, + niriCompatibility: .adapted, + remappable: true, + macBindings: [.mac(.openBracket, [.command])], + niriBindings: [] + ), + ShortcutDescriptor( + id: .toggleBrowserSplit, + title: "Toggle Browser Split", + detail: "Show or hide browser split", + section: .panes, + niriActionName: nil, + niriCompatibility: .adapted, + remappable: true, + macBindings: [.mac(.b, [.command, .shift])], + niriBindings: [] + ), + ShortcutDescriptor( + id: .niriAddTerminalRight, + title: "Add Terminal Right", + detail: "Add terminal tile to the right", + section: .niri, + niriActionName: "Spawn window right", + niriCompatibility: .exact, + remappable: true, + macBindings: [ + .mac(.t, [.command, .option]), + .mac(.backslash, [.command, .option]), + ], + niriBindings: [.niri(.t), .niri(.backslash)] + ), + ShortcutDescriptor( + id: .niriAddTaskBelow, + title: "Add Task Below", + detail: "Add terminal tile below in current stack", + section: .niri, + niriActionName: "Spawn window down", + niriCompatibility: .exact, + remappable: true, + macBindings: [.mac(.backslash, [.command, .option, .shift])], + niriBindings: [.niri(.backslash, fixed: [.shift])] + ), + ShortcutDescriptor( + id: .niriAddBrowserTile, + title: "Add Browser Tile", + detail: "Add browser tile in current column", + section: .niri, + niriActionName: "Spawn browser helper tile", + niriCompatibility: .adapted, + remappable: true, + macBindings: [.mac(.b, [.command, .option])], + niriBindings: [.niri(.b)] + ), + ShortcutDescriptor( + id: .niriOpenAddTileMenu, + title: "Open Add Tile Menu", + detail: "Open the Add Tile quick menu", + section: .niri, + niriActionName: "Open quick-add menu", + niriCompatibility: .adapted, + remappable: true, + macBindings: [.mac(.a, [.command, .option])], + niriBindings: [.niri(.a)] + ), + ShortcutDescriptor( + id: .niriFocusLeft, + title: "Focus Left", + detail: "Move focus to left tile", + section: .niri, + niriActionName: "Focus column left", + niriCompatibility: .exact, + remappable: true, + macBindings: [.mac(.leftArrow, [.command, .option])], + niriBindings: [.niri(.h)] + ), + ShortcutDescriptor( + id: .niriFocusDown, + title: "Focus Down", + detail: "Move focus to tile below", + section: .niri, + niriActionName: "Focus window down", + niriCompatibility: .exact, + remappable: true, + macBindings: [.mac(.downArrow, [.command, .option])], + niriBindings: [.niri(.j)] + ), + ShortcutDescriptor( + id: .niriFocusUp, + title: "Focus Up", + detail: "Move focus to tile above", + section: .niri, + niriActionName: "Focus window up", + niriCompatibility: .exact, + remappable: true, + macBindings: [.mac(.upArrow, [.command, .option])], + niriBindings: [.niri(.k)] + ), + ShortcutDescriptor( + id: .niriFocusRight, + title: "Focus Right", + detail: "Move focus to right tile", + section: .niri, + niriActionName: "Focus column right", + niriCompatibility: .exact, + remappable: true, + macBindings: [.mac(.rightArrow, [.command, .option])], + niriBindings: [.niri(.l)] + ), + ShortcutDescriptor( + id: .niriToggleOverview, + title: "Toggle Overview", + detail: "Open or close overview", + section: .niri, + niriActionName: "Toggle overview", + niriCompatibility: .exact, + remappable: true, + macBindings: [.mac(.o, [.command, .option])], + niriBindings: [.niri(.o)] + ), + ShortcutDescriptor( + id: .niriConfirmSelection, + title: "Confirm Overview Selection", + detail: "Confirm selected tile in overview", + section: .niri, + niriActionName: "Confirm overview selection", + niriCompatibility: .exact, + remappable: true, + macBindings: [.mac(.returnKey, [])], + niriBindings: [] + ), + ShortcutDescriptor( + id: .niriToggleColumnTabbedDisplay, + title: "Toggle Column Tabbed Display", + detail: "Switch focused column between normal and tabbed", + section: .niri, + niriActionName: "Toggle tabbed column", + niriCompatibility: .exact, + remappable: true, + macBindings: [.mac(.t, [.command, .option, .shift])], + niriBindings: [.niri(.t, fixed: [.shift])] + ), + ShortcutDescriptor( + id: .niriToggleSnap, + title: "Toggle Snap", + detail: "Toggle niri snap behavior", + section: .niri, + niriActionName: "Toggle edge/snap behavior", + niriCompatibility: .adapted, + remappable: true, + macBindings: [.mac(.s, [.command, .option])], + niriBindings: [.niri(.s)] + ), + ShortcutDescriptor( + id: .niriFocusWorkspaceUp, + title: "Focus Workspace Up", + detail: "Move to previous workspace", + section: .niri, + niriActionName: "Focus workspace up", + niriCompatibility: .exact, + remappable: true, + macBindings: [.mac(.upArrow, [.command, .option, .control])], + niriBindings: [.niri(.u), .niri(.pageUp)] + ), + ShortcutDescriptor( + id: .niriFocusWorkspaceDown, + title: "Focus Workspace Down", + detail: "Move to next workspace", + section: .niri, + niriActionName: "Focus workspace down", + niriCompatibility: .exact, + remappable: true, + macBindings: [.mac(.downArrow, [.command, .option, .control])], + niriBindings: [.niri(.i), .niri(.pageDown)] + ), + ShortcutDescriptor( + id: .niriMoveColumnToWorkspaceUp, + title: "Move Column To Workspace Up", + detail: "Move focused column to previous workspace", + section: .niri, + niriActionName: "Move column to workspace up", + niriCompatibility: .exact, + remappable: true, + macBindings: [], + niriBindings: [.niri(.u, fixed: [.shift]), .niri(.pageUp, fixed: [.shift])] + ), + ShortcutDescriptor( + id: .niriMoveColumnToWorkspaceDown, + title: "Move Column To Workspace Down", + detail: "Move focused column to next workspace", + section: .niri, + niriActionName: "Move column to workspace down", + niriCompatibility: .exact, + remappable: true, + macBindings: [], + niriBindings: [.niri(.i, fixed: [.shift]), .niri(.pageDown, fixed: [.shift])] + ), + ShortcutDescriptor( + id: .niriToggleFocusedTileZoom, + title: "Toggle Focused Tile Zoom", + detail: "Toggle focused tile max-zoom mode", + section: .niri, + niriActionName: "Toggle focused tile max zoom", + niriCompatibility: .adapted, + remappable: true, + macBindings: [.mac(.f, [.command, .option])], + niriBindings: [.niri(.f)] + ), + ShortcutDescriptor( + id: .niriZoomInFocusedWebTile, + title: "Zoom In Focused Web Tile", + detail: "Increase zoom for focused browser-like tile", + section: .niri, + niriActionName: "Adjust web zoom", + niriCompatibility: .adapted, + remappable: true, + macBindings: [.mac(.equal, [.command])], + niriBindings: [.niri(.equal)] + ), + ShortcutDescriptor( + id: .niriZoomOutFocusedWebTile, + title: "Zoom Out Focused Web Tile", + detail: "Decrease zoom for focused browser-like tile", + section: .niri, + niriActionName: "Adjust web zoom", + niriCompatibility: .adapted, + remappable: true, + macBindings: [.mac(.minus, [.command])], + niriBindings: [.niri(.minus)] + ), + ShortcutDescriptor( + id: .quickApprove, + title: "Quick Approve", + detail: "Send approve/yes input when prompt is detected", + section: .workflow, + niriActionName: nil, + niriCompatibility: .adapted, + remappable: true, + macBindings: [.mac(.y, [.command])], + niriBindings: [] + ), + ] } private extension ShortcutBindingTemplate { - static func mac(_ key: ShortcutKey, _ modifiers: Set) -> ShortcutBindingTemplate { - ShortcutBindingTemplate(key: key, fixedModifiers: modifiers) - } + static func mac(_ key: ShortcutKey, _ modifiers: Set) -> ShortcutBindingTemplate { + ShortcutBindingTemplate(key: key, fixedModifiers: modifiers) + } - static func niri(_ key: ShortcutKey, fixed: Set = []) -> ShortcutBindingTemplate { - ShortcutBindingTemplate(key: key, fixedModifiers: fixed, includesModKey: true) - } + static func niri(_ key: ShortcutKey, fixed: Set = []) -> ShortcutBindingTemplate { + ShortcutBindingTemplate(key: key, fixedModifiers: fixed, includesModKey: true) + } } diff --git a/idx0/Services/Updates/AppUpdateModels.swift b/idx0/Services/Updates/AppUpdateModels.swift index dcbd6b4..a8abcde 100644 --- a/idx0/Services/Updates/AppUpdateModels.swift +++ b/idx0/Services/Updates/AppUpdateModels.swift @@ -1,99 +1,99 @@ import Foundation enum AppUpdateStatus: String, Equatable { - case disabled - case idle - case checking - case upToDate - case available - case downloading - case downloaded - case error + case disabled + case idle + case checking + case upToDate + case available + case downloading + case downloaded + case error } struct AppUpdateState: Equatable { - var currentVersion: String - var availableVersion: String? - var progress: Double? - var lastCheckedAt: Date? - var errorMessage: String? - var enabled: Bool - var status: AppUpdateStatus + var currentVersion: String + var availableVersion: String? + var progress: Double? + var lastCheckedAt: Date? + var errorMessage: String? + var enabled: Bool + var status: AppUpdateStatus - init( - currentVersion: String, - availableVersion: String? = nil, - progress: Double? = nil, - lastCheckedAt: Date? = nil, - errorMessage: String? = nil, - enabled: Bool = true, - status: AppUpdateStatus = .idle - ) { - self.currentVersion = currentVersion - self.availableVersion = availableVersion - self.progress = progress - self.lastCheckedAt = lastCheckedAt - self.errorMessage = errorMessage - self.enabled = enabled - self.status = status - } + init( + currentVersion: String, + availableVersion: String? = nil, + progress: Double? = nil, + lastCheckedAt: Date? = nil, + errorMessage: String? = nil, + enabled: Bool = true, + status: AppUpdateStatus = .idle + ) { + self.currentVersion = currentVersion + self.availableVersion = availableVersion + self.progress = progress + self.lastCheckedAt = lastCheckedAt + self.errorMessage = errorMessage + self.enabled = enabled + self.status = status + } } enum AppUpdateCheckSource: Equatable { - case startup - case scheduled - case manual - case retry + case startup + case scheduled + case manual + case retry } enum AppUpdateEvent: Equatable { - case policyChanged(enabled: Bool) - case checkRequested(source: AppUpdateCheckSource) - case checkSucceeded(availableVersion: String?, checkedAt: Date) - case checkFailed(message: String, checkedAt: Date) - case downloadStarted - case downloadProgress(Double) - case downloadSucceeded - case downloadFailed(String) - case installStarted - case installFailed(String) + case policyChanged(enabled: Bool) + case checkRequested(source: AppUpdateCheckSource) + case checkSucceeded(availableVersion: String?, checkedAt: Date) + case checkFailed(message: String, checkedAt: Date) + case downloadStarted + case downloadProgress(Double) + case downloadSucceeded + case downloadFailed(String) + case installStarted + case installFailed(String) } enum AppUpdatePrimaryAction: Equatable { - case check - case download - case install - case retry + case check + case download + case install + case retry } enum AppUpdateActionMapper { - static func primaryAction(for status: AppUpdateStatus) -> AppUpdatePrimaryAction? { - switch status { - case .disabled, .checking, .downloading: - return nil - case .idle, .upToDate: - return .check - case .available: - return .download - case .downloaded: - return .install - case .error: - return .retry - } + static func primaryAction(for status: AppUpdateStatus) -> AppUpdatePrimaryAction? { + switch status { + case .disabled, .checking, .downloading: + nil + case .idle, .upToDate: + .check + case .available: + .download + case .downloaded: + .install + case .error: + .retry } + } - static func primaryActionTitle(for status: AppUpdateStatus) -> String? { - switch primaryAction(for: status) { - case .check: - return "Check for Updates" - case .download: - return "Download Update" - case .install: - return "Install Update" - case .retry: - return "Retry" - case nil: - return nil - } + static func primaryActionTitle(for status: AppUpdateStatus) -> String? { + switch primaryAction(for: status) { + case .check: + "Check for Updates" + case .download: + "Download Update" + case .install: + "Install Update" + case .retry: + "Retry" + case nil: + nil } + } } diff --git a/idx0/Services/Updates/AppUpdateReducer.swift b/idx0/Services/Updates/AppUpdateReducer.swift index cb6b190..f430c4b 100644 --- a/idx0/Services/Updates/AppUpdateReducer.swift +++ b/idx0/Services/Updates/AppUpdateReducer.swift @@ -1,78 +1,78 @@ import Foundation enum AppUpdateReducer { - static func reduce(state: AppUpdateState, event: AppUpdateEvent) -> AppUpdateState { - var next = state + static func reduce(state: AppUpdateState, event: AppUpdateEvent) -> AppUpdateState { + var next = state - switch event { - case .policyChanged(let enabled): - next.enabled = enabled - next.progress = nil - if !enabled { - next.status = .disabled - next.errorMessage = nil - } else if next.status == .disabled { - next.status = .idle - next.errorMessage = nil - } + switch event { + case let .policyChanged(enabled): + next.enabled = enabled + next.progress = nil + if !enabled { + next.status = .disabled + next.errorMessage = nil + } else if next.status == .disabled { + next.status = .idle + next.errorMessage = nil + } - case .checkRequested: - guard next.enabled else { return next } - if next.status == .checking || next.status == .downloading { - return next - } - next.status = .checking - next.errorMessage = nil - next.progress = nil - - case .checkSucceeded(let availableVersion, let checkedAt): - guard next.enabled else { return next } - next.lastCheckedAt = checkedAt - next.errorMessage = nil - next.progress = nil - next.availableVersion = availableVersion - next.status = availableVersion == nil ? .upToDate : .available + case .checkRequested: + guard next.enabled else { return next } + if next.status == .checking || next.status == .downloading { + return next + } + next.status = .checking + next.errorMessage = nil + next.progress = nil - case .checkFailed(let message, let checkedAt): - guard next.enabled else { return next } - next.lastCheckedAt = checkedAt - next.errorMessage = message - next.progress = nil - next.status = .error + case let .checkSucceeded(availableVersion, checkedAt): + guard next.enabled else { return next } + next.lastCheckedAt = checkedAt + next.errorMessage = nil + next.progress = nil + next.availableVersion = availableVersion + next.status = availableVersion == nil ? .upToDate : .available - case .downloadStarted: - guard next.enabled else { return next } - next.status = .downloading - next.progress = 0 - next.errorMessage = nil + case let .checkFailed(message, checkedAt): + guard next.enabled else { return next } + next.lastCheckedAt = checkedAt + next.errorMessage = message + next.progress = nil + next.status = .error - case .downloadProgress(let value): - guard next.enabled else { return next } - next.status = .downloading - next.progress = min(max(value, 0), 1) + case .downloadStarted: + guard next.enabled else { return next } + next.status = .downloading + next.progress = 0 + next.errorMessage = nil - case .downloadSucceeded: - guard next.enabled else { return next } - next.status = .downloaded - next.progress = 1 - next.errorMessage = nil + case let .downloadProgress(value): + guard next.enabled else { return next } + next.status = .downloading + next.progress = min(max(value, 0), 1) - case .downloadFailed(let message): - guard next.enabled else { return next } - next.status = .error - next.progress = nil - next.errorMessage = message + case .downloadSucceeded: + guard next.enabled else { return next } + next.status = .downloaded + next.progress = 1 + next.errorMessage = nil - case .installStarted: - guard next.enabled else { return next } - next.errorMessage = nil + case let .downloadFailed(message): + guard next.enabled else { return next } + next.status = .error + next.progress = nil + next.errorMessage = message - case .installFailed(let message): - guard next.enabled else { return next } - next.status = .error - next.errorMessage = message - } + case .installStarted: + guard next.enabled else { return next } + next.errorMessage = nil - return next + case let .installFailed(message): + guard next.enabled else { return next } + next.status = .error + next.errorMessage = message } + + return next + } } diff --git a/idx0/Services/Updates/AppUpdateService.swift b/idx0/Services/Updates/AppUpdateService.swift index 87be1ee..d0719dd 100644 --- a/idx0/Services/Updates/AppUpdateService.swift +++ b/idx0/Services/Updates/AppUpdateService.swift @@ -2,174 +2,174 @@ import Foundation @MainActor final class AppUpdateService: ObservableObject { - static let startupDelay: TimeInterval = 15 - static let pollInterval: TimeInterval = 4 * 60 * 60 - - @Published private(set) var state: AppUpdateState - - private let driver: AppUpdateDriverProtocol - private let scheduler: UpdateSchedulerProtocol - private let versionProvider: AppVersionProviding - private let environment: EnvironmentProviding - private let autoCheckEnabledProvider: () -> Bool - private let now: () -> Date - - private var startupToken: UpdateSchedulerCancellable? - private var repeatingToken: UpdateSchedulerCancellable? - - init( - driver: AppUpdateDriverProtocol, - scheduler: UpdateSchedulerProtocol, - versionProvider: AppVersionProviding, - environment: EnvironmentProviding, - autoCheckEnabledProvider: @escaping () -> Bool, - now: @escaping () -> Date = Date.init - ) { - self.driver = driver - self.scheduler = scheduler - self.versionProvider = versionProvider - self.environment = environment - self.autoCheckEnabledProvider = autoCheckEnabledProvider - self.now = now - - self.state = AppUpdateState(currentVersion: versionProvider.currentVersion) - - self.driver.onEvent = { [weak self] event in - Task { @MainActor in - self?.handleDriverEvent(event) - } - } - - refreshPolicy() + static let startupDelay: TimeInterval = 15 + static let pollInterval: TimeInterval = 4 * 60 * 60 + + @Published private(set) var state: AppUpdateState + + private let driver: AppUpdateDriverProtocol + private let scheduler: UpdateSchedulerProtocol + private let versionProvider: AppVersionProviding + private let environment: EnvironmentProviding + private let autoCheckEnabledProvider: () -> Bool + private let now: () -> Date + + private var startupToken: UpdateSchedulerCancellable? + private var repeatingToken: UpdateSchedulerCancellable? + + init( + driver: AppUpdateDriverProtocol, + scheduler: UpdateSchedulerProtocol, + versionProvider: AppVersionProviding, + environment: EnvironmentProviding, + autoCheckEnabledProvider: @escaping () -> Bool, + now: @escaping () -> Date = Date.init + ) { + self.driver = driver + self.scheduler = scheduler + self.versionProvider = versionProvider + self.environment = environment + self.autoCheckEnabledProvider = autoCheckEnabledProvider + self.now = now + + state = AppUpdateState(currentVersion: versionProvider.currentVersion) + + self.driver.onEvent = { [weak self] event in + Task { @MainActor in + self?.handleDriverEvent(event) + } } - func refreshPolicy() { - let enabled = !environment.isRunningTests && !environment.isDebugBuild && !environment.disableAutoUpdate - state = AppUpdateReducer.reduce(state: state, event: .policyChanged(enabled: enabled)) - configureScheduling() - } + refreshPolicy() + } - func checkNow() { - checkNow(source: .manual) - } + func refreshPolicy() { + let enabled = !environment.isRunningTests && !environment.isDebugBuild && !environment.disableAutoUpdate + state = AppUpdateReducer.reduce(state: state, event: .policyChanged(enabled: enabled)) + configureScheduling() + } - func performPrimaryAction() { - guard let action = AppUpdateActionMapper.primaryAction(for: state.status) else { - return - } - - switch action { - case .check: - checkNow(source: .manual) - case .retry: - checkNow(source: .retry) - case .download: - guard state.enabled else { return } - state = AppUpdateReducer.reduce(state: state, event: .downloadStarted) - driver.downloadUpdate() - case .install: - guard state.enabled else { return } - state = AppUpdateReducer.reduce(state: state, event: .installStarted) - driver.installUpdate() - } - } + func checkNow() { + checkNow(source: .manual) + } - var canPerformPrimaryAction: Bool { - AppUpdateActionMapper.primaryAction(for: state.status) != nil + func performPrimaryAction() { + guard let action = AppUpdateActionMapper.primaryAction(for: state.status) else { + return } - var primaryActionTitle: String? { - AppUpdateActionMapper.primaryActionTitle(for: state.status) + switch action { + case .check: + checkNow(source: .manual) + case .retry: + checkNow(source: .retry) + case .download: + guard state.enabled else { return } + state = AppUpdateReducer.reduce(state: state, event: .downloadStarted) + driver.downloadUpdate() + case .install: + guard state.enabled else { return } + state = AppUpdateReducer.reduce(state: state, event: .installStarted) + driver.installUpdate() } - - var contextualMenuActionTitle: String? { - switch state.status { - case .available: - return "Download Update" - case .downloaded: - return "Install Update" - case .error: - return "Retry Update Check" - default: - return nil - } + } + + var canPerformPrimaryAction: Bool { + AppUpdateActionMapper.primaryAction(for: state.status) != nil + } + + var primaryActionTitle: String? { + AppUpdateActionMapper.primaryActionTitle(for: state.status) + } + + var contextualMenuActionTitle: String? { + switch state.status { + case .available: + "Download Update" + case .downloaded: + "Install Update" + case .error: + "Retry Update Check" + default: + nil } - - var statusDescription: String { - switch state.status { - case .disabled: - return "Updates are disabled in this environment." - case .idle: - return autoCheckEnabledProvider() ? "Auto-check is enabled." : "Auto-check is disabled." - case .checking: - return "Checking for updates…" - case .upToDate: - return "IDX0 is up to date." - case .available: - if let availableVersion = state.availableVersion { - return "Version \(availableVersion) is available." - } - return "An update is available." - case .downloading: - let progress = Int((state.progress ?? 0) * 100) - return "Downloading update (\(progress)%)." - case .downloaded: - return "Update downloaded and ready to install." - case .error: - return state.errorMessage ?? "Update check failed." - } + } + + var statusDescription: String { + switch state.status { + case .disabled: + return "Updates are disabled in this environment." + case .idle: + return autoCheckEnabledProvider() ? "Auto-check is enabled." : "Auto-check is disabled." + case .checking: + return "Checking for updates…" + case .upToDate: + return "IDX0 is up to date." + case .available: + if let availableVersion = state.availableVersion { + return "Version \(availableVersion) is available." + } + return "An update is available." + case .downloading: + let progress = Int((state.progress ?? 0) * 100) + return "Downloading update (\(progress)%)." + case .downloaded: + return "Update downloaded and ready to install." + case .error: + return state.errorMessage ?? "Update check failed." } + } - private func configureScheduling() { - startupToken?.cancel() - repeatingToken?.cancel() - startupToken = nil - repeatingToken = nil + private func configureScheduling() { + startupToken?.cancel() + repeatingToken?.cancel() + startupToken = nil + repeatingToken = nil - guard state.enabled, autoCheckEnabledProvider() else { - return - } - - startupToken = scheduler.schedule(after: Self.startupDelay) { [weak self] in - self?.checkNow(source: .startup) - } - - repeatingToken = scheduler.scheduleRepeating(every: Self.pollInterval) { [weak self] in - self?.checkNow(source: .scheduled) - } + guard state.enabled, autoCheckEnabledProvider() else { + return } - private func checkNow(source: AppUpdateCheckSource) { - guard state.enabled else { return } - guard state.status != .checking, state.status != .downloading else { return } - - state = AppUpdateReducer.reduce(state: state, event: .checkRequested(source: source)) - driver.checkForUpdates( - feedURLOverride: environment.updateFeedURLOverride, - currentVersion: state.currentVersion - ) + startupToken = scheduler.schedule(after: Self.startupDelay) { [weak self] in + self?.checkNow(source: .startup) } - private func handleDriverEvent(_ event: AppUpdateDriverEvent) { - switch event { - case .checkSucceeded(let availableVersion, _): - state = AppUpdateReducer.reduce( - state: state, - event: .checkSucceeded(availableVersion: availableVersion, checkedAt: now()) - ) - case .checkFailed(let message): - state = AppUpdateReducer.reduce( - state: state, - event: .checkFailed(message: message, checkedAt: now()) - ) - case .downloadProgress(let value): - state = AppUpdateReducer.reduce(state: state, event: .downloadProgress(value)) - case .downloadCompleted: - state = AppUpdateReducer.reduce(state: state, event: .downloadSucceeded) - case .downloadFailed(let message): - state = AppUpdateReducer.reduce(state: state, event: .downloadFailed(message)) - case .installFailed(let message): - state = AppUpdateReducer.reduce(state: state, event: .installFailed(message)) - } + repeatingToken = scheduler.scheduleRepeating(every: Self.pollInterval) { [weak self] in + self?.checkNow(source: .scheduled) + } + } + + private func checkNow(source: AppUpdateCheckSource) { + guard state.enabled else { return } + guard state.status != .checking, state.status != .downloading else { return } + + state = AppUpdateReducer.reduce(state: state, event: .checkRequested(source: source)) + driver.checkForUpdates( + feedURLOverride: environment.updateFeedURLOverride, + currentVersion: state.currentVersion + ) + } + + private func handleDriverEvent(_ event: AppUpdateDriverEvent) { + switch event { + case let .checkSucceeded(availableVersion, _): + state = AppUpdateReducer.reduce( + state: state, + event: .checkSucceeded(availableVersion: availableVersion, checkedAt: now()) + ) + case let .checkFailed(message): + state = AppUpdateReducer.reduce( + state: state, + event: .checkFailed(message: message, checkedAt: now()) + ) + case let .downloadProgress(value): + state = AppUpdateReducer.reduce(state: state, event: .downloadProgress(value)) + case .downloadCompleted: + state = AppUpdateReducer.reduce(state: state, event: .downloadSucceeded) + case let .downloadFailed(message): + state = AppUpdateReducer.reduce(state: state, event: .downloadFailed(message)) + case let .installFailed(message): + state = AppUpdateReducer.reduce(state: state, event: .installFailed(message)) } + } } diff --git a/idx0/Services/Updates/AppUpdateSupport.swift b/idx0/Services/Updates/AppUpdateSupport.swift index 514390d..57126f1 100644 --- a/idx0/Services/Updates/AppUpdateSupport.swift +++ b/idx0/Services/Updates/AppUpdateSupport.swift @@ -2,147 +2,148 @@ import Foundation @MainActor protocol AppUpdateDriverProtocol: AnyObject { - var onEvent: ((AppUpdateDriverEvent) -> Void)? { get set } - func checkForUpdates(feedURLOverride: URL?, currentVersion: String) - func downloadUpdate() - func installUpdate() + var onEvent: ((AppUpdateDriverEvent) -> Void)? { get set } + func checkForUpdates(feedURLOverride: URL?, currentVersion: String) + func downloadUpdate() + func installUpdate() } enum AppUpdateDriverEvent: Equatable { - case checkSucceeded(availableVersion: String?, downloadURL: URL?) - case checkFailed(message: String) - case downloadProgress(Double) - case downloadCompleted - case downloadFailed(message: String) - case installFailed(message: String) + case checkSucceeded(availableVersion: String?, downloadURL: URL?) + case checkFailed(message: String) + case downloadProgress(Double) + case downloadCompleted + case downloadFailed(message: String) + case installFailed(message: String) } protocol UpdateSchedulerCancellable { - func cancel() + func cancel() } @MainActor protocol UpdateSchedulerProtocol { - @discardableResult - func schedule( - after interval: TimeInterval, - _ action: @escaping @Sendable @MainActor () -> Void - ) -> UpdateSchedulerCancellable - - @discardableResult - func scheduleRepeating( - every interval: TimeInterval, - _ action: @escaping @Sendable @MainActor () -> Void - ) -> UpdateSchedulerCancellable + @discardableResult + func schedule( + after interval: TimeInterval, + _ action: @escaping @Sendable @MainActor () -> Void + ) -> UpdateSchedulerCancellable + + @discardableResult + func scheduleRepeating( + every interval: TimeInterval, + _ action: @escaping @Sendable @MainActor () -> Void + ) -> UpdateSchedulerCancellable } protocol AppVersionProviding { - var currentVersion: String { get } + var currentVersion: String { get } } protocol EnvironmentProviding { - var isRunningTests: Bool { get } - var isDebugBuild: Bool { get } - var disableAutoUpdate: Bool { get } - var updateFeedURLOverride: URL? { get } - var defaultUpdateFeedURL: URL? { get } + var isRunningTests: Bool { get } + var isDebugBuild: Bool { get } + var disableAutoUpdate: Bool { get } + var updateFeedURLOverride: URL? { get } + var defaultUpdateFeedURL: URL? { get } } struct BundleAppVersionProvider: AppVersionProviding { - private let bundle: Bundle + private let bundle: Bundle - init(bundle: Bundle = .main) { - self.bundle = bundle - } - - var currentVersion: String { - guard let short = bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String else { - return "0.0.0" - } + init(bundle: Bundle = .main) { + self.bundle = bundle + } - let cleaned = short.trimmingCharacters(in: .whitespacesAndNewlines) - return cleaned.isEmpty ? "0.0.0" : cleaned + var currentVersion: String { + guard let short = bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String else { + return "0.0.0" } + + let cleaned = short.trimmingCharacters(in: .whitespacesAndNewlines) + return cleaned.isEmpty ? "0.0.0" : cleaned + } } struct ProcessEnvironmentProvider: EnvironmentProviding { - private let environment: [String: String] - private let bundle: Bundle - - init(environment: [String: String] = ProcessInfo.processInfo.environment, bundle: Bundle = .main) { - self.environment = environment - self.bundle = bundle - } - - var isRunningTests: Bool { - environment["XCTestBundlePath"] != nil || environment["XCTestConfigurationFilePath"] != nil - } - - var isDebugBuild: Bool { - #if DEBUG - true - #else - false - #endif + private let environment: [String: String] + private let bundle: Bundle + + init(environment: [String: String] = ProcessInfo.processInfo.environment, bundle: Bundle = .main) { + self.environment = environment + self.bundle = bundle + } + + var isRunningTests: Bool { + environment["XCTestBundlePath"] != nil || environment["XCTestConfigurationFilePath"] != nil + } + + var isDebugBuild: Bool { + #if DEBUG + true + #else + false + #endif + } + + var disableAutoUpdate: Bool { + environment["IDX0_DISABLE_AUTO_UPDATE"] == "1" + } + + var updateFeedURLOverride: URL? { + guard let raw = environment["IDX0_UPDATE_FEED_URL"], !raw.isEmpty else { + return nil } - - var disableAutoUpdate: Bool { - environment["IDX0_DISABLE_AUTO_UPDATE"] == "1" - } - - var updateFeedURLOverride: URL? { - guard let raw = environment["IDX0_UPDATE_FEED_URL"], !raw.isEmpty else { - return nil - } - return URL(string: raw) - } - - var defaultUpdateFeedURL: URL? { - guard let raw = bundle.object(forInfoDictionaryKey: "SUFeedURL") as? String, - !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - return nil - } - return URL(string: raw) + return URL(string: raw) + } + + var defaultUpdateFeedURL: URL? { + guard let raw = bundle.object(forInfoDictionaryKey: "SUFeedURL") as? String, + !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + return nil } + return URL(string: raw) + } } @MainActor final class TimerUpdateScheduler: UpdateSchedulerProtocol { - @discardableResult - func schedule( - after interval: TimeInterval, - _ action: @escaping @Sendable @MainActor () -> Void - ) -> UpdateSchedulerCancellable { - let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in - Task { @MainActor in - action() - } - } - return TimerUpdateSchedulerToken(timer: timer) + @discardableResult + func schedule( + after interval: TimeInterval, + _ action: @escaping @Sendable @MainActor () -> Void + ) -> UpdateSchedulerCancellable { + let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in + Task { @MainActor in + action() + } } - - @discardableResult - func scheduleRepeating( - every interval: TimeInterval, - _ action: @escaping @Sendable @MainActor () -> Void - ) -> UpdateSchedulerCancellable { - let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in - Task { @MainActor in - action() - } - } - return TimerUpdateSchedulerToken(timer: timer) + return TimerUpdateSchedulerToken(timer: timer) + } + + @discardableResult + func scheduleRepeating( + every interval: TimeInterval, + _ action: @escaping @Sendable @MainActor () -> Void + ) -> UpdateSchedulerCancellable { + let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in + Task { @MainActor in + action() + } } + return TimerUpdateSchedulerToken(timer: timer) + } } private final class TimerUpdateSchedulerToken: UpdateSchedulerCancellable { - private weak var timer: Timer? + private weak var timer: Timer? - init(timer: Timer) { - self.timer = timer - } + init(timer: Timer) { + self.timer = timer + } - func cancel() { - timer?.invalidate() - } + func cancel() { + timer?.invalidate() + } } diff --git a/idx0/Services/Updates/AppcastFeedBuilder.swift b/idx0/Services/Updates/AppcastFeedBuilder.swift index 1673fef..72c887e 100644 --- a/idx0/Services/Updates/AppcastFeedBuilder.swift +++ b/idx0/Services/Updates/AppcastFeedBuilder.swift @@ -1,140 +1,140 @@ import Foundation struct AppcastReleaseEntry: Equatable { - var version: String - var downloadURL: URL - var length: Int - var publishedAt: Date - var prerelease: Bool - var signature: String? - var minimumSystemVersion: String? - var notesURL: URL? + var version: String + var downloadURL: URL + var length: Int + var publishedAt: Date + var prerelease: Bool + var signature: String? + var minimumSystemVersion: String? + var notesURL: URL? } enum AppcastFeedBuilder { - static func buildXML( - entries: [AppcastReleaseEntry], - title: String = "IDX0", - includePrerelease: Bool = false - ) -> String { - let filtered = entries - .filter { includePrerelease || !$0.prerelease } - .sorted(by: sortEntries) - - guard !filtered.isEmpty else { - return """ - - - - \(xmlEscape("\(title) Updates")) - \(xmlEscape("Latest releases for \(title)")) - - - """ - } - - var items: [String] = [] - for entry in filtered { - var enclosureAttributes: [String: String] = [ - "url": entry.downloadURL.absoluteString, - "length": "\(entry.length)", - "type": "application/octet-stream", - "sparkle:version": buildVersion(from: entry.version), - "sparkle:shortVersionString": entry.version, - ] - - if let signature = entry.signature, !signature.isEmpty { - enclosureAttributes["sparkle:edSignature"] = signature - } - if let minimumSystemVersion = entry.minimumSystemVersion, !minimumSystemVersion.isEmpty { - enclosureAttributes["sparkle:minimumSystemVersion"] = minimumSystemVersion - } - - let enclosureText = enclosureAttributes - .sorted(by: { $0.key < $1.key }) - .map { key, value in "\(key)=\"\(xmlEscape(value))\"" } - .joined(separator: " ") - - var itemLines: [String] = [ - " ", - " \(xmlEscape("\(title) \(entry.version)"))", - " \(xmlEscape(httpDateFormatter.string(from: entry.publishedAt)))", - " ", - ] - - if let notesURL = entry.notesURL { - itemLines.append(" \(xmlEscape(notesURL.absoluteString))") - } - - itemLines.append(" ") - items.append(itemLines.joined(separator: "\n")) - } - - return """ - - - - \(xmlEscape("\(title) Updates")) - \(xmlEscape("Latest releases for \(title)")) - \(items.joined(separator: "\n")) - - - """ + static func buildXML( + entries: [AppcastReleaseEntry], + title: String = "IDX0", + includePrerelease: Bool = false + ) -> String { + let filtered = entries + .filter { includePrerelease || !$0.prerelease } + .sorted(by: sortEntries) + + guard !filtered.isEmpty else { + return """ + + + + \(xmlEscape("\(title) Updates")) + \(xmlEscape("Latest releases for \(title)")) + + + """ } - private static func sortEntries(lhs: AppcastReleaseEntry, rhs: AppcastReleaseEntry) -> Bool { - let l = semanticVersionComponents(lhs.version) - let r = semanticVersionComponents(rhs.version) - - if l.numeric != r.numeric { - for index in 0.. rv - } - } - } - - if l.isPrerelease != r.isPrerelease { - return !l.isPrerelease - } - - return lhs.publishedAt > rhs.publishedAt + var items: [String] = [] + for entry in filtered { + var enclosureAttributes: [String: String] = [ + "url": entry.downloadURL.absoluteString, + "length": "\(entry.length)", + "type": "application/octet-stream", + "sparkle:version": buildVersion(from: entry.version), + "sparkle:shortVersionString": entry.version, + ] + + if let signature = entry.signature, !signature.isEmpty { + enclosureAttributes["sparkle:edSignature"] = signature + } + if let minimumSystemVersion = entry.minimumSystemVersion, !minimumSystemVersion.isEmpty { + enclosureAttributes["sparkle:minimumSystemVersion"] = minimumSystemVersion + } + + let enclosureText = enclosureAttributes + .sorted(by: { $0.key < $1.key }) + .map { key, value in "\(key)=\"\(xmlEscape(value))\"" } + .joined(separator: " ") + + var itemLines: [String] = [ + " ", + " \(xmlEscape("\(title) \(entry.version)"))", + " \(xmlEscape(httpDateFormatter.string(from: entry.publishedAt)))", + " ", + ] + + if let notesURL = entry.notesURL { + itemLines.append(" \(xmlEscape(notesURL.absoluteString))") + } + + itemLines.append(" ") + items.append(itemLines.joined(separator: "\n")) } - private static func semanticVersionComponents(_ raw: String) -> (numeric: [Int], isPrerelease: Bool) { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - .replacingOccurrences(of: "^v", with: "", options: .regularExpression) - let parts = trimmed.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false) - let numeric = parts.first? - .split(separator: ".") - .map { Int($0) ?? 0 } ?? [] - return (numeric: numeric, isPrerelease: parts.count > 1) - } - - private static func buildVersion(from version: String) -> String { - let digits = version.compactMap { char -> String? in - char.isNumber ? String(char) : nil + return """ + + + + \(xmlEscape("\(title) Updates")) + \(xmlEscape("Latest releases for \(title)")) + \(items.joined(separator: "\n")) + + + """ + } + + private static func sortEntries(lhs: AppcastReleaseEntry, rhs: AppcastReleaseEntry) -> Bool { + let l = semanticVersionComponents(lhs.version) + let r = semanticVersionComponents(rhs.version) + + if l.numeric != r.numeric { + for index in 0 ..< max(l.numeric.count, r.numeric.count) { + let lv = index < l.numeric.count ? l.numeric[index] : 0 + let rv = index < r.numeric.count ? r.numeric[index] : 0 + if lv != rv { + return lv > rv } - let compact = digits.joined() - return compact.isEmpty ? version : compact + } } - private static func xmlEscape(_ text: String) -> String { - text - .replacingOccurrences(of: "&", with: "&") - .replacingOccurrences(of: "<", with: "<") - .replacingOccurrences(of: ">", with: ">") - .replacingOccurrences(of: "\"", with: """) - .replacingOccurrences(of: "'", with: "'") + if l.isPrerelease != r.isPrerelease { + return !l.isPrerelease } - private static let httpDateFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone(secondsFromGMT: 0) - formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z" - return formatter - }() + return lhs.publishedAt > rhs.publishedAt + } + + private static func semanticVersionComponents(_ raw: String) -> (numeric: [Int], isPrerelease: Bool) { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "^v", with: "", options: .regularExpression) + let parts = trimmed.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false) + let numeric = parts.first? + .split(separator: ".") + .map { Int($0) ?? 0 } ?? [] + return (numeric: numeric, isPrerelease: parts.count > 1) + } + + private static func buildVersion(from version: String) -> String { + let digits = version.compactMap { char -> String? in + char.isNumber ? String(char) : nil + } + let compact = digits.joined() + return compact.isEmpty ? version : compact + } + + private static func xmlEscape(_ text: String) -> String { + text + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "\"", with: """) + .replacingOccurrences(of: "'", with: "'") + } + + private static let httpDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z" + return formatter + }() } diff --git a/idx0/Services/Updates/SparkleUpdateDriver.swift b/idx0/Services/Updates/SparkleUpdateDriver.swift index d9f488b..8cdc107 100644 --- a/idx0/Services/Updates/SparkleUpdateDriver.swift +++ b/idx0/Services/Updates/SparkleUpdateDriver.swift @@ -1,210 +1,213 @@ import AppKit import Foundation #if canImport(Sparkle) -import Sparkle + import Sparkle #endif @MainActor final class SparkleUpdateDriver: AppUpdateDriverProtocol { - var onEvent: ((AppUpdateDriverEvent) -> Void)? - - private let environment: EnvironmentProviding - private let parser = AppcastFeedParser() - private var latestDownloadURL: URL? - private var checkTask: Task? - - init(environment: EnvironmentProviding = ProcessEnvironmentProvider()) { - self.environment = environment - } - - deinit { - checkTask?.cancel() + var onEvent: ((AppUpdateDriverEvent) -> Void)? + + private let environment: EnvironmentProviding + private let parser = AppcastFeedParser() + private var latestDownloadURL: URL? + private var checkTask: Task? + + init(environment: EnvironmentProviding = ProcessEnvironmentProvider()) { + self.environment = environment + } + + deinit { + checkTask?.cancel() + } + + func checkForUpdates(feedURLOverride: URL?, currentVersion: String) { + let feedURL = feedURLOverride ?? environment.defaultUpdateFeedURL + guard let feedURL else { + onEvent?(.checkFailed(message: "Update feed URL is not configured.")) + return } - func checkForUpdates(feedURLOverride: URL?, currentVersion: String) { - let feedURL = feedURLOverride ?? environment.defaultUpdateFeedURL - guard let feedURL else { - onEvent?(.checkFailed(message: "Update feed URL is not configured.")) - return - } - - checkTask?.cancel() - checkTask = Task { - do { - let (data, response) = try await URLSession.shared.data(from: feedURL) - if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { - self.onEvent?(.checkFailed(message: "Update feed request failed with status \(http.statusCode).")) - return - } - - guard let item = parser.parseFirstItem(from: data) else { - self.latestDownloadURL = nil - self.onEvent?(.checkSucceeded(availableVersion: nil, downloadURL: nil)) - return - } - - let availableVersion = item.version - let downloadURL = item.downloadURL - let hasUpdate = isVersion(availableVersion, newerThan: currentVersion) - - self.latestDownloadURL = hasUpdate ? downloadURL : nil - self.onEvent?( - .checkSucceeded( - availableVersion: hasUpdate ? availableVersion : nil, - downloadURL: hasUpdate ? downloadURL : nil - ) - ) - } catch { - if Task.isCancelled { - return - } - self.onEvent?(.checkFailed(message: error.localizedDescription)) - } + checkTask?.cancel() + checkTask = Task { + do { + let (data, response) = try await URLSession.shared.data(from: feedURL) + if let http = response as? HTTPURLResponse, !(200 ..< 300).contains(http.statusCode) { + self.onEvent?(.checkFailed(message: "Update feed request failed with status \(http.statusCode).")) + return } - } - func downloadUpdate() { - guard let url = latestDownloadURL else { - onEvent?(.downloadFailed(message: "No update download URL is available.")) - return + guard let item = parser.parseFirstItem(from: data) else { + self.latestDownloadURL = nil + self.onEvent?(.checkSucceeded(availableVersion: nil, downloadURL: nil)) + return } - Task { - onEvent?(.downloadProgress(0.1)) - try? await Task.sleep(nanoseconds: 120_000_000) - onEvent?(.downloadProgress(0.5)) - _ = NSWorkspace.shared.open(url) - onEvent?(.downloadProgress(1.0)) - onEvent?(.downloadCompleted) + let availableVersion = item.version + let downloadURL = item.downloadURL + let hasUpdate = isVersion(availableVersion, newerThan: currentVersion) + + self.latestDownloadURL = hasUpdate ? downloadURL : nil + self.onEvent?( + .checkSucceeded( + availableVersion: hasUpdate ? availableVersion : nil, + downloadURL: hasUpdate ? downloadURL : nil + ) + ) + } catch { + if Task.isCancelled { + return } + self.onEvent?(.checkFailed(message: error.localizedDescription)) + } } + } - func installUpdate() { - guard let url = latestDownloadURL else { - onEvent?(.installFailed(message: "No downloaded update is available to install.")) - return - } + func downloadUpdate() { + guard let url = latestDownloadURL else { + onEvent?(.downloadFailed(message: "No update download URL is available.")) + return + } - if !NSWorkspace.shared.open(url) { - onEvent?(.installFailed(message: "Could not open the downloaded update package.")) - } + Task { + onEvent?(.downloadProgress(0.1)) + try? await Task.sleep(nanoseconds: 120_000_000) + onEvent?(.downloadProgress(0.5)) + _ = NSWorkspace.shared.open(url) + onEvent?(.downloadProgress(1.0)) + onEvent?(.downloadCompleted) } + } - private func isVersion(_ lhsRaw: String, newerThan rhsRaw: String) -> Bool { - let lhs = normalizeVersion(lhsRaw) - let rhs = normalizeVersion(rhsRaw) - - if lhs.numeric != rhs.numeric { - let maxCount = max(lhs.numeric.count, rhs.numeric.count) - for index in 0.. r - } - } - } + func installUpdate() { + guard let url = latestDownloadURL else { + onEvent?(.installFailed(message: "No downloaded update is available to install.")) + return + } - switch (lhs.hasPrerelease, rhs.hasPrerelease) { - case (false, true): - return true - case (true, false): - return false - default: - return lhs.raw > rhs.raw + if !NSWorkspace.shared.open(url) { + onEvent?(.installFailed(message: "Could not open the downloaded update package.")) + } + } + + private func isVersion(_ lhsRaw: String, newerThan rhsRaw: String) -> Bool { + let lhs = normalizeVersion(lhsRaw) + let rhs = normalizeVersion(rhsRaw) + + if lhs.numeric != rhs.numeric { + let maxCount = max(lhs.numeric.count, rhs.numeric.count) + for index in 0 ..< maxCount { + let l = index < lhs.numeric.count ? lhs.numeric[index] : 0 + let r = index < rhs.numeric.count ? rhs.numeric[index] : 0 + if l != r { + return l > r } + } } - private func normalizeVersion(_ raw: String) -> (raw: String, numeric: [Int], hasPrerelease: Bool) { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "v", with: "", options: [.caseInsensitive], range: raw.hasPrefix("v") ? raw.startIndex.. 1 - return (raw: trimmed, numeric: numeric, hasPrerelease: hasPrerelease) + switch (lhs.hasPrerelease, rhs.hasPrerelease) { + case (false, true): + return true + case (true, false): + return false + default: + return lhs.raw > rhs.raw } + } + + private func normalizeVersion(_ raw: String) -> (raw: String, numeric: [Int], hasPrerelease: Bool) { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "v", with: "", options: [.caseInsensitive], range: raw.hasPrefix("v") ? raw.startIndex ..< raw.index(after: raw.startIndex) : nil) + let prereleaseSplit = trimmed.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false) + let numericString = String(prereleaseSplit.first ?? "") + let numeric = numericString + .split(separator: ".") + .map { Int($0) ?? 0 } + let hasPrerelease = prereleaseSplit.count > 1 + return (raw: trimmed, numeric: numeric, hasPrerelease: hasPrerelease) + } } private struct AppcastItem { - let version: String - let downloadURL: URL + let version: String + let downloadURL: URL } private final class AppcastFeedParser: NSObject, XMLParserDelegate { - private var currentElement = "" - private var inItem = false - private var capturedVersion: String? - private var capturedURL: URL? - private var textBuffer = "" - - func parseFirstItem(from data: Data) -> AppcastItem? { - currentElement = "" - inItem = false - capturedVersion = nil - capturedURL = nil - textBuffer = "" - - let parser = XMLParser(data: data) - parser.delegate = self - parser.parse() - - guard let version = capturedVersion, - let url = capturedURL else { - return nil - } - - return AppcastItem(version: version, downloadURL: url) + private var currentElement = "" + private var inItem = false + private var capturedVersion: String? + private var capturedURL: URL? + private var textBuffer = "" + + func parseFirstItem(from data: Data) -> AppcastItem? { + currentElement = "" + inItem = false + capturedVersion = nil + capturedURL = nil + textBuffer = "" + + let parser = XMLParser(data: data) + parser.delegate = self + parser.parse() + + guard let version = capturedVersion, + let url = capturedURL + else { + return nil } - func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String : String] = [:]) { - currentElement = qName ?? elementName - textBuffer = "" + return AppcastItem(version: version, downloadURL: url) + } - if currentElement == "item" { - inItem = true - return - } + func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]) { + currentElement = qName ?? elementName + textBuffer = "" - guard inItem, currentElement == "enclosure" else { return } + if currentElement == "item" { + inItem = true + return + } - if capturedURL == nil, - let rawURL = attributeDict["url"], - let url = URL(string: rawURL) { - capturedURL = url - } + guard inItem, currentElement == "enclosure" else { return } - if capturedVersion == nil { - if let shortVersion = attributeDict["sparkle:shortVersionString"], !shortVersion.isEmpty { - capturedVersion = shortVersion - } else if let version = attributeDict["sparkle:version"], !version.isEmpty { - capturedVersion = version - } - } + if capturedURL == nil, + let rawURL = attributeDict["url"], + let url = URL(string: rawURL) + { + capturedURL = url } - func parser(_ parser: XMLParser, foundCharacters string: String) { - guard inItem else { return } - textBuffer += string + if capturedVersion == nil { + if let shortVersion = attributeDict["sparkle:shortVersionString"], !shortVersion.isEmpty { + capturedVersion = shortVersion + } else if let version = attributeDict["sparkle:version"], !version.isEmpty { + capturedVersion = version + } } + } - func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) { - let name = qName ?? elementName + func parser(_ parser: XMLParser, foundCharacters string: String) { + guard inItem else { return } + textBuffer += string + } - guard inItem else { return } + func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) { + let name = qName ?? elementName - let value = textBuffer.trimmingCharacters(in: .whitespacesAndNewlines) - if capturedVersion == nil, - (name == "sparkle:shortVersionString" || name == "sparkle:version"), - !value.isEmpty { - capturedVersion = value - } + guard inItem else { return } - if name == "item" { - inItem = false - } + let value = textBuffer.trimmingCharacters(in: .whitespacesAndNewlines) + if capturedVersion == nil, + name == "sparkle:shortVersionString" || name == "sparkle:version", + !value.isEmpty + { + capturedVersion = value + } - textBuffer = "" + if name == "item" { + inItem = false } + + textBuffer = "" + } } diff --git a/idx0/UI/CommandPaletteOverlay.swift b/idx0/UI/CommandPaletteOverlay.swift index 4a88d9f..0fbf718 100644 --- a/idx0/UI/CommandPaletteOverlay.swift +++ b/idx0/UI/CommandPaletteOverlay.swift @@ -1,526 +1,525 @@ import SwiftUI struct CommandPaletteOverlay: View { - @EnvironmentObject private var coordinator: AppCoordinator - @EnvironmentObject private var sessionService: SessionService - @EnvironmentObject private var workflowService: WorkflowService - @EnvironmentObject private var appUpdateService: AppUpdateService - @Environment(\.themeColors) private var tc + @EnvironmentObject private var coordinator: AppCoordinator + @EnvironmentObject private var sessionService: SessionService + @EnvironmentObject private var workflowService: WorkflowService + @EnvironmentObject private var appUpdateService: AppUpdateService + @Environment(\.themeColors) private var tc - @FocusState private var queryFocused: Bool - @State private var query = "" - @State private var selectedIndex = 0 - @State private var hoverReady = false - @State private var lastSelectionSource: SelectionSource = .keyboard - private enum SelectionSource { case keyboard, hover } + @FocusState private var queryFocused: Bool + @State private var query = "" + @State private var selectedIndex = 0 + @State private var hoverReady = false + @State private var lastSelectionSource: SelectionSource = .keyboard + private enum SelectionSource { case keyboard, hover } - var body: some View { - ZStack { - // Invisible dismiss layer (no dimming) - Color.clear - .contentShape(Rectangle()) - .ignoresSafeArea() - .onTapGesture { dismiss() } + var body: some View { + ZStack { + // Invisible dismiss layer (no dimming) + Color.clear + .contentShape(Rectangle()) + .ignoresSafeArea() + .onTapGesture { dismiss() } - VStack(spacing: 0) { - // Search field - HStack(spacing: 8) { - Image(systemName: "magnifyingglass") - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(tc.accent) + VStack(spacing: 0) { + // Search field + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(tc.accent) - TextField("Search...", text: $query) - .textFieldStyle(.plain) - .font(.system(size: 13)) - .focused($queryFocused) - .onSubmit { executeSelected() } - .onChange(of: query) { _, _ in selectedIndex = 0 } + TextField("Search...", text: $query) + .textFieldStyle(.plain) + .font(.system(size: 13)) + .focused($queryFocused) + .onSubmit { executeSelected() } + .onChange(of: query) { _, _ in selectedIndex = 0 } - if !query.isEmpty { - Button { - query = "" - } label: { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 10)) - .foregroundStyle(tc.tertiaryText) - } - .buttonStyle(.plain) - .idxHitTarget() - } + if !query.isEmpty { + Button { + query = "" + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 10)) + .foregroundStyle(tc.tertiaryText) + } + .buttonStyle(.plain) + .idxHitTarget() + } - keyBadge("esc") - .idxHitTarget() - .onTapGesture { dismiss() } - } - .padding(.horizontal, 12) - .padding(.vertical, 9) + keyBadge("esc") + .idxHitTarget() + .onTapGesture { dismiss() } + } + .padding(.horizontal, 12) + .padding(.vertical, 9) - Rectangle() - .fill(tc.divider) - .frame(height: 1) + Rectangle() + .fill(tc.divider) + .frame(height: 1) - // Results - if !filteredActions.isEmpty { - ScrollViewReader { proxy in - ScrollView { - LazyVStack(spacing: 2) { - ForEach(Array(filteredActions.prefix(12).enumerated()), id: \.element.id) { index, action in - paletteRow(action: action, isSelected: index == selectedIndex) - .id(action.id) - .onTapGesture { - guard action.isEnabled else { return } - selectedIndex = index - executeSelected() - } - .onHover { hovering in - guard hoverReady, hovering, action.isEnabled else { return } - lastSelectionSource = .hover - selectedIndex = index - } - } - } - .padding(6) - } - .frame(maxHeight: 360) - .scrollIndicators(.hidden) - .onChange(of: selectedIndex) { _, newValue in - guard lastSelectionSource == .keyboard else { return } - if let action = filteredActions.prefix(12).dropFirst(newValue).first { - withAnimation(.easeOut(duration: 0.08)) { - proxy.scrollTo(action.id, anchor: .center) - } - } - } + // Results + if !filteredActions.isEmpty { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 2) { + ForEach(Array(filteredActions.prefix(12).enumerated()), id: \.element.id) { index, action in + paletteRow(action: action, isSelected: index == selectedIndex) + .id(action.id) + .onTapGesture { + guard action.isEnabled else { return } + selectedIndex = index + executeSelected() } - } else { - HStack { - Image(systemName: "magnifyingglass") - .font(.system(size: 10)) - .foregroundStyle(tc.tertiaryText) - Text("No results for \"\(query)\"") - .font(.system(size: 11)) - .foregroundStyle(tc.tertiaryText) + .onHover { hovering in + guard hoverReady, hovering, action.isEnabled else { return } + lastSelectionSource = .hover + selectedIndex = index } - .padding(12) } + } + .padding(6) } - .frame(width: 420) - .background(tc.sidebarBackground, in: RoundedRectangle(cornerRadius: 10)) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(tc.surface2.opacity(0.4), lineWidth: 1) - ) - .shadow(color: .black.opacity(0.35), radius: 20, y: 6) - .padding(.top, 60) - .frame(maxHeight: .infinity, alignment: .top) - } - .onAppear { - hoverReady = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - queryFocused = true - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - hoverReady = true + .frame(maxHeight: 360) + .scrollIndicators(.hidden) + .onChange(of: selectedIndex) { _, newValue in + guard lastSelectionSource == .keyboard else { return } + if let action = filteredActions.prefix(12).dropFirst(newValue).first { + withAnimation(.easeOut(duration: 0.08)) { + proxy.scrollTo(action.id, anchor: .center) + } + } } + } + } else { + HStack { + Image(systemName: "magnifyingglass") + .font(.system(size: 10)) + .foregroundStyle(tc.tertiaryText) + Text("No results for \"\(query)\"") + .font(.system(size: 11)) + .foregroundStyle(tc.tertiaryText) + } + .padding(12) } - .onKeyPress(.escape) { dismiss(); return .handled } - .onKeyPress(.downArrow) { moveSelection(1); return .handled } - .onKeyPress(.upArrow) { moveSelection(-1); return .handled } + } + .frame(width: 420) + .background(tc.sidebarBackground, in: RoundedRectangle(cornerRadius: 10)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(tc.surface2.opacity(0.4), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.35), radius: 20, y: 6) + .padding(.top, 60) + .frame(maxHeight: .infinity, alignment: .top) } + .onAppear { + hoverReady = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + queryFocused = true + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + hoverReady = true + } + } + .onKeyPress(.escape) { dismiss(); return .handled } + .onKeyPress(.downArrow) { moveSelection(1); return .handled } + .onKeyPress(.upArrow) { moveSelection(-1); return .handled } + } - @ViewBuilder - private func paletteRow(action: PaletteAction, isSelected: Bool) -> some View { - HStack(spacing: 10) { - Group { - if let imageName = action.iconImageName { - Image(imageName) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 13, height: 13) - } else { - Image(systemName: action.icon) - .font(.system(size: 11, weight: .medium)) - } - } - .foregroundStyle( - !action.isEnabled ? tc.mutedText : - isSelected ? tc.accent : tc.secondaryText - ) - .frame(width: 24, height: 24) - .background( - !action.isEnabled ? tc.surface0 : - isSelected ? tc.accent.opacity(0.1) : tc.surface1, - in: RoundedRectangle(cornerRadius: 6, style: .continuous) - ) + private func paletteRow(action: PaletteAction, isSelected: Bool) -> some View { + HStack(spacing: 10) { + Group { + if let imageName = action.iconImageName { + Image(imageName) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 13, height: 13) + } else { + Image(systemName: action.icon) + .font(.system(size: 11, weight: .medium)) + } + } + .foregroundStyle( + !action.isEnabled ? tc.mutedText : + isSelected ? tc.accent : tc.secondaryText + ) + .frame(width: 24, height: 24) + .background( + !action.isEnabled ? tc.surface0 : + isSelected ? tc.accent.opacity(0.1) : tc.surface1, + in: RoundedRectangle(cornerRadius: 6, style: .continuous) + ) - VStack(alignment: .leading, spacing: 1) { - Text(highlightedTitle(action.title)) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(action.isEnabled ? tc.primaryText : tc.tertiaryText) - .lineLimit(1) + VStack(alignment: .leading, spacing: 1) { + Text(highlightedTitle(action.title)) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(action.isEnabled ? tc.primaryText : tc.tertiaryText) + .lineLimit(1) - Text(action.detail) - .font(.system(size: 9, weight: .medium, design: .monospaced)) - .foregroundStyle(tc.tertiaryText) - .lineLimit(1) - } + Text(action.detail) + .font(.system(size: 9, weight: .medium, design: .monospaced)) + .foregroundStyle(tc.tertiaryText) + .lineLimit(1) + } - Spacer(minLength: 0) + Spacer(minLength: 0) - if let shortcut = action.shortcut { - keyBadge(shortcut) - } - } - .padding(.horizontal, 8) - .padding(.vertical, 5) - .background(isSelected ? tc.surface0 : Color.clear, in: RoundedRectangle(cornerRadius: 6)) - .contentShape(RoundedRectangle(cornerRadius: 6)) + if let shortcut = action.shortcut { + keyBadge(shortcut) + } } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(isSelected ? tc.surface0 : Color.clear, in: RoundedRectangle(cornerRadius: 6)) + .contentShape(RoundedRectangle(cornerRadius: 6)) + } - private func keyBadge(_ text: String) -> some View { - Text(text) - .font(.system(size: 9, weight: .medium, design: .monospaced)) - .foregroundStyle(tc.tertiaryText) - .padding(.horizontal, 5) - .padding(.vertical, 2) - .background(tc.surface1, in: RoundedRectangle(cornerRadius: 3)) - } + private func keyBadge(_ text: String) -> some View { + Text(text) + .font(.system(size: 9, weight: .medium, design: .monospaced)) + .foregroundStyle(tc.tertiaryText) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(tc.surface1, in: RoundedRectangle(cornerRadius: 3)) + } - private func highlightedTitle(_ title: String) -> AttributedString { - FuzzyMatch.highlight(query: query, in: title) - } + private func highlightedTitle(_ title: String) -> AttributedString { + FuzzyMatch.highlight(query: query, in: title) + } - private func moveSelection(_ delta: Int) { - let max = min(filteredActions.count, 12) - 1 - guard max >= 0 else { return } - lastSelectionSource = .keyboard - selectedIndex = min(max, Swift.max(0, selectedIndex + delta)) - } + private func moveSelection(_ delta: Int) { + let max = min(filteredActions.count, 12) - 1 + guard max >= 0 else { return } + lastSelectionSource = .keyboard + selectedIndex = min(max, Swift.max(0, selectedIndex + delta)) + } - private func executeSelected() { - let actions = Array(filteredActions.prefix(12)) - guard selectedIndex < actions.count else { return } - let action = actions[selectedIndex] - guard action.isEnabled else { return } - dismiss() - DispatchQueue.main.async { action.run() } - } + private func executeSelected() { + let actions = Array(filteredActions.prefix(12)) + guard selectedIndex < actions.count else { return } + let action = actions[selectedIndex] + guard action.isEnabled else { return } + dismiss() + DispatchQueue.main.async { action.run() } + } - private func dismiss() { - coordinator.dismissCommandPalette() - } + private func dismiss() { + coordinator.dismissCommandPalette() + } - private var filteredActions: [PaletteAction] { - let normalized = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let actions = allActions - guard !normalized.isEmpty else { return actions } - return actions.filter { action in - FuzzyMatch.matches(query: normalized, text: action.searchText) - }.sorted { lhs, rhs in - FuzzyMatch.score(query: normalized, text: lhs.searchText) > FuzzyMatch.score(query: normalized, text: rhs.searchText) - } + private var filteredActions: [PaletteAction] { + let normalized = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let actions = allActions + guard !normalized.isEmpty else { return actions } + return actions.filter { action in + FuzzyMatch.matches(query: normalized, text: action.searchText) + }.sorted { lhs, rhs in + FuzzyMatch.score(query: normalized, text: lhs.searchText) > FuzzyMatch.score(query: normalized, text: rhs.searchText) } + } - private func shortcutLabel(_ action: ShortcutActionID) -> String? { - ShortcutRegistry.shared.displayLabel(for: action, settings: sessionService.settings) - } + private func shortcutLabel(_ action: ShortcutActionID) -> String? { + ShortcutRegistry.shared.displayLabel(for: action, settings: sessionService.settings) + } - private var allActions: [PaletteAction] { - let selected = sessionService.selectedSession - let selectedID = selected?.id - let hasWorktree = selected?.worktreePath != nil - let canOpenClipboard = !(NSPasteboard.general.string(forType: .string)? - .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) - let showsVibe = sessionService.settings.appMode.showsVibeFeatures - let niriMode = sessionService.settings.niriCanvasEnabled - let visibleApps = NiriAppUIVisibility.commandPaletteApps(from: sessionService.registeredNiriApps) + private var allActions: [PaletteAction] { + let selected = sessionService.selectedSession + let selectedID = selected?.id + let hasWorktree = selected?.worktreePath != nil + let canOpenClipboard = !(NSPasteboard.general.string(forType: .string)? + .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) + let showsVibe = sessionService.settings.appMode.showsVibeFeatures + let niriMode = sessionService.settings.niriCanvasEnabled + let visibleApps = NiriAppUIVisibility.commandPaletteApps(from: sessionService.registeredNiriApps) - var actions: [PaletteAction] = [ - PaletteAction( - id: "quick-session", icon: "plus", title: "New Quick Session", - detail: "Create an instant terminal session", - shortcut: shortcutLabel(.newQuickSession), searchText: "new quick session instant terminal", - isEnabled: true, run: { _ = coordinator.performCommand(.newQuickSession) } - ), - PaletteAction( - id: "repo-session", icon: "folder", title: "New Repo/Worktree Session", - detail: "Open structured setup for repo or worktree", - shortcut: shortcutLabel(.newRepoWorktreeSession), searchText: "new repo worktree structured session setup", - isEnabled: true, run: { _ = coordinator.performCommand(.newRepoWorktreeSession) } - ), - PaletteAction( - id: "switch-session", icon: "arrow.left.arrow.right", title: "Quick Switch Session", - detail: "Jump to a session by name", - shortcut: shortcutLabel(.quickSwitchSession), searchText: "switch session jump focus quick", - isEnabled: !sessionService.sessions.isEmpty, - run: { _ = coordinator.performCommand(.quickSwitchSession) } - ), - PaletteAction( - id: "rename-session", icon: "pencil", title: "Rename Session", - detail: "Change the title of the current session", - shortcut: shortcutLabel(.renameSession), searchText: "rename session title", - isEnabled: selectedID != nil, - run: { _ = coordinator.performCommand(.renameSession) } - ), - PaletteAction( - id: "close-session", icon: "xmark", title: "Close Session", - detail: "Close the current session", - shortcut: shortcutLabel(.closeSession), searchText: "close session", - isEnabled: selectedID != nil, - run: { _ = coordinator.performCommand(.closeSession) } - ), - PaletteAction( - id: "relaunch-session", icon: "arrow.clockwise", title: "Relaunch Session", - detail: "Restart the current terminal session", - shortcut: shortcutLabel(.relaunchSession), searchText: "relaunch session restart terminal", - isEnabled: selectedID != nil, - run: { _ = coordinator.performCommand(.relaunchSession) } - ), - PaletteAction( - id: "toggle-sidebar", icon: "sidebar.left", title: "Toggle Sidebar", - detail: "Show or hide the sidebar", - shortcut: shortcutLabel(.toggleSidebar), searchText: "toggle sidebar show hide", - isEnabled: true, - run: { _ = coordinator.performCommand(.toggleSidebar) } - ), - PaletteAction( - id: "keyboard-shortcuts", icon: "keyboard", title: "Keyboard Shortcuts", - detail: "View all keyboard shortcuts", - shortcut: shortcutLabel(.keyboardShortcuts), searchText: "keyboard shortcuts help keys bindings", - isEnabled: true, - run: { _ = coordinator.performCommand(.keyboardShortcuts) } - ), - PaletteAction( - id: "split-right", icon: "rectangle.split.2x1", title: niriMode ? "Niri: Add Terminal Right" : "Split Pane Right", - detail: niriMode ? "Create a terminal tile to the right" : "Split the current pane vertically", - shortcut: shortcutLabel(.splitRight), searchText: "split pane right vertical niri terminal", - isEnabled: selectedID != nil, - run: { _ = coordinator.performCommand(.splitRight) } - ), - PaletteAction( - id: "split-down", icon: "rectangle.split.1x2", title: niriMode ? "Niri: Add Task Below" : "Split Pane Down", - detail: niriMode ? "Create a terminal tile below in this task stack" : "Split the current pane horizontally", - shortcut: shortcutLabel(.splitDown), searchText: "split pane down horizontal niri task below", - isEnabled: selectedID != nil, - run: { _ = coordinator.performCommand(.splitDown) } - ), - PaletteAction( - id: "close-pane", icon: "xmark.rectangle", title: niriMode ? "Close Tile" : "Close Pane", - detail: niriMode ? "Close the focused tile" : "Close the focused pane", - shortcut: shortcutLabel(.closePane), searchText: "close pane tile split", - isEnabled: selectedID != nil && (niriMode || sessionService.paneTrees[selectedID!] != nil), - run: { _ = coordinator.performCommand(.closePane) } - ), - PaletteAction( - id: "open-settings", icon: "gear", title: "Open Settings", - detail: "Open IDX0 preferences", - shortcut: shortcutLabel(.openSettings), searchText: "open settings preferences", - isEnabled: true, - run: { _ = coordinator.performCommand(.openSettings) } - ), - PaletteAction( - id: "check-for-updates", - icon: "arrow.triangle.2.circlepath", - title: appUpdateService.primaryActionTitle ?? "Check for Updates", - detail: appUpdateService.statusDescription, - shortcut: shortcutLabel(.checkForUpdates), - searchText: "check updates download install retry", - isEnabled: appUpdateService.canPerformPrimaryAction, - run: { _ = coordinator.performCommand(.checkForUpdates) } - ), - ] + var actions: [PaletteAction] = [ + PaletteAction( + id: "quick-session", icon: "plus", title: "New Quick Session", + detail: "Create an instant terminal session", + shortcut: shortcutLabel(.newQuickSession), searchText: "new quick session instant terminal", + isEnabled: true, run: { _ = coordinator.performCommand(.newQuickSession) } + ), + PaletteAction( + id: "repo-session", icon: "folder", title: "New Repo/Worktree Session", + detail: "Open structured setup for repo or worktree", + shortcut: shortcutLabel(.newRepoWorktreeSession), searchText: "new repo worktree structured session setup", + isEnabled: true, run: { _ = coordinator.performCommand(.newRepoWorktreeSession) } + ), + PaletteAction( + id: "switch-session", icon: "arrow.left.arrow.right", title: "Quick Switch Session", + detail: "Jump to a session by name", + shortcut: shortcutLabel(.quickSwitchSession), searchText: "switch session jump focus quick", + isEnabled: !sessionService.sessions.isEmpty, + run: { _ = coordinator.performCommand(.quickSwitchSession) } + ), + PaletteAction( + id: "rename-session", icon: "pencil", title: "Rename Session", + detail: "Change the title of the current session", + shortcut: shortcutLabel(.renameSession), searchText: "rename session title", + isEnabled: selectedID != nil, + run: { _ = coordinator.performCommand(.renameSession) } + ), + PaletteAction( + id: "close-session", icon: "xmark", title: "Close Session", + detail: "Close the current session", + shortcut: shortcutLabel(.closeSession), searchText: "close session", + isEnabled: selectedID != nil, + run: { _ = coordinator.performCommand(.closeSession) } + ), + PaletteAction( + id: "relaunch-session", icon: "arrow.clockwise", title: "Relaunch Session", + detail: "Restart the current terminal session", + shortcut: shortcutLabel(.relaunchSession), searchText: "relaunch session restart terminal", + isEnabled: selectedID != nil, + run: { _ = coordinator.performCommand(.relaunchSession) } + ), + PaletteAction( + id: "toggle-sidebar", icon: "sidebar.left", title: "Toggle Sidebar", + detail: "Show or hide the sidebar", + shortcut: shortcutLabel(.toggleSidebar), searchText: "toggle sidebar show hide", + isEnabled: true, + run: { _ = coordinator.performCommand(.toggleSidebar) } + ), + PaletteAction( + id: "keyboard-shortcuts", icon: "keyboard", title: "Keyboard Shortcuts", + detail: "View all keyboard shortcuts", + shortcut: shortcutLabel(.keyboardShortcuts), searchText: "keyboard shortcuts help keys bindings", + isEnabled: true, + run: { _ = coordinator.performCommand(.keyboardShortcuts) } + ), + PaletteAction( + id: "split-right", icon: "rectangle.split.2x1", title: niriMode ? "Niri: Add Terminal Right" : "Split Pane Right", + detail: niriMode ? "Create a terminal tile to the right" : "Split the current pane vertically", + shortcut: shortcutLabel(.splitRight), searchText: "split pane right vertical niri terminal", + isEnabled: selectedID != nil, + run: { _ = coordinator.performCommand(.splitRight) } + ), + PaletteAction( + id: "split-down", icon: "rectangle.split.1x2", title: niriMode ? "Niri: Add Task Below" : "Split Pane Down", + detail: niriMode ? "Create a terminal tile below in this task stack" : "Split the current pane horizontally", + shortcut: shortcutLabel(.splitDown), searchText: "split pane down horizontal niri task below", + isEnabled: selectedID != nil, + run: { _ = coordinator.performCommand(.splitDown) } + ), + PaletteAction( + id: "close-pane", icon: "xmark.rectangle", title: niriMode ? "Close Tile" : "Close Pane", + detail: niriMode ? "Close the focused tile" : "Close the focused pane", + shortcut: shortcutLabel(.closePane), searchText: "close pane tile split", + isEnabled: selectedID != nil && (niriMode || sessionService.paneTrees[selectedID!] != nil), + run: { _ = coordinator.performCommand(.closePane) } + ), + PaletteAction( + id: "open-settings", icon: "gear", title: "Open Settings", + detail: "Open IDX0 preferences", + shortcut: shortcutLabel(.openSettings), searchText: "open settings preferences", + isEnabled: true, + run: { _ = coordinator.performCommand(.openSettings) } + ), + PaletteAction( + id: "check-for-updates", + icon: "arrow.triangle.2.circlepath", + title: appUpdateService.primaryActionTitle ?? "Check for Updates", + detail: appUpdateService.statusDescription, + shortcut: shortcutLabel(.checkForUpdates), + searchText: "check updates download install retry", + isEnabled: appUpdateService.canPerformPrimaryAction, + run: { _ = coordinator.performCommand(.checkForUpdates) } + ), + ] - if niriMode { - actions.append(contentsOf: [ - PaletteAction( - id: "vscode-setup-browser-debug", - icon: "ladybug", - title: "VS Code: Setup Browser Debug (idx-web)", - detail: "Create/update launch.json attach config and launch Chromium debug browser", - searchText: "vscode browser debug attach chrome launch json idx-web", - isEnabled: selectedID != nil, - run: { if let id = selectedID { _ = sessionService.setupVSCodeBrowserDebug(for: id) } } - ), - PaletteAction( - id: "niri-open-add-tile-menu", icon: "plus.circle", title: "Add Tile", - detail: "Open the tile spotlight to add a new tile", - shortcut: shortcutLabel(.niriOpenAddTileMenu), searchText: "add tile spotlight new terminal browser app plus", - isEnabled: selectedID != nil, - run: { _ = coordinator.performCommand(.niriOpenAddTileMenu) } - ), - PaletteAction( - id: "niri-overview", icon: "square.grid.3x3", title: "Niri: Toggle Overview", - detail: "Open or close Niri overview mode", - shortcut: shortcutLabel(.niriToggleOverview), searchText: "niri overview toggle canvas workspaces", - isEnabled: selectedID != nil, - run: { _ = coordinator.performCommand(.niriToggleOverview) } - ), - PaletteAction( - id: "niri-tabbed", icon: "rectangle.tophalf.inset.filled", title: "Niri: Toggle Column Tabbed Display", - detail: "Switch focused column between normal and tabbed", - shortcut: shortcutLabel(.niriToggleColumnTabbedDisplay), searchText: "niri toggle tabbed column display mode", - isEnabled: selectedID != nil, - run: { _ = coordinator.performCommand(.niriToggleColumnTabbedDisplay) } - ), - PaletteAction( - id: "niri-focused-zoom", icon: "arrow.up.left.and.arrow.down.right", title: "Niri: Toggle Focused Tile Zoom", - detail: "Make the focused tile fill the canvas viewport", - shortcut: shortcutLabel(.niriToggleFocusedTileZoom), searchText: "niri focused tile zoom fullscreen max", - isEnabled: selectedID != nil, - run: { _ = coordinator.performCommand(.niriToggleFocusedTileZoom) } - ), - PaletteAction( - id: "niri-snap", icon: "dot.scope", title: "Niri: Toggle Snap", - detail: sessionService.settings.niri.snapEnabled ? "Disable snap and keep free-pan release" : "Enable velocity-based snap", - shortcut: shortcutLabel(.niriToggleSnap), searchText: "niri snap soft snap free pan velocity", - isEnabled: true, - run: { _ = coordinator.performCommand(.niriToggleSnap) } - ), - PaletteAction( - id: "niri-focus-workspace-down", icon: "arrow.down.to.line", title: "Niri: Focus Workspace Down", - detail: "Move focus to the next workspace", - shortcut: shortcutLabel(.niriFocusWorkspaceDown), searchText: "niri workspace down focus next", - isEnabled: selectedID != nil, - run: { _ = coordinator.performCommand(.niriFocusWorkspaceDown) } - ), - PaletteAction( - id: "niri-focus-workspace-up", icon: "arrow.up.to.line", title: "Niri: Focus Workspace Up", - detail: "Move focus to the previous workspace", - shortcut: shortcutLabel(.niriFocusWorkspaceUp), searchText: "niri workspace up focus previous", - isEnabled: selectedID != nil, - run: { _ = coordinator.performCommand(.niriFocusWorkspaceUp) } - ), - PaletteAction( - id: "niri-move-column-down", icon: "arrow.down.square", title: "Niri: Move Column To Workspace Down", - detail: "Move focused column to the next workspace", - shortcut: shortcutLabel(.niriMoveColumnToWorkspaceDown), - searchText: "niri move column workspace down", - isEnabled: selectedID != nil, - run: { _ = coordinator.performCommand(.niriMoveColumnToWorkspaceDown) } - ), - PaletteAction( - id: "niri-move-column-up", icon: "arrow.up.square", title: "Niri: Move Column To Workspace Up", - detail: "Move focused column to the previous workspace", - shortcut: shortcutLabel(.niriMoveColumnToWorkspaceUp), - searchText: "niri move column workspace up", - isEnabled: selectedID != nil, - run: { _ = coordinator.performCommand(.niriMoveColumnToWorkspaceUp) } - ) - ]) + if niriMode { + actions.append(contentsOf: [ + PaletteAction( + id: "vscode-setup-browser-debug", + icon: "ladybug", + title: "VS Code: Setup Browser Debug (idx-web)", + detail: "Create/update launch.json attach config and launch Chromium debug browser", + searchText: "vscode browser debug attach chrome launch json idx-web", + isEnabled: selectedID != nil, + run: { if let id = selectedID { _ = sessionService.setupVSCodeBrowserDebug(for: id) } } + ), + PaletteAction( + id: "niri-open-add-tile-menu", icon: "plus.circle", title: "Add Tile", + detail: "Open the tile spotlight to add a new tile", + shortcut: shortcutLabel(.niriOpenAddTileMenu), searchText: "add tile spotlight new terminal browser app plus", + isEnabled: selectedID != nil, + run: { _ = coordinator.performCommand(.niriOpenAddTileMenu) } + ), + PaletteAction( + id: "niri-overview", icon: "square.grid.3x3", title: "Niri: Toggle Overview", + detail: "Open or close Niri overview mode", + shortcut: shortcutLabel(.niriToggleOverview), searchText: "niri overview toggle canvas workspaces", + isEnabled: selectedID != nil, + run: { _ = coordinator.performCommand(.niriToggleOverview) } + ), + PaletteAction( + id: "niri-tabbed", icon: "rectangle.tophalf.inset.filled", title: "Niri: Toggle Column Tabbed Display", + detail: "Switch focused column between normal and tabbed", + shortcut: shortcutLabel(.niriToggleColumnTabbedDisplay), searchText: "niri toggle tabbed column display mode", + isEnabled: selectedID != nil, + run: { _ = coordinator.performCommand(.niriToggleColumnTabbedDisplay) } + ), + PaletteAction( + id: "niri-focused-zoom", icon: "arrow.up.left.and.arrow.down.right", title: "Niri: Toggle Focused Tile Zoom", + detail: "Make the focused tile fill the canvas viewport", + shortcut: shortcutLabel(.niriToggleFocusedTileZoom), searchText: "niri focused tile zoom fullscreen max", + isEnabled: selectedID != nil, + run: { _ = coordinator.performCommand(.niriToggleFocusedTileZoom) } + ), + PaletteAction( + id: "niri-snap", icon: "dot.scope", title: "Niri: Toggle Snap", + detail: sessionService.settings.niri.snapEnabled ? "Disable snap and keep free-pan release" : "Enable velocity-based snap", + shortcut: shortcutLabel(.niriToggleSnap), searchText: "niri snap soft snap free pan velocity", + isEnabled: true, + run: { _ = coordinator.performCommand(.niriToggleSnap) } + ), + PaletteAction( + id: "niri-focus-workspace-down", icon: "arrow.down.to.line", title: "Niri: Focus Workspace Down", + detail: "Move focus to the next workspace", + shortcut: shortcutLabel(.niriFocusWorkspaceDown), searchText: "niri workspace down focus next", + isEnabled: selectedID != nil, + run: { _ = coordinator.performCommand(.niriFocusWorkspaceDown) } + ), + PaletteAction( + id: "niri-focus-workspace-up", icon: "arrow.up.to.line", title: "Niri: Focus Workspace Up", + detail: "Move focus to the previous workspace", + shortcut: shortcutLabel(.niriFocusWorkspaceUp), searchText: "niri workspace up focus previous", + isEnabled: selectedID != nil, + run: { _ = coordinator.performCommand(.niriFocusWorkspaceUp) } + ), + PaletteAction( + id: "niri-move-column-down", icon: "arrow.down.square", title: "Niri: Move Column To Workspace Down", + detail: "Move focused column to the next workspace", + shortcut: shortcutLabel(.niriMoveColumnToWorkspaceDown), + searchText: "niri move column workspace down", + isEnabled: selectedID != nil, + run: { _ = coordinator.performCommand(.niriMoveColumnToWorkspaceDown) } + ), + PaletteAction( + id: "niri-move-column-up", icon: "arrow.up.square", title: "Niri: Move Column To Workspace Up", + detail: "Move focused column to the previous workspace", + shortcut: shortcutLabel(.niriMoveColumnToWorkspaceUp), + searchText: "niri move column workspace up", + isEnabled: selectedID != nil, + run: { _ = coordinator.performCommand(.niriMoveColumnToWorkspaceUp) } + ), + ]) - for app in visibleApps { - actions.append( - PaletteAction( - id: "niri-add-app-\(app.id)", - icon: app.icon, - iconImageName: app.iconImageName, - title: "Niri: Add \(app.displayName) Tile", - detail: app.menuSubtitle, - searchText: "niri add app \(app.displayName.lowercased()) \(app.id)", - isEnabled: selectedID != nil, - run: { - if let id = selectedID { - _ = sessionService.niriAddAppRight(in: id, appID: app.id) - } - } - ) - ) + for app in visibleApps { + actions.append( + PaletteAction( + id: "niri-add-app-\(app.id)", + icon: app.icon, + iconImageName: app.iconImageName, + title: "Niri: Add \(app.displayName) Tile", + detail: app.menuSubtitle, + searchText: "niri add app \(app.displayName.lowercased()) \(app.id)", + isEnabled: selectedID != nil, + run: { + if let id = selectedID { + _ = sessionService.niriAddAppRight(in: id, appID: app.id) + } } - } else { - actions.append(contentsOf: [ - PaletteAction( - id: "toggle-browser", icon: "rectangle.split.2x1", title: "Toggle Browser Split", - detail: "Show or hide the embedded browser pane", - shortcut: shortcutLabel(.toggleBrowserSplit), searchText: "toggle browser split pane", - isEnabled: selectedID != nil, - run: { _ = coordinator.performCommand(.toggleBrowserSplit) } - ), - PaletteAction( - id: "toggle-focus", icon: "eye", title: "Toggle Focus Mode", - detail: "Hide sidebar and workflow rail", - shortcut: shortcutLabel(.toggleFocusMode), searchText: "toggle focus mode distraction free", - isEnabled: true, - run: { _ = coordinator.performCommand(.toggleFocusMode) } - ), - PaletteAction( - id: "open-clipboard", icon: "link", title: "Open Clipboard URL", - detail: "Open clipboard URL in browser split", - shortcut: shortcutLabel(.openClipboardURL), searchText: "open clipboard url browser split", - isEnabled: selectedID != nil && canOpenClipboard, - run: { _ = coordinator.performCommand(.openClipboardURL) } - ), - PaletteAction( - id: "next-pane", icon: "arrow.right.square", title: "Next Pane", - detail: "Focus the next pane", - shortcut: shortcutLabel(.nextPane), searchText: "next pane focus cycle", - isEnabled: selectedID != nil && sessionService.paneTrees[selectedID!] != nil, - run: { _ = coordinator.performCommand(.nextPane) } - ) - ]) - } - - // Session list for quick switching - for session in sessionService.sessions where session.id != selectedID { - actions.append(PaletteAction( - id: "switch-\(session.id.uuidString)", - icon: session.isWorktreeBacked ? "arrow.triangle.branch" : "terminal", - title: "Switch to: \(session.title)", - detail: session.subtitle, - shortcut: nil, - searchText: "switch \(session.title.lowercased()) \(session.subtitle.lowercased())", - isEnabled: true, - run: { [id = session.id] in sessionService.focusSession(id) } - )) - } + ) + ) + } + } else { + actions.append(contentsOf: [ + PaletteAction( + id: "toggle-browser", icon: "rectangle.split.2x1", title: "Toggle Browser Split", + detail: "Show or hide the embedded browser pane", + shortcut: shortcutLabel(.toggleBrowserSplit), searchText: "toggle browser split pane", + isEnabled: selectedID != nil, + run: { _ = coordinator.performCommand(.toggleBrowserSplit) } + ), + PaletteAction( + id: "toggle-focus", icon: "eye", title: "Toggle Focus Mode", + detail: "Hide sidebar and workflow rail", + shortcut: shortcutLabel(.toggleFocusMode), searchText: "toggle focus mode distraction free", + isEnabled: true, + run: { _ = coordinator.performCommand(.toggleFocusMode) } + ), + PaletteAction( + id: "open-clipboard", icon: "link", title: "Open Clipboard URL", + detail: "Open clipboard URL in browser split", + shortcut: shortcutLabel(.openClipboardURL), searchText: "open clipboard url browser split", + isEnabled: selectedID != nil && canOpenClipboard, + run: { _ = coordinator.performCommand(.openClipboardURL) } + ), + PaletteAction( + id: "next-pane", icon: "arrow.right.square", title: "Next Pane", + detail: "Focus the next pane", + shortcut: shortcutLabel(.nextPane), searchText: "next pane focus cycle", + isEnabled: selectedID != nil && sessionService.paneTrees[selectedID!] != nil, + run: { _ = coordinator.performCommand(.nextPane) } + ), + ]) + } - if showsVibe { - actions.append(contentsOf: [ - PaletteAction( - id: "create-checkpoint", icon: "bookmark", title: "Create Checkpoint", - detail: "Save current state of selected session", - shortcut: shortcutLabel(.showCheckpoints), searchText: "create checkpoint save state", - isEnabled: selectedID != nil, - run: { - guard let id = selectedID else { return } - Task { _ = try? await workflowService.createManualCheckpoint(sessionID: id, title: "Manual Checkpoint", summary: "Created from command palette", requestReview: false) } - } - ), - PaletteAction( - id: "toggle-rail", icon: "tray.full", title: "Toggle Workflow Rail", - detail: "Show or hide the supervision panel", - shortcut: shortcutLabel(.toggleWorkflowRail), searchText: "toggle workflow rail inbox supervision panel", - isEnabled: true, - run: { _ = coordinator.performCommand(.toggleWorkflowRail) } - ), - PaletteAction( - id: "focus-queue", icon: "exclamationmark.circle", title: "Focus Next Queue Item", - detail: "Jump to the highest-priority unresolved item", - shortcut: shortcutLabel(.focusNextQueueItem), searchText: "focus queue next item priority", - isEnabled: !workflowService.unresolvedQueueItems.isEmpty, - run: { _ = coordinator.performCommand(.focusNextQueueItem) } - ), - PaletteAction( - id: "reveal-worktree", icon: "folder.badge.questionmark", title: "Reveal Worktree in Finder", - detail: "Open worktree directory in Finder", - searchText: "reveal worktree finder", - isEnabled: selectedID != nil && hasWorktree, - run: { if let id = selectedID { sessionService.revealWorktree(for: id) } } - ), - ]) - } + // Session list for quick switching + for session in sessionService.sessions where session.id != selectedID { + actions.append(PaletteAction( + id: "switch-\(session.id.uuidString)", + icon: session.isWorktreeBacked ? "arrow.triangle.branch" : "terminal", + title: "Switch to: \(session.title)", + detail: session.subtitle, + shortcut: nil, + searchText: "switch \(session.title.lowercased()) \(session.subtitle.lowercased())", + isEnabled: true, + run: { [id = session.id] in sessionService.focusSession(id) } + )) + } - return actions + if showsVibe { + actions.append(contentsOf: [ + PaletteAction( + id: "create-checkpoint", icon: "bookmark", title: "Create Checkpoint", + detail: "Save current state of selected session", + shortcut: shortcutLabel(.showCheckpoints), searchText: "create checkpoint save state", + isEnabled: selectedID != nil, + run: { + guard let id = selectedID else { return } + Task { _ = try? await workflowService.createManualCheckpoint(sessionID: id, title: "Manual Checkpoint", summary: "Created from command palette", requestReview: false) } + } + ), + PaletteAction( + id: "toggle-rail", icon: "tray.full", title: "Toggle Workflow Rail", + detail: "Show or hide the supervision panel", + shortcut: shortcutLabel(.toggleWorkflowRail), searchText: "toggle workflow rail inbox supervision panel", + isEnabled: true, + run: { _ = coordinator.performCommand(.toggleWorkflowRail) } + ), + PaletteAction( + id: "focus-queue", icon: "exclamationmark.circle", title: "Focus Next Queue Item", + detail: "Jump to the highest-priority unresolved item", + shortcut: shortcutLabel(.focusNextQueueItem), searchText: "focus queue next item priority", + isEnabled: !workflowService.unresolvedQueueItems.isEmpty, + run: { _ = coordinator.performCommand(.focusNextQueueItem) } + ), + PaletteAction( + id: "reveal-worktree", icon: "folder.badge.questionmark", title: "Reveal Worktree in Finder", + detail: "Open worktree directory in Finder", + searchText: "reveal worktree finder", + isEnabled: selectedID != nil && hasWorktree, + run: { if let id = selectedID { sessionService.revealWorktree(for: id) } } + ), + ]) } + + return actions + } } struct PaletteAction: Identifiable { - let id: String - let icon: String - var iconImageName: String? = nil - let title: String - let detail: String - var shortcut: String? = nil - let searchText: String - let isEnabled: Bool - let run: () -> Void + let id: String + let icon: String + var iconImageName: String? + let title: String + let detail: String + var shortcut: String? + let searchText: String + let isEnabled: Bool + let run: () -> Void } diff --git a/idx0/UI/MainWindow/TabBarOverlay.swift b/idx0/UI/MainWindow/TabBarOverlay.swift index 9046f1b..4a84b31 100644 --- a/idx0/UI/MainWindow/TabBarOverlay.swift +++ b/idx0/UI/MainWindow/TabBarOverlay.swift @@ -3,210 +3,211 @@ import SwiftUI // MARK: - Tab Bar Overlay struct TabBarOverlay: View { - @EnvironmentObject private var sessionService: SessionService - @EnvironmentObject private var coordinator: AppCoordinator - @EnvironmentObject private var appUpdateService: AppUpdateService - @Environment(\.themeColors) private var tc - - @State private var isHovering = false - - var body: some View { - HStack(spacing: 0) { - // Reserve space for traffic lights only when sidebar is hidden - // (when sidebar is visible, traffic lights sit over the sidebar) - if !sessionService.settings.sidebarVisible { - Color.clear - .frame(width: 78, height: 28) - } else { - Color.clear - .frame(width: 8, height: 28) - } - - Button { - appUpdateService.performPrimaryAction() - } label: { - HStack(spacing: 4) { - Circle() - .fill(updateIndicatorColor) - .frame(width: 7, height: 7) - - if appUpdateService.state.status == .checking || appUpdateService.state.status == .downloading { - ProgressView() - .controlSize(.mini) - .scaleEffect(0.55) - .frame(width: 8, height: 8) - } else { - Image(systemName: updateIconName) - .font(.system(size: 9, weight: .medium)) - .foregroundStyle(isHovering ? tc.secondaryText : tc.mutedText) - } - } - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(tc.surface0.opacity(0.8), in: Capsule()) - } - .buttonStyle(.plain) - .disabled(!appUpdateService.canPerformPrimaryAction) - .help(updateButtonHelp) - .padding(.trailing, 6) - - // Session info - if let session = sessionService.selectedSession { - Text(Self.displayTitle(for: session)) - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(tc.primaryText) - .lineLimit(1) - .help(session.title) - - // Branch pill (Ghostty-style) - if let branch = session.branchName, !branch.isEmpty { - HStack(spacing: 6) { - Image(systemName: "arrow.triangle.branch") - .font(.system(size: 9, weight: .medium)) - Text(branch) - .font(.system(size: 11, weight: .medium)) - .lineLimit(1) - } - .padding(.horizontal, 8) - .padding(.vertical, 4) - .foregroundStyle(tc.secondaryText) - .background(Capsule().fill(tc.surface1)) - .contentShape(Capsule()) - .padding(.leading, 6) - } - - // Agent activity indicator - if let activity = session.agentActivity, activity.isActive { - HStack(spacing: 4) { - ProgressView() - .controlSize(.mini) - .scaleEffect(0.6) - Text(String(activity.description.prefix(30))) - .font(.system(size: 9)) - .foregroundStyle(.green.opacity(0.6)) - .lineLimit(1) - } - .padding(.leading, 6) - } - } - - Spacer(minLength: 0) - - // Attention indicator for background sessions - let needsAttention = coordinator.terminalMonitor.sessionsNeedingAttention() - if needsAttention > 0 { - Button { - focusNextAttentionSession() - } label: { - HStack(spacing: 4) { - Circle() - .fill(.orange) - .frame(width: 5, height: 5) - Text("\(needsAttention) waiting") - .font(.system(size: 9, weight: .medium)) - .foregroundStyle(.orange.opacity(0.7)) - } - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background(.orange.opacity(0.08), in: Capsule()) - } - .buttonStyle(.plain) - .padding(.trailing, 4) - } - - if !sessionService.settings.niriCanvasEnabled { - Button { - if let selected = sessionService.selectedSessionID { - sessionService.toggleBrowserSplit(for: selected) - } - } label: { - Image(systemName: "rectangle.split.2x1") - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(isHovering ? tc.secondaryText : tc.mutedText) - .frame(width: 24, height: 24) - } - .buttonStyle(.plain) - .help("Toggle Browser Split (\u{2318}\u{21e7}B)") - } - - // Sidebar toggle - Button { - sessionService.saveSettings { $0.sidebarVisible.toggle() } - } label: { - Image(systemName: sessionService.settings.sidebarVisible ? "sidebar.left" : "sidebar.leading") - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(isHovering ? tc.secondaryText : tc.mutedText) - .frame(width: 24, height: 24) - } - .buttonStyle(.plain) - .help("Toggle Sidebar (\u{2318}B)") - .padding(.leading, 4) - .padding(.trailing, 8) + @EnvironmentObject private var sessionService: SessionService + @EnvironmentObject private var coordinator: AppCoordinator + @EnvironmentObject private var appUpdateService: AppUpdateService + @Environment(\.themeColors) private var tc + + @State private var isHovering = false + + var body: some View { + HStack(spacing: 0) { + // Reserve space for traffic lights only when sidebar is hidden + // (when sidebar is visible, traffic lights sit over the sidebar) + if !sessionService.settings.sidebarVisible { + Color.clear + .frame(width: 78, height: 28) + } else { + Color.clear + .frame(width: 8, height: 28) + } + + Button { + appUpdateService.performPrimaryAction() + } label: { + HStack(spacing: 4) { + Circle() + .fill(updateIndicatorColor) + .frame(width: 7, height: 7) + + if appUpdateService.state.status == .checking || appUpdateService.state.status == .downloading { + ProgressView() + .controlSize(.mini) + .scaleEffect(0.55) + .frame(width: 8, height: 8) + } else { + Image(systemName: updateIconName) + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(isHovering ? tc.secondaryText : tc.mutedText) + } } - .frame(height: 28) - .background(tc.windowBackground) - .onHover { isHovering = $0 } - .animation(.easeOut(duration: 0.15), value: isHovering) - } - - /// Strip `user@host:` prefix from terminal titles to show just the path. - static func displayTitle(for session: Session) -> String { - let title = session.title - if session.hasCustomTitle { return title } - if let colonIndex = title.firstIndex(of: ":") { - let beforeColon = title[title.startIndex.. 0 { + Button { + focusNextAttentionSession() + } label: { + HStack(spacing: 4) { + Circle() + .fill(.orange) + .frame(width: 5, height: 5) + Text("\(needsAttention) waiting") + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(.orange.opacity(0.7)) + } + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(.orange.opacity(0.08), in: Capsule()) } - } - - private var updateIconName: String { - switch appUpdateService.state.status { - case .downloaded: - return "arrow.down.app.fill" - case .error: - return "exclamationmark.triangle" - default: - return "arrow.triangle.2.circlepath" + .buttonStyle(.plain) + .padding(.trailing, 4) + } + + if !sessionService.settings.niriCanvasEnabled { + Button { + if let selected = sessionService.selectedSessionID { + sessionService.toggleBrowserSplit(for: selected) + } + } label: { + Image(systemName: "rectangle.split.2x1") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(isHovering ? tc.secondaryText : tc.mutedText) + .frame(width: 24, height: 24) } + .buttonStyle(.plain) + .help("Toggle Browser Split (\u{2318}\u{21e7}B)") + } + + // Sidebar toggle + Button { + sessionService.saveSettings { $0.sidebarVisible.toggle() } + } label: { + Image(systemName: sessionService.settings.sidebarVisible ? "sidebar.left" : "sidebar.leading") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(isHovering ? tc.secondaryText : tc.mutedText) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + .help("Toggle Sidebar (\u{2318}B)") + .padding(.leading, 4) + .padding(.trailing, 8) + } + .frame(height: 28) + .background(tc.windowBackground) + .onHover { isHovering = $0 } + .animation(.easeOut(duration: 0.15), value: isHovering) + } + + /// Strip `user@host:` prefix from terminal titles to show just the path. + static func displayTitle(for session: Session) -> String { + let title = session.title + if session.hasCustomTitle { return title } + if let colonIndex = title.firstIndex(of: ":") { + let beforeColon = title[title.startIndex ..< colonIndex] + if beforeColon.contains("@") { + return String(title[title.index(after: colonIndex)...]) + } + } + return title + } + + private func focusNextAttentionSession() { + for (sessionID, result) in coordinator.terminalMonitor.agentStates { + if result.hasDetectedAgent, + result.state == .waitingForInput || result.state == .error, + sessionID != sessionService.selectedSessionID + { + sessionService.focusSession(sessionID) + return + } } + } + + private var updateIndicatorColor: Color { + switch appUpdateService.state.status { + case .disabled: + .gray.opacity(0.55) + case .idle, .upToDate: + .mint.opacity(0.8) + case .checking: + .blue.opacity(0.85) + case .available: + .orange + case .downloading: + .blue + case .downloaded: + .green + case .error: + .red + } + } + + private var updateIconName: String { + switch appUpdateService.state.status { + case .downloaded: + "arrow.down.app.fill" + case .error: + "exclamationmark.triangle" + default: + "arrow.triangle.2.circlepath" + } + } - private var updateButtonHelp: String { - guard let actionTitle = appUpdateService.primaryActionTitle else { - return appUpdateService.statusDescription - } - return "\(actionTitle) • \(appUpdateService.statusDescription)" + private var updateButtonHelp: String { + guard let actionTitle = appUpdateService.primaryActionTitle else { + return appUpdateService.statusDescription } + return "\(actionTitle) • \(appUpdateService.statusDescription)" + } } diff --git a/idx0/UI/Settings/Inline/InlineAdvancedSettings.swift b/idx0/UI/Settings/Inline/InlineAdvancedSettings.swift index 94cc674..3b557ac 100644 --- a/idx0/UI/Settings/Inline/InlineAdvancedSettings.swift +++ b/idx0/UI/Settings/Inline/InlineAdvancedSettings.swift @@ -3,207 +3,207 @@ import SwiftUI // MARK: - Advanced struct InlineAdvancedSettings: View { - @ObservedObject var sessionService: SessionService - @EnvironmentObject private var appUpdateService: AppUpdateService - @Environment(\.themeColors) private var tc - - @State private var onboardingResetPending = false - @State private var fullOnboardingResetPending = false - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - SettingSectionHeader(title: "Shell") - - SettingRowView(label: "Preferred Shell Path", caption: "Leave empty to use the system default shell. Changes apply to new sessions.") { - TextField( - "/bin/zsh", - text: Binding( - get: { sessionService.settings.preferredShellPath ?? "" }, - set: { newValue in - sessionService.saveSettings { settings in - let cleaned = newValue.trimmingCharacters(in: .whitespacesAndNewlines) - settings.preferredShellPath = cleaned.isEmpty ? nil : cleaned - } - } - ) - ) - .textFieldStyle(.plain) - .font(.system(size: 11, design: .monospaced)) - .padding(.horizontal, 8) - .padding(.vertical, 6) - .background(tc.surface0, in: RoundedRectangle(cornerRadius: 4)) - .overlay( - RoundedRectangle(cornerRadius: 4) - .stroke(tc.surface2.opacity(0.5), lineWidth: 0.5) - ) - .frame(maxWidth: 280) + @ObservedObject var sessionService: SessionService + @EnvironmentObject private var appUpdateService: AppUpdateService + @Environment(\.themeColors) private var tc + + @State private var onboardingResetPending = false + @State private var fullOnboardingResetPending = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SettingSectionHeader(title: "Shell") + + SettingRowView(label: "Preferred Shell Path", caption: "Leave empty to use the system default shell. Changes apply to new sessions.") { + TextField( + "/bin/zsh", + text: Binding( + get: { sessionService.settings.preferredShellPath ?? "" }, + set: { newValue in + sessionService.saveSettings { settings in + let cleaned = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + settings.preferredShellPath = cleaned.isEmpty ? nil : cleaned + } + } + ) + ) + .textFieldStyle(.plain) + .font(.system(size: 11, design: .monospaced)) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(tc.surface0, in: RoundedRectangle(cornerRadius: 4)) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(tc.surface2.opacity(0.5), lineWidth: 0.5) + ) + .frame(maxWidth: 280) + } + + SettingRowView( + label: "Terminal Startup Command Template", + caption: "Optional command sent when a new terminal controller starts. Use ${WORKDIR} and ${SESSION_ID}. Leave empty to disable." + ) { + TextField( + "cd ${WORKDIR}", + text: Binding( + get: { sessionService.settings.terminalStartupCommandTemplate ?? "" }, + set: { newValue in + sessionService.saveSettings { settings in + let cleaned = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + settings.terminalStartupCommandTemplate = cleaned.isEmpty ? nil : cleaned + } } + ) + ) + .textFieldStyle(.plain) + .font(.system(size: 11, design: .monospaced)) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(tc.surface0, in: RoundedRectangle(cornerRadius: 4)) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(tc.surface2.opacity(0.5), lineWidth: 0.5) + ) + .frame(maxWidth: 420) + } + + SettingDivider() + SettingSectionHeader(title: "Updates") + + SettingToggleRow( + label: "Auto-check for updates", + caption: "Check in the background after startup and every few hours.", + isOn: Binding( + get: { sessionService.settings.autoCheckForUpdates }, + set: { newValue in + withAnimation(.easeOut(duration: 0.15)) { + sessionService.saveSettings { settings in + settings.autoCheckForUpdates = newValue + } + appUpdateService.refreshPolicy() + } + } + ) + ) - SettingRowView( - label: "Terminal Startup Command Template", - caption: "Optional command sent when a new terminal controller starts. Use ${WORKDIR} and ${SESSION_ID}. Leave empty to disable." - ) { - TextField( - "cd ${WORKDIR}", - text: Binding( - get: { sessionService.settings.terminalStartupCommandTemplate ?? "" }, - set: { newValue in - sessionService.saveSettings { settings in - let cleaned = newValue.trimmingCharacters(in: .whitespacesAndNewlines) - settings.terminalStartupCommandTemplate = cleaned.isEmpty ? nil : cleaned - } - } - ) - ) - .textFieldStyle(.plain) - .font(.system(size: 11, design: .monospaced)) - .padding(.horizontal, 8) + SettingRowView(label: "Status", caption: appUpdateService.statusDescription) { + HStack(spacing: 8) { + if let actionTitle = appUpdateService.primaryActionTitle { + Button { + appUpdateService.performPrimaryAction() + } label: { + Text(actionTitle) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(tc.secondaryText) + .padding(.horizontal, 12) .padding(.vertical, 6) .background(tc.surface0, in: RoundedRectangle(cornerRadius: 4)) .overlay( - RoundedRectangle(cornerRadius: 4) - .stroke(tc.surface2.opacity(0.5), lineWidth: 0.5) + RoundedRectangle(cornerRadius: 4) + .stroke(tc.surface2.opacity(0.5), lineWidth: 0.5) ) - .frame(maxWidth: 420) } - - SettingDivider() - SettingSectionHeader(title: "Updates") - - SettingToggleRow( - label: "Auto-check for updates", - caption: "Check in the background after startup and every few hours.", - isOn: Binding( - get: { sessionService.settings.autoCheckForUpdates }, - set: { newValue in - withAnimation(.easeOut(duration: 0.15)) { - sessionService.saveSettings { settings in - settings.autoCheckForUpdates = newValue - } - appUpdateService.refreshPolicy() - } - } - ) - ) - - SettingRowView(label: "Status", caption: appUpdateService.statusDescription) { - HStack(spacing: 8) { - if let actionTitle = appUpdateService.primaryActionTitle { - Button { - appUpdateService.performPrimaryAction() - } label: { - Text(actionTitle) - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(tc.secondaryText) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(tc.surface0, in: RoundedRectangle(cornerRadius: 4)) - .overlay( - RoundedRectangle(cornerRadius: 4) - .stroke(tc.surface2.opacity(0.5), lineWidth: 0.5) - ) - } - .buttonStyle(.plain) - .disabled(!appUpdateService.canPerformPrimaryAction) - } - - if let lastChecked = appUpdateService.state.lastCheckedAt { - Text(lastChecked.formatted(date: .abbreviated, time: .shortened)) - .font(.system(size: 10, design: .monospaced)) - .foregroundStyle(tc.tertiaryText) - } - } + .buttonStyle(.plain) + .disabled(!appUpdateService.canPerformPrimaryAction) + } + + if let lastChecked = appUpdateService.state.lastCheckedAt { + Text(lastChecked.formatted(date: .abbreviated, time: .shortened)) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(tc.tertiaryText) + } + } + } + + SettingDivider() + SettingSectionHeader(title: "Reset") + + VStack(alignment: .leading, spacing: 10) { + if fullOnboardingResetPending { + resetConfirmation("All onboarding will show on next launch. Restart IDX0 to see it.") + } else if onboardingResetPending { + resetConfirmation("Niri walkthrough will show on next launch. Restart IDX0 to see it.") + } else { + Button { + sessionService.saveSettings { settings in + settings.hasSeenNiriOnboarding = false } - - SettingDivider() - SettingSectionHeader(title: "Reset") - - VStack(alignment: .leading, spacing: 10) { - if fullOnboardingResetPending { - resetConfirmation("All onboarding will show on next launch. Restart IDX0 to see it.") - } else if onboardingResetPending { - resetConfirmation("Niri walkthrough will show on next launch. Restart IDX0 to see it.") - } else { - Button { - sessionService.saveSettings { settings in - settings.hasSeenNiriOnboarding = false - } - onboardingResetPending = true - } label: { - Text("Reset Niri Walkthrough") - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(tc.secondaryText) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(tc.surface0, in: RoundedRectangle(cornerRadius: 4)) - .overlay( - RoundedRectangle(cornerRadius: 4) - .stroke(tc.surface2.opacity(0.5), lineWidth: 0.5) - ) - } - .buttonStyle(.plain) - - Button { - sessionService.saveSettings { settings in - settings.hasSeenFirstRun = false - settings.hasSeenNiriOnboarding = false - } - fullOnboardingResetPending = true - } label: { - Text("Reset All Onboarding") - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(tc.secondaryText) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(tc.surface0, in: RoundedRectangle(cornerRadius: 4)) - .overlay( - RoundedRectangle(cornerRadius: 4) - .stroke(tc.surface2.opacity(0.5), lineWidth: 0.5) - ) - } - .buttonStyle(.plain) - } - - Button { - sessionService.saveSettings { settings in - let preserveFirstRun = settings.hasSeenFirstRun - let preserveNiriOnboarding = settings.hasSeenNiriOnboarding - settings = AppSettings() - settings.hasSeenFirstRun = preserveFirstRun - settings.hasSeenNiriOnboarding = preserveNiriOnboarding - } - } label: { - Text("Reset All Settings") - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(.red.opacity(0.8)) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background( - RoundedRectangle(cornerRadius: 4) - .stroke(.red.opacity(0.3), lineWidth: 0.5) - ) - } - .buttonStyle(.plain) - - Text("This will reset all settings to their defaults. This cannot be undone.") - .font(.system(size: 11)) - .foregroundStyle(tc.tertiaryText) + onboardingResetPending = true + } label: { + Text("Reset Niri Walkthrough") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(tc.secondaryText) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(tc.surface0, in: RoundedRectangle(cornerRadius: 4)) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(tc.surface2.opacity(0.5), lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + + Button { + sessionService.saveSettings { settings in + settings.hasSeenFirstRun = false + settings.hasSeenNiriOnboarding = false } - .padding(.vertical, 4) + fullOnboardingResetPending = true + } label: { + Text("Reset All Onboarding") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(tc.secondaryText) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(tc.surface0, in: RoundedRectangle(cornerRadius: 4)) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(tc.surface2.opacity(0.5), lineWidth: 0.5) + ) + } + .buttonStyle(.plain) } - } - private func resetConfirmation(_ message: String) -> some View { - HStack(spacing: 8) { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 12)) - .foregroundStyle(.green) - Text(message) - .font(.system(size: 11)) - .foregroundStyle(tc.secondaryText) + Button { + sessionService.saveSettings { settings in + let preserveFirstRun = settings.hasSeenFirstRun + let preserveNiriOnboarding = settings.hasSeenNiriOnboarding + settings = AppSettings() + settings.hasSeenFirstRun = preserveFirstRun + settings.hasSeenNiriOnboarding = preserveNiriOnboarding + } + } label: { + Text("Reset All Settings") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.red.opacity(0.8)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 4) + .stroke(.red.opacity(0.3), lineWidth: 0.5) + ) } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(tc.surface0, in: RoundedRectangle(cornerRadius: 4)) + .buttonStyle(.plain) + + Text("This will reset all settings to their defaults. This cannot be undone.") + .font(.system(size: 11)) + .foregroundStyle(tc.tertiaryText) + } + .padding(.vertical, 4) + } + } + + private func resetConfirmation(_ message: String) -> some View { + HStack(spacing: 8) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 12)) + .foregroundStyle(.green) + Text(message) + .font(.system(size: 11)) + .foregroundStyle(tc.secondaryText) } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(tc.surface0, in: RoundedRectangle(cornerRadius: 4)) + } } diff --git a/idx0/UI/Settings/Tabs/AdvancedSettingsTab.swift b/idx0/UI/Settings/Tabs/AdvancedSettingsTab.swift index 4797e60..456474f 100644 --- a/idx0/UI/Settings/Tabs/AdvancedSettingsTab.swift +++ b/idx0/UI/Settings/Tabs/AdvancedSettingsTab.swift @@ -3,88 +3,88 @@ import SwiftUI // MARK: - Advanced Tab struct AdvancedSettingsTab: View { - @ObservedObject var sessionService: SessionService - @EnvironmentObject private var appUpdateService: AppUpdateService + @ObservedObject var sessionService: SessionService + @EnvironmentObject private var appUpdateService: AppUpdateService - var body: some View { - Form { - Section("Shell") { - TextField( - "Preferred Shell Path", - text: Binding( - get: { sessionService.settings.preferredShellPath ?? "" }, - set: { newValue in - sessionService.saveSettings { settings in - let cleaned = newValue.trimmingCharacters(in: .whitespacesAndNewlines) - settings.preferredShellPath = cleaned.isEmpty ? nil : cleaned - } - } - ) - ) - Text("Leave empty to use the system default shell") - .font(.caption) - .foregroundStyle(.tertiary) + var body: some View { + Form { + Section("Shell") { + TextField( + "Preferred Shell Path", + text: Binding( + get: { sessionService.settings.preferredShellPath ?? "" }, + set: { newValue in + sessionService.saveSettings { settings in + let cleaned = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + settings.preferredShellPath = cleaned.isEmpty ? nil : cleaned + } } + ) + ) + Text("Leave empty to use the system default shell") + .font(.caption) + .foregroundStyle(.tertiary) + } - Section("Updates") { - Toggle( - "Auto-check for updates", - isOn: Binding( - get: { sessionService.settings.autoCheckForUpdates }, - set: { newValue in - sessionService.saveSettings { settings in - settings.autoCheckForUpdates = newValue - } - appUpdateService.refreshPolicy() - } - ) - ) + Section("Updates") { + Toggle( + "Auto-check for updates", + isOn: Binding( + get: { sessionService.settings.autoCheckForUpdates }, + set: { newValue in + sessionService.saveSettings { settings in + settings.autoCheckForUpdates = newValue + } + appUpdateService.refreshPolicy() + } + ) + ) - Text(appUpdateService.statusDescription) - .font(.caption) - .foregroundStyle(.tertiary) + Text(appUpdateService.statusDescription) + .font(.caption) + .foregroundStyle(.tertiary) - if let lastChecked = appUpdateService.state.lastCheckedAt { - Text("Last checked: \(lastChecked.formatted(date: .abbreviated, time: .shortened))") - .font(.caption2) - .foregroundStyle(.tertiary) - } + if let lastChecked = appUpdateService.state.lastCheckedAt { + Text("Last checked: \(lastChecked.formatted(date: .abbreviated, time: .shortened))") + .font(.caption2) + .foregroundStyle(.tertiary) + } - if let actionTitle = appUpdateService.primaryActionTitle { - Button(actionTitle) { - appUpdateService.performPrimaryAction() - } - .disabled(!appUpdateService.canPerformPrimaryAction) - } - } + if let actionTitle = appUpdateService.primaryActionTitle { + Button(actionTitle) { + appUpdateService.performPrimaryAction() + } + .disabled(!appUpdateService.canPerformPrimaryAction) + } + } - Section("Reset") { - Button("Reset Niri Walkthrough (requires restart)") { - sessionService.saveSettings { settings in - settings.hasSeenNiriOnboarding = false - } - } + Section("Reset") { + Button("Reset Niri Walkthrough (requires restart)") { + sessionService.saveSettings { settings in + settings.hasSeenNiriOnboarding = false + } + } - Button("Reset All Onboarding (requires restart)") { - sessionService.saveSettings { settings in - settings.hasSeenFirstRun = false - settings.hasSeenNiriOnboarding = false - } - } + Button("Reset All Onboarding (requires restart)") { + sessionService.saveSettings { settings in + settings.hasSeenFirstRun = false + settings.hasSeenNiriOnboarding = false + } + } - Button("Reset All Settings to Defaults") { - sessionService.saveSettings { settings in - let preserveFirstRun = settings.hasSeenFirstRun - let preserveNiriOnboarding = settings.hasSeenNiriOnboarding - settings = AppSettings() - settings.hasSeenFirstRun = preserveFirstRun - settings.hasSeenNiriOnboarding = preserveNiriOnboarding - } - } - .foregroundStyle(.red) - } + Button("Reset All Settings to Defaults") { + sessionService.saveSettings { settings in + let preserveFirstRun = settings.hasSeenFirstRun + let preserveNiriOnboarding = settings.hasSeenNiriOnboarding + settings = AppSettings() + settings.hasSeenFirstRun = preserveFirstRun + settings.hasSeenNiriOnboarding = preserveNiriOnboarding + } } - .formStyle(.grouped) - .padding(10) + .foregroundStyle(.red) + } } + .formStyle(.grouped) + .padding(10) + } } diff --git a/idx0Tests/AppCommandRegistryTests.swift b/idx0Tests/AppCommandRegistryTests.swift index 9f0842a..9e66799 100644 --- a/idx0Tests/AppCommandRegistryTests.swift +++ b/idx0Tests/AppCommandRegistryTests.swift @@ -1,31 +1,31 @@ -import XCTest @testable import idx0 +import XCTest final class AppCommandRegistryTests: XCTestCase { - func testRegistryCoversAllShortcutDescriptors() { - let shortcutIDs = Set(ShortcutRegistry.shared.descriptors.map(\.id)) - let registryIDs = Set(AppCommandRegistry.shared.descriptors.map(\.id)) - XCTAssertEqual(registryIDs, shortcutIDs) - } + func testRegistryCoversAllShortcutDescriptors() { + let shortcutIDs = Set(ShortcutRegistry.shared.descriptors.map(\.id)) + let registryIDs = Set(AppCommandRegistry.shared.descriptors.map(\.id)) + XCTAssertEqual(registryIDs, shortcutIDs) + } - func testCommandSurfaceSetsStayInParity() { - let registry = AppCommandRegistry.shared - XCTAssertEqual(registry.shortcutCommandIDs, registry.menuCommandIDs) - XCTAssertEqual(registry.menuCommandIDs, registry.paletteCommandIDs) - } + func testCommandSurfaceSetsStayInParity() { + let registry = AppCommandRegistry.shared + XCTAssertEqual(registry.shortcutCommandIDs, registry.menuCommandIDs) + XCTAssertEqual(registry.menuCommandIDs, registry.paletteCommandIDs) + } - func testIPCCommandConstantsAreUniqueAndNonEmpty() { - let all = IPCCommand.all - XCTAssertFalse(all.isEmpty) - XCTAssertEqual(all.count, Set(all).count) - XCTAssertFalse(all.contains { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) - } + func testIPCCommandConstantsAreUniqueAndNonEmpty() { + let all = IPCCommand.all + XCTAssertFalse(all.isEmpty) + XCTAssertEqual(all.count, Set(all).count) + XCTAssertFalse(all.contains { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) + } - func testCheckForUpdatesCommandIsPresentOnAllSurfaces() { - let registry = AppCommandRegistry.shared - XCTAssertNotNil(registry.descriptor(for: .checkForUpdates)) - XCTAssertTrue(registry.shortcutCommandIDs.contains(.checkForUpdates)) - XCTAssertTrue(registry.menuCommandIDs.contains(.checkForUpdates)) - XCTAssertTrue(registry.paletteCommandIDs.contains(.checkForUpdates)) - } + func testCheckForUpdatesCommandIsPresentOnAllSurfaces() { + let registry = AppCommandRegistry.shared + XCTAssertNotNil(registry.descriptor(for: .checkForUpdates)) + XCTAssertTrue(registry.shortcutCommandIDs.contains(.checkForUpdates)) + XCTAssertTrue(registry.menuCommandIDs.contains(.checkForUpdates)) + XCTAssertTrue(registry.paletteCommandIDs.contains(.checkForUpdates)) + } } diff --git a/idx0Tests/AppSettingsKeyboardTests.swift b/idx0Tests/AppSettingsKeyboardTests.swift index 698638e..f4ef8d2 100644 --- a/idx0Tests/AppSettingsKeyboardTests.swift +++ b/idx0Tests/AppSettingsKeyboardTests.swift @@ -1,57 +1,57 @@ -import XCTest @testable import idx0 +import XCTest final class AppSettingsKeyboardTests: XCTestCase { - func testDecodingMissingKeyboardFieldsUsesDefaults() throws { - let json = """ - { - "schemaVersion" : 4, - "sidebarVisible" : true - } - """ + func testDecodingMissingKeyboardFieldsUsesDefaults() throws { + let json = """ + { + "schemaVersion" : 4, + "sidebarVisible" : true + } + """ - let decoded = try JSONDecoder().decode(AppSettings.self, from: Data(json.utf8)) + let decoded = try JSONDecoder().decode(AppSettings.self, from: Data(json.utf8)) - XCTAssertEqual(decoded.keybindingMode, .both) - XCTAssertEqual(decoded.modKeySetting, .commandOption) - XCTAssertTrue(decoded.customKeybindings.isEmpty) - XCTAssertFalse(decoded.hasSeenNiriOnboarding) - XCTAssertFalse(decoded.cleanupOnClose) - XCTAssertNil(decoded.terminalStartupCommandTemplate) - XCTAssertNil(decoded.niri.defaultNewColumnWidth) - XCTAssertNil(decoded.niri.defaultNewTileHeight) - XCTAssertTrue(decoded.autoCheckForUpdates) - } + XCTAssertEqual(decoded.keybindingMode, .both) + XCTAssertEqual(decoded.modKeySetting, .commandOption) + XCTAssertTrue(decoded.customKeybindings.isEmpty) + XCTAssertFalse(decoded.hasSeenNiriOnboarding) + XCTAssertFalse(decoded.cleanupOnClose) + XCTAssertNil(decoded.terminalStartupCommandTemplate) + XCTAssertNil(decoded.niri.defaultNewColumnWidth) + XCTAssertNil(decoded.niri.defaultNewTileHeight) + XCTAssertTrue(decoded.autoCheckForUpdates) + } - func testRoundTripPersistsKeyboardSettings() throws { - var settings = AppSettings() - settings.keybindingMode = .custom - settings.modKeySetting = .optionControl - settings.hasSeenNiriOnboarding = true - settings.cleanupOnClose = true - settings.terminalStartupCommandTemplate = "cd ${WORKDIR} && echo ${SESSION_ID}" - settings.niri.defaultNewColumnWidth = 920 - settings.niri.defaultNewTileHeight = 540 - settings.autoCheckForUpdates = false - settings.customKeybindings[ShortcutActionID.niriToggleOverview.rawValue] = KeyChord( - key: .o, - modifiers: [.option, .control] - ) + func testRoundTripPersistsKeyboardSettings() throws { + var settings = AppSettings() + settings.keybindingMode = .custom + settings.modKeySetting = .optionControl + settings.hasSeenNiriOnboarding = true + settings.cleanupOnClose = true + settings.terminalStartupCommandTemplate = "cd ${WORKDIR} && echo ${SESSION_ID}" + settings.niri.defaultNewColumnWidth = 920 + settings.niri.defaultNewTileHeight = 540 + settings.autoCheckForUpdates = false + settings.customKeybindings[ShortcutActionID.niriToggleOverview.rawValue] = KeyChord( + key: .o, + modifiers: [.option, .control] + ) - let data = try JSONEncoder().encode(settings) - let decoded = try JSONDecoder().decode(AppSettings.self, from: data) + let data = try JSONEncoder().encode(settings) + let decoded = try JSONDecoder().decode(AppSettings.self, from: data) - XCTAssertEqual(decoded.keybindingMode, .custom) - XCTAssertEqual(decoded.modKeySetting, .optionControl) - XCTAssertTrue(decoded.hasSeenNiriOnboarding) - XCTAssertTrue(decoded.cleanupOnClose) - XCTAssertEqual(decoded.terminalStartupCommandTemplate, "cd ${WORKDIR} && echo ${SESSION_ID}") - XCTAssertEqual(decoded.niri.defaultNewColumnWidth, 920) - XCTAssertEqual(decoded.niri.defaultNewTileHeight, 540) - XCTAssertFalse(decoded.autoCheckForUpdates) - XCTAssertEqual( - decoded.customKeybindings[ShortcutActionID.niriToggleOverview.rawValue], - KeyChord(key: .o, modifiers: [.option, .control]) - ) - } + XCTAssertEqual(decoded.keybindingMode, .custom) + XCTAssertEqual(decoded.modKeySetting, .optionControl) + XCTAssertTrue(decoded.hasSeenNiriOnboarding) + XCTAssertTrue(decoded.cleanupOnClose) + XCTAssertEqual(decoded.terminalStartupCommandTemplate, "cd ${WORKDIR} && echo ${SESSION_ID}") + XCTAssertEqual(decoded.niri.defaultNewColumnWidth, 920) + XCTAssertEqual(decoded.niri.defaultNewTileHeight, 540) + XCTAssertFalse(decoded.autoCheckForUpdates) + XCTAssertEqual( + decoded.customKeybindings[ShortcutActionID.niriToggleOverview.rawValue], + KeyChord(key: .o, modifiers: [.option, .control]) + ) + } } diff --git a/idx0Tests/AppUpdateActionMapperTests.swift b/idx0Tests/AppUpdateActionMapperTests.swift index a4aa111..ffc7b5f 100644 --- a/idx0Tests/AppUpdateActionMapperTests.swift +++ b/idx0Tests/AppUpdateActionMapperTests.swift @@ -1,23 +1,23 @@ -import XCTest @testable import idx0 +import XCTest final class AppUpdateActionMapperTests: XCTestCase { - func testPrimaryActionMappingByStatus() { - XCTAssertNil(AppUpdateActionMapper.primaryAction(for: .disabled)) - XCTAssertEqual(AppUpdateActionMapper.primaryAction(for: .idle), .check) - XCTAssertNil(AppUpdateActionMapper.primaryAction(for: .checking)) - XCTAssertEqual(AppUpdateActionMapper.primaryAction(for: .upToDate), .check) - XCTAssertEqual(AppUpdateActionMapper.primaryAction(for: .available), .download) - XCTAssertNil(AppUpdateActionMapper.primaryAction(for: .downloading)) - XCTAssertEqual(AppUpdateActionMapper.primaryAction(for: .downloaded), .install) - XCTAssertEqual(AppUpdateActionMapper.primaryAction(for: .error), .retry) - } + func testPrimaryActionMappingByStatus() { + XCTAssertNil(AppUpdateActionMapper.primaryAction(for: .disabled)) + XCTAssertEqual(AppUpdateActionMapper.primaryAction(for: .idle), .check) + XCTAssertNil(AppUpdateActionMapper.primaryAction(for: .checking)) + XCTAssertEqual(AppUpdateActionMapper.primaryAction(for: .upToDate), .check) + XCTAssertEqual(AppUpdateActionMapper.primaryAction(for: .available), .download) + XCTAssertNil(AppUpdateActionMapper.primaryAction(for: .downloading)) + XCTAssertEqual(AppUpdateActionMapper.primaryAction(for: .downloaded), .install) + XCTAssertEqual(AppUpdateActionMapper.primaryAction(for: .error), .retry) + } - func testPrimaryActionTitlesMatchStatus() { - XCTAssertEqual(AppUpdateActionMapper.primaryActionTitle(for: .idle), "Check for Updates") - XCTAssertEqual(AppUpdateActionMapper.primaryActionTitle(for: .available), "Download Update") - XCTAssertEqual(AppUpdateActionMapper.primaryActionTitle(for: .downloaded), "Install Update") - XCTAssertEqual(AppUpdateActionMapper.primaryActionTitle(for: .error), "Retry") - XCTAssertNil(AppUpdateActionMapper.primaryActionTitle(for: .checking)) - } + func testPrimaryActionTitlesMatchStatus() { + XCTAssertEqual(AppUpdateActionMapper.primaryActionTitle(for: .idle), "Check for Updates") + XCTAssertEqual(AppUpdateActionMapper.primaryActionTitle(for: .available), "Download Update") + XCTAssertEqual(AppUpdateActionMapper.primaryActionTitle(for: .downloaded), "Install Update") + XCTAssertEqual(AppUpdateActionMapper.primaryActionTitle(for: .error), "Retry") + XCTAssertNil(AppUpdateActionMapper.primaryActionTitle(for: .checking)) + } } diff --git a/idx0Tests/AppUpdateReducerTests.swift b/idx0Tests/AppUpdateReducerTests.swift index 5ed14f1..386aa4e 100644 --- a/idx0Tests/AppUpdateReducerTests.swift +++ b/idx0Tests/AppUpdateReducerTests.swift @@ -1,96 +1,96 @@ -import XCTest @testable import idx0 +import XCTest final class AppUpdateReducerTests: XCTestCase { - func testCheckRequestTransitionsToChecking() { - var state = AppUpdateState(currentVersion: "1.0.0") - state.errorMessage = "old" - state.progress = 0.4 - - let next = AppUpdateReducer.reduce(state: state, event: .checkRequested(source: .manual)) - - XCTAssertEqual(next.status, .checking) - XCTAssertNil(next.errorMessage) - XCTAssertNil(next.progress) - } - - func testCheckSuccessWithAvailableVersionTransitionsToAvailable() { - let checkedAt = Date(timeIntervalSince1970: 10) - let state = AppUpdateState(currentVersion: "1.0.0", status: .checking) - - let next = AppUpdateReducer.reduce( - state: state, - event: .checkSucceeded(availableVersion: "1.1.0", checkedAt: checkedAt) - ) - - XCTAssertEqual(next.status, .available) - XCTAssertEqual(next.availableVersion, "1.1.0") - XCTAssertEqual(next.lastCheckedAt, checkedAt) - } - - func testCheckSuccessWithNoVersionTransitionsToUpToDate() { - let checkedAt = Date(timeIntervalSince1970: 11) - let state = AppUpdateState(currentVersion: "1.1.0", status: .checking) - - let next = AppUpdateReducer.reduce( - state: state, - event: .checkSucceeded(availableVersion: nil, checkedAt: checkedAt) - ) - - XCTAssertEqual(next.status, .upToDate) - XCTAssertNil(next.availableVersion) - XCTAssertEqual(next.lastCheckedAt, checkedAt) - } - - func testDownloadLifecycleTransitions() { - let base = AppUpdateState(currentVersion: "1.0.0", availableVersion: "1.1.0", status: .available) - - let started = AppUpdateReducer.reduce(state: base, event: .downloadStarted) - XCTAssertEqual(started.status, .downloading) - XCTAssertEqual(started.progress, 0) - - let progress = AppUpdateReducer.reduce(state: started, event: .downloadProgress(0.65)) - XCTAssertEqual(progress.status, .downloading) - XCTAssertEqual(try XCTUnwrap(progress.progress), 0.65, accuracy: 0.0001) - - let completed = AppUpdateReducer.reduce(state: progress, event: .downloadSucceeded) - XCTAssertEqual(completed.status, .downloaded) - XCTAssertEqual(completed.progress, 1) - - let failed = AppUpdateReducer.reduce(state: progress, event: .downloadFailed("network")) - XCTAssertEqual(failed.status, .error) - XCTAssertEqual(failed.errorMessage, "network") - } - - func testInstallFailureCanRetryToChecking() { - let errored = AppUpdateReducer.reduce( - state: AppUpdateState(currentVersion: "1.0.0", status: .downloaded), - event: .installFailed("failed") - ) - XCTAssertEqual(errored.status, .error) - - let retried = AppUpdateReducer.reduce(state: errored, event: .checkRequested(source: .retry)) - XCTAssertEqual(retried.status, .checking) - XCTAssertNil(retried.errorMessage) - } - - func testPolicyDisableAndReenableTransitions() { - let state = AppUpdateState(currentVersion: "1.0.0", status: .upToDate) - - let disabled = AppUpdateReducer.reduce(state: state, event: .policyChanged(enabled: false)) - XCTAssertEqual(disabled.status, .disabled) - XCTAssertFalse(disabled.enabled) - - let reenabled = AppUpdateReducer.reduce(state: disabled, event: .policyChanged(enabled: true)) - XCTAssertEqual(reenabled.status, .idle) - XCTAssertTrue(reenabled.enabled) - } - - func testCheckRequestIsIgnoredWhileDownloading() { - let state = AppUpdateState(currentVersion: "1.0.0", status: .downloading) - - let next = AppUpdateReducer.reduce(state: state, event: .checkRequested(source: .manual)) - - XCTAssertEqual(next, state) - } + func testCheckRequestTransitionsToChecking() { + var state = AppUpdateState(currentVersion: "1.0.0") + state.errorMessage = "old" + state.progress = 0.4 + + let next = AppUpdateReducer.reduce(state: state, event: .checkRequested(source: .manual)) + + XCTAssertEqual(next.status, .checking) + XCTAssertNil(next.errorMessage) + XCTAssertNil(next.progress) + } + + func testCheckSuccessWithAvailableVersionTransitionsToAvailable() { + let checkedAt = Date(timeIntervalSince1970: 10) + let state = AppUpdateState(currentVersion: "1.0.0", status: .checking) + + let next = AppUpdateReducer.reduce( + state: state, + event: .checkSucceeded(availableVersion: "1.1.0", checkedAt: checkedAt) + ) + + XCTAssertEqual(next.status, .available) + XCTAssertEqual(next.availableVersion, "1.1.0") + XCTAssertEqual(next.lastCheckedAt, checkedAt) + } + + func testCheckSuccessWithNoVersionTransitionsToUpToDate() { + let checkedAt = Date(timeIntervalSince1970: 11) + let state = AppUpdateState(currentVersion: "1.1.0", status: .checking) + + let next = AppUpdateReducer.reduce( + state: state, + event: .checkSucceeded(availableVersion: nil, checkedAt: checkedAt) + ) + + XCTAssertEqual(next.status, .upToDate) + XCTAssertNil(next.availableVersion) + XCTAssertEqual(next.lastCheckedAt, checkedAt) + } + + func testDownloadLifecycleTransitions() { + let base = AppUpdateState(currentVersion: "1.0.0", availableVersion: "1.1.0", status: .available) + + let started = AppUpdateReducer.reduce(state: base, event: .downloadStarted) + XCTAssertEqual(started.status, .downloading) + XCTAssertEqual(started.progress, 0) + + let progress = AppUpdateReducer.reduce(state: started, event: .downloadProgress(0.65)) + XCTAssertEqual(progress.status, .downloading) + XCTAssertEqual(try XCTUnwrap(progress.progress), 0.65, accuracy: 0.0001) + + let completed = AppUpdateReducer.reduce(state: progress, event: .downloadSucceeded) + XCTAssertEqual(completed.status, .downloaded) + XCTAssertEqual(completed.progress, 1) + + let failed = AppUpdateReducer.reduce(state: progress, event: .downloadFailed("network")) + XCTAssertEqual(failed.status, .error) + XCTAssertEqual(failed.errorMessage, "network") + } + + func testInstallFailureCanRetryToChecking() { + let errored = AppUpdateReducer.reduce( + state: AppUpdateState(currentVersion: "1.0.0", status: .downloaded), + event: .installFailed("failed") + ) + XCTAssertEqual(errored.status, .error) + + let retried = AppUpdateReducer.reduce(state: errored, event: .checkRequested(source: .retry)) + XCTAssertEqual(retried.status, .checking) + XCTAssertNil(retried.errorMessage) + } + + func testPolicyDisableAndReenableTransitions() { + let state = AppUpdateState(currentVersion: "1.0.0", status: .upToDate) + + let disabled = AppUpdateReducer.reduce(state: state, event: .policyChanged(enabled: false)) + XCTAssertEqual(disabled.status, .disabled) + XCTAssertFalse(disabled.enabled) + + let reenabled = AppUpdateReducer.reduce(state: disabled, event: .policyChanged(enabled: true)) + XCTAssertEqual(reenabled.status, .idle) + XCTAssertTrue(reenabled.enabled) + } + + func testCheckRequestIsIgnoredWhileDownloading() { + let state = AppUpdateState(currentVersion: "1.0.0", status: .downloading) + + let next = AppUpdateReducer.reduce(state: state, event: .checkRequested(source: .manual)) + + XCTAssertEqual(next, state) + } } diff --git a/idx0Tests/AppUpdateServiceTests.swift b/idx0Tests/AppUpdateServiceTests.swift index 7da0ffa..a45551f 100644 --- a/idx0Tests/AppUpdateServiceTests.swift +++ b/idx0Tests/AppUpdateServiceTests.swift @@ -1,254 +1,254 @@ import Foundation -import XCTest @testable import idx0 +import XCTest @MainActor final class AppUpdateServiceTests: XCTestCase { - func testStartupAndRepeatingChecksAreScheduledWhenEnabled() async { - let driver = FakeUpdateDriver() - let scheduler = FakeScheduler() - let environment = FakeEnvironment() - - let service = AppUpdateService( - driver: driver, - scheduler: scheduler, - versionProvider: FakeVersionProvider(currentVersion: "1.0.0"), - environment: environment, - autoCheckEnabledProvider: { true } - ) - _ = service - - XCTAssertEqual(scheduler.oneShotIntervals, [AppUpdateService.startupDelay]) - XCTAssertEqual(scheduler.repeatingIntervals, [AppUpdateService.pollInterval]) - - scheduler.fireOneShot(at: 0) - XCTAssertEqual(driver.checkCalls.count, 1) - XCTAssertEqual(driver.checkCalls.first?.currentVersion, "1.0.0") - - scheduler.fireRepeating(at: 0) - XCTAssertEqual(driver.checkCalls.count, 1, "second check should be blocked while checking") - - driver.emit(.checkSucceeded(availableVersion: nil, downloadURL: nil)) - await flushMainActorTasks() - scheduler.fireRepeating(at: 0) - XCTAssertEqual(driver.checkCalls.count, 2) - } - - func testManualCheckAndPrimaryActionsFollowStateMachine() async { - let driver = FakeUpdateDriver() - let service = makeService(driver: driver) - - service.checkNow() - XCTAssertEqual(driver.checkCalls.count, 1) - XCTAssertEqual(service.state.status, .checking) - - driver.emit(.checkSucceeded(availableVersion: "1.2.0", downloadURL: URL(string: "https://example.com/idx0.zip"))) - await flushMainActorTasks() - XCTAssertEqual(service.state.status, .available) - - service.performPrimaryAction() - XCTAssertEqual(driver.downloadCount, 1) - XCTAssertEqual(service.state.status, .downloading) - - driver.emit(.downloadCompleted) - await flushMainActorTasks() - XCTAssertEqual(service.state.status, .downloaded) - - service.performPrimaryAction() - XCTAssertEqual(driver.installCount, 1) - } - - func testDisabledPolicySkipsSchedulingAndChecks() { - let driver = FakeUpdateDriver() - let scheduler = FakeScheduler() - let service = AppUpdateService( - driver: driver, - scheduler: scheduler, - versionProvider: FakeVersionProvider(currentVersion: "1.0.0"), - environment: FakeEnvironment(isRunningTests: true), - autoCheckEnabledProvider: { true } - ) - - XCTAssertEqual(service.state.status, .disabled) - XCTAssertTrue(scheduler.oneShotIntervals.isEmpty) - XCTAssertTrue(scheduler.repeatingIntervals.isEmpty) - - service.checkNow() - XCTAssertTrue(driver.checkCalls.isEmpty) - } - - func testAutoCheckToggleOffDisablesSchedulingButAllowsManualCheck() { - let driver = FakeUpdateDriver() - let scheduler = FakeScheduler() - let service = AppUpdateService( - driver: driver, - scheduler: scheduler, - versionProvider: FakeVersionProvider(currentVersion: "1.0.0"), - environment: FakeEnvironment(), - autoCheckEnabledProvider: { false } - ) - - XCTAssertEqual(service.state.status, .idle) - XCTAssertTrue(scheduler.oneShotIntervals.isEmpty) - XCTAssertTrue(scheduler.repeatingIntervals.isEmpty) - - service.checkNow() - XCTAssertEqual(driver.checkCalls.count, 1) - } - - func testCheckRequestsAreIgnoredDuringDownload() async { - let driver = FakeUpdateDriver() - let service = makeService(driver: driver) - - service.checkNow() - driver.emit(.checkSucceeded(availableVersion: "1.2.0", downloadURL: URL(string: "https://example.com/idx0.zip"))) - await flushMainActorTasks() - service.performPrimaryAction() // download - - service.checkNow() - - XCTAssertEqual(driver.downloadCount, 1) - XCTAssertEqual(driver.checkCalls.count, 1) - XCTAssertEqual(service.state.status, .downloading) - } - - func testContextualActionTitlesFollowState() async { - let driver = FakeUpdateDriver() - let service = makeService(driver: driver) - - XCTAssertNil(service.contextualMenuActionTitle) - - service.checkNow() - driver.emit(.checkSucceeded(availableVersion: "1.2.0", downloadURL: URL(string: "https://example.com/idx0.zip"))) - await flushMainActorTasks() - XCTAssertEqual(service.contextualMenuActionTitle, "Download Update") - - service.performPrimaryAction() - driver.emit(.downloadCompleted) - await flushMainActorTasks() - XCTAssertEqual(service.contextualMenuActionTitle, "Install Update") - - driver.emit(.installFailed(message: "nope")) - await flushMainActorTasks() - XCTAssertEqual(service.contextualMenuActionTitle, "Retry Update Check") - } - - private func makeService( - driver: FakeUpdateDriver, - scheduler: FakeScheduler = FakeScheduler(), - autoCheckEnabledProvider: @escaping () -> Bool = { true } - ) -> AppUpdateService { - AppUpdateService( - driver: driver, - scheduler: scheduler, - versionProvider: FakeVersionProvider(currentVersion: "1.0.0"), - environment: FakeEnvironment(), - autoCheckEnabledProvider: autoCheckEnabledProvider, - now: { Date(timeIntervalSince1970: 100) } - ) - } - - private func flushMainActorTasks() async { - await Task.yield() - await Task.yield() - } + func testStartupAndRepeatingChecksAreScheduledWhenEnabled() async { + let driver = FakeUpdateDriver() + let scheduler = FakeScheduler() + let environment = FakeEnvironment() + + let service = AppUpdateService( + driver: driver, + scheduler: scheduler, + versionProvider: FakeVersionProvider(currentVersion: "1.0.0"), + environment: environment, + autoCheckEnabledProvider: { true } + ) + _ = service + + XCTAssertEqual(scheduler.oneShotIntervals, [AppUpdateService.startupDelay]) + XCTAssertEqual(scheduler.repeatingIntervals, [AppUpdateService.pollInterval]) + + scheduler.fireOneShot(at: 0) + XCTAssertEqual(driver.checkCalls.count, 1) + XCTAssertEqual(driver.checkCalls.first?.currentVersion, "1.0.0") + + scheduler.fireRepeating(at: 0) + XCTAssertEqual(driver.checkCalls.count, 1, "second check should be blocked while checking") + + driver.emit(.checkSucceeded(availableVersion: nil, downloadURL: nil)) + await flushMainActorTasks() + scheduler.fireRepeating(at: 0) + XCTAssertEqual(driver.checkCalls.count, 2) + } + + func testManualCheckAndPrimaryActionsFollowStateMachine() async { + let driver = FakeUpdateDriver() + let service = makeService(driver: driver) + + service.checkNow() + XCTAssertEqual(driver.checkCalls.count, 1) + XCTAssertEqual(service.state.status, .checking) + + driver.emit(.checkSucceeded(availableVersion: "1.2.0", downloadURL: URL(string: "https://example.com/idx0.zip"))) + await flushMainActorTasks() + XCTAssertEqual(service.state.status, .available) + + service.performPrimaryAction() + XCTAssertEqual(driver.downloadCount, 1) + XCTAssertEqual(service.state.status, .downloading) + + driver.emit(.downloadCompleted) + await flushMainActorTasks() + XCTAssertEqual(service.state.status, .downloaded) + + service.performPrimaryAction() + XCTAssertEqual(driver.installCount, 1) + } + + func testDisabledPolicySkipsSchedulingAndChecks() { + let driver = FakeUpdateDriver() + let scheduler = FakeScheduler() + let service = AppUpdateService( + driver: driver, + scheduler: scheduler, + versionProvider: FakeVersionProvider(currentVersion: "1.0.0"), + environment: FakeEnvironment(isRunningTests: true), + autoCheckEnabledProvider: { true } + ) + + XCTAssertEqual(service.state.status, .disabled) + XCTAssertTrue(scheduler.oneShotIntervals.isEmpty) + XCTAssertTrue(scheduler.repeatingIntervals.isEmpty) + + service.checkNow() + XCTAssertTrue(driver.checkCalls.isEmpty) + } + + func testAutoCheckToggleOffDisablesSchedulingButAllowsManualCheck() { + let driver = FakeUpdateDriver() + let scheduler = FakeScheduler() + let service = AppUpdateService( + driver: driver, + scheduler: scheduler, + versionProvider: FakeVersionProvider(currentVersion: "1.0.0"), + environment: FakeEnvironment(), + autoCheckEnabledProvider: { false } + ) + + XCTAssertEqual(service.state.status, .idle) + XCTAssertTrue(scheduler.oneShotIntervals.isEmpty) + XCTAssertTrue(scheduler.repeatingIntervals.isEmpty) + + service.checkNow() + XCTAssertEqual(driver.checkCalls.count, 1) + } + + func testCheckRequestsAreIgnoredDuringDownload() async { + let driver = FakeUpdateDriver() + let service = makeService(driver: driver) + + service.checkNow() + driver.emit(.checkSucceeded(availableVersion: "1.2.0", downloadURL: URL(string: "https://example.com/idx0.zip"))) + await flushMainActorTasks() + service.performPrimaryAction() // download + + service.checkNow() + + XCTAssertEqual(driver.downloadCount, 1) + XCTAssertEqual(driver.checkCalls.count, 1) + XCTAssertEqual(service.state.status, .downloading) + } + + func testContextualActionTitlesFollowState() async { + let driver = FakeUpdateDriver() + let service = makeService(driver: driver) + + XCTAssertNil(service.contextualMenuActionTitle) + + service.checkNow() + driver.emit(.checkSucceeded(availableVersion: "1.2.0", downloadURL: URL(string: "https://example.com/idx0.zip"))) + await flushMainActorTasks() + XCTAssertEqual(service.contextualMenuActionTitle, "Download Update") + + service.performPrimaryAction() + driver.emit(.downloadCompleted) + await flushMainActorTasks() + XCTAssertEqual(service.contextualMenuActionTitle, "Install Update") + + driver.emit(.installFailed(message: "nope")) + await flushMainActorTasks() + XCTAssertEqual(service.contextualMenuActionTitle, "Retry Update Check") + } + + private func makeService( + driver: FakeUpdateDriver, + scheduler: FakeScheduler = FakeScheduler(), + autoCheckEnabledProvider: @escaping () -> Bool = { true } + ) -> AppUpdateService { + AppUpdateService( + driver: driver, + scheduler: scheduler, + versionProvider: FakeVersionProvider(currentVersion: "1.0.0"), + environment: FakeEnvironment(), + autoCheckEnabledProvider: autoCheckEnabledProvider, + now: { Date(timeIntervalSince1970: 100) } + ) + } + + private func flushMainActorTasks() async { + await Task.yield() + await Task.yield() + } } private struct FakeVersionProvider: AppVersionProviding { - let currentVersion: String + let currentVersion: String } private struct FakeEnvironment: EnvironmentProviding { - var isRunningTests: Bool = false - var isDebugBuild: Bool = false - var disableAutoUpdate: Bool = false - var updateFeedURLOverride: URL? = nil - var defaultUpdateFeedURL: URL? = URL(string: "https://example.com/appcast.xml") + var isRunningTests: Bool = false + var isDebugBuild: Bool = false + var disableAutoUpdate: Bool = false + var updateFeedURLOverride: URL? + var defaultUpdateFeedURL: URL? = URL(string: "https://example.com/appcast.xml") } @MainActor private final class FakeUpdateDriver: AppUpdateDriverProtocol { - struct CheckCall: Equatable { - let feedURLOverride: URL? - let currentVersion: String - } + struct CheckCall: Equatable { + let feedURLOverride: URL? + let currentVersion: String + } - var onEvent: ((AppUpdateDriverEvent) -> Void)? - private(set) var checkCalls: [CheckCall] = [] - private(set) var downloadCount = 0 - private(set) var installCount = 0 + var onEvent: ((AppUpdateDriverEvent) -> Void)? + private(set) var checkCalls: [CheckCall] = [] + private(set) var downloadCount = 0 + private(set) var installCount = 0 - func checkForUpdates(feedURLOverride: URL?, currentVersion: String) { - checkCalls.append(.init(feedURLOverride: feedURLOverride, currentVersion: currentVersion)) - } + func checkForUpdates(feedURLOverride: URL?, currentVersion: String) { + checkCalls.append(.init(feedURLOverride: feedURLOverride, currentVersion: currentVersion)) + } - func downloadUpdate() { - downloadCount += 1 - } + func downloadUpdate() { + downloadCount += 1 + } - func installUpdate() { - installCount += 1 - } + func installUpdate() { + installCount += 1 + } - func emit(_ event: AppUpdateDriverEvent) { - onEvent?(event) - } + func emit(_ event: AppUpdateDriverEvent) { + onEvent?(event) + } } @MainActor private final class FakeScheduler: UpdateSchedulerProtocol { - private final class Token: UpdateSchedulerCancellable { - var isCancelled = false - - func cancel() { - isCancelled = true - } - } - - private var oneShots: [(interval: TimeInterval, action: @Sendable @MainActor () -> Void, token: Token)] = [] - private var repeatings: [(interval: TimeInterval, action: @Sendable @MainActor () -> Void, token: Token)] = [] - - var oneShotIntervals: [TimeInterval] { - oneShots.map(\.interval) - } - - var repeatingIntervals: [TimeInterval] { - repeatings.map(\.interval) - } - - @discardableResult - func schedule( - after interval: TimeInterval, - _ action: @escaping @Sendable @MainActor () -> Void - ) -> UpdateSchedulerCancellable { - let token = Token() - oneShots.append((interval, action, token)) - return token - } - - @discardableResult - func scheduleRepeating( - every interval: TimeInterval, - _ action: @escaping @Sendable @MainActor () -> Void - ) -> UpdateSchedulerCancellable { - let token = Token() - repeatings.append((interval, action, token)) - return token - } - - func fireOneShot(at index: Int) { - guard oneShots.indices.contains(index) else { return } - let job = oneShots[index] - if !job.token.isCancelled { - job.action() - } - } - - func fireRepeating(at index: Int) { - guard repeatings.indices.contains(index) else { return } - let job = repeatings[index] - if !job.token.isCancelled { - job.action() - } - } + private final class Token: UpdateSchedulerCancellable { + var isCancelled = false + + func cancel() { + isCancelled = true + } + } + + private var oneShots: [(interval: TimeInterval, action: @Sendable @MainActor () -> Void, token: Token)] = [] + private var repeatings: [(interval: TimeInterval, action: @Sendable @MainActor () -> Void, token: Token)] = [] + + var oneShotIntervals: [TimeInterval] { + oneShots.map(\.interval) + } + + var repeatingIntervals: [TimeInterval] { + repeatings.map(\.interval) + } + + @discardableResult + func schedule( + after interval: TimeInterval, + _ action: @escaping @Sendable @MainActor () -> Void + ) -> UpdateSchedulerCancellable { + let token = Token() + oneShots.append((interval, action, token)) + return token + } + + @discardableResult + func scheduleRepeating( + every interval: TimeInterval, + _ action: @escaping @Sendable @MainActor () -> Void + ) -> UpdateSchedulerCancellable { + let token = Token() + repeatings.append((interval, action, token)) + return token + } + + func fireOneShot(at index: Int) { + guard oneShots.indices.contains(index) else { return } + let job = oneShots[index] + if !job.token.isCancelled { + job.action() + } + } + + func fireRepeating(at index: Int) { + guard repeatings.indices.contains(index) else { return } + let job = repeatings[index] + if !job.token.isCancelled { + job.action() + } + } } diff --git a/idx0Tests/AppcastScriptTests.swift b/idx0Tests/AppcastScriptTests.swift index 83a3fc4..dadb4ae 100644 --- a/idx0Tests/AppcastScriptTests.swift +++ b/idx0Tests/AppcastScriptTests.swift @@ -1,44 +1,44 @@ import Foundation -import XCTest @testable import idx0 +import XCTest final class AppcastScriptTests: XCTestCase { - func testBuildXMLExcludesPrereleaseByDefault() { - let entries = [ - makeEntry(version: "1.2.0", prerelease: false, publishedAt: Date(timeIntervalSince1970: 100)), - makeEntry(version: "1.3.0-beta.1", prerelease: true, publishedAt: Date(timeIntervalSince1970: 200)) - ] + func testBuildXMLExcludesPrereleaseByDefault() { + let entries = [ + makeEntry(version: "1.2.0", prerelease: false, publishedAt: Date(timeIntervalSince1970: 100)), + makeEntry(version: "1.3.0-beta.1", prerelease: true, publishedAt: Date(timeIntervalSince1970: 200)), + ] - let xml = AppcastFeedBuilder.buildXML(entries: entries) + let xml = AppcastFeedBuilder.buildXML(entries: entries) - XCTAssertTrue(xml.contains("IDX0 1.2.0")) - XCTAssertFalse(xml.contains("IDX0 1.3.0-beta.1")) - XCTAssertFalse(xml.contains("sparkle:shortVersionString=\"1.3.0-beta.1\"")) - } + XCTAssertTrue(xml.contains("IDX0 1.2.0")) + XCTAssertFalse(xml.contains("IDX0 1.3.0-beta.1")) + XCTAssertFalse(xml.contains("sparkle:shortVersionString=\"1.3.0-beta.1\"")) + } - func testBuildXMLIncludesPrereleaseWhenRequested() { - let entries = [ - makeEntry(version: "1.2.0", prerelease: false, publishedAt: Date(timeIntervalSince1970: 100)), - makeEntry(version: "1.3.0-beta.1", prerelease: true, publishedAt: Date(timeIntervalSince1970: 200)) - ] + func testBuildXMLIncludesPrereleaseWhenRequested() { + let entries = [ + makeEntry(version: "1.2.0", prerelease: false, publishedAt: Date(timeIntervalSince1970: 100)), + makeEntry(version: "1.3.0-beta.1", prerelease: true, publishedAt: Date(timeIntervalSince1970: 200)), + ] - let xml = AppcastFeedBuilder.buildXML(entries: entries, includePrerelease: true) + let xml = AppcastFeedBuilder.buildXML(entries: entries, includePrerelease: true) - XCTAssertTrue(xml.contains("IDX0 1.2.0")) - XCTAssertTrue(xml.contains("IDX0 1.3.0-beta.1")) - XCTAssertTrue(xml.contains("sparkle:shortVersionString=\"1.3.0-beta.1\"")) - } + XCTAssertTrue(xml.contains("IDX0 1.2.0")) + XCTAssertTrue(xml.contains("IDX0 1.3.0-beta.1")) + XCTAssertTrue(xml.contains("sparkle:shortVersionString=\"1.3.0-beta.1\"")) + } - private func makeEntry(version: String, prerelease: Bool, publishedAt: Date) -> AppcastReleaseEntry { - AppcastReleaseEntry( - version: version, - downloadURL: URL(string: "https://example.com/IDX0-\(version)-mac.zip")!, - length: 1024, - publishedAt: publishedAt, - prerelease: prerelease, - signature: nil, - minimumSystemVersion: "14.0", - notesURL: nil - ) - } + private func makeEntry(version: String, prerelease: Bool, publishedAt: Date) -> AppcastReleaseEntry { + AppcastReleaseEntry( + version: version, + downloadURL: URL(string: "https://example.com/IDX0-\(version)-mac.zip")!, + length: 1024, + publishedAt: publishedAt, + prerelease: prerelease, + signature: nil, + minimumSystemVersion: "14.0", + notesURL: nil + ) + } } diff --git a/idx0Tests/Keyboard/ShortcutRegistryTests.swift b/idx0Tests/Keyboard/ShortcutRegistryTests.swift index 0b913f9..ae8271d 100644 --- a/idx0Tests/Keyboard/ShortcutRegistryTests.swift +++ b/idx0Tests/Keyboard/ShortcutRegistryTests.swift @@ -1,118 +1,118 @@ -import XCTest @testable import idx0 +import XCTest final class ShortcutRegistryTests: XCTestCase { - func testDefaultSettingsHaveNoShortcutConflicts() { - let validator = ShortcutValidator() - let conflicts = validator.conflicts(for: AppSettings()) - XCTAssertTrue( - conflicts.isEmpty, - conflicts.map(\.message).joined(separator: "\n") - ) - } - - func testPrimaryBindingUsesMacDefaultInBothMode() { - let registry = ShortcutRegistry.shared - var settings = AppSettings() - settings.keybindingMode = .both - settings.modKeySetting = .commandOption - - let binding = registry.primaryBinding(for: .niriFocusLeft, settings: settings) - - XCTAssertEqual(binding?.key, .leftArrow) - XCTAssertEqual(binding?.modifiers, [.command, .option]) - } - - func testPrimaryBindingUsesNiriDefaultInNiriFirstMode() { - let registry = ShortcutRegistry.shared - var settings = AppSettings() - settings.keybindingMode = .niriFirst - settings.modKeySetting = .commandOption - - let binding = registry.primaryBinding(for: .niriFocusLeft, settings: settings) - - XCTAssertEqual(binding?.key, .h) - XCTAssertEqual(binding?.modifiers, [.command, .option]) - } - - func testNiriBindingUsesConfiguredModKey() { - let registry = ShortcutRegistry.shared - var settings = AppSettings() - settings.keybindingMode = .niriFirst - settings.modKeySetting = .control - - let binding = registry.primaryBinding(for: .niriFocusRight, settings: settings) - - XCTAssertEqual(binding?.key, .l) - XCTAssertEqual(binding?.modifiers, [.control]) - } - - func testNiriAddTerminalRightUsesModTInNiriFirstMode() { - let registry = ShortcutRegistry.shared - var settings = AppSettings() - settings.keybindingMode = .niriFirst - settings.modKeySetting = .commandOption - - let binding = registry.primaryBinding(for: .niriAddTerminalRight, settings: settings) - - XCTAssertEqual(binding?.key, .t) - XCTAssertEqual(binding?.modifiers, [.command, .option]) - } - - func testClosePaneUsesModWInNiriFirstMode() { - let registry = ShortcutRegistry.shared - var settings = AppSettings() - settings.keybindingMode = .niriFirst - settings.modKeySetting = .commandOption - - let binding = registry.primaryBinding(for: .closePane, settings: settings) - - XCTAssertEqual(binding?.key, .w) - XCTAssertEqual(binding?.modifiers, [.command, .option]) - } - - func testNiriTabbedToggleUsesModShiftTInNiriFirstMode() { - let registry = ShortcutRegistry.shared - var settings = AppSettings() - settings.keybindingMode = .niriFirst - settings.modKeySetting = .commandOption - - let binding = registry.primaryBinding(for: .niriToggleColumnTabbedDisplay, settings: settings) - - XCTAssertEqual(binding?.key, .t) - XCTAssertEqual(binding?.modifiers, [.command, .option, .shift]) - } - - func testCustomBindingOverridesPrimaryBinding() { - let registry = ShortcutRegistry.shared - var settings = AppSettings() - settings.keybindingMode = .custom - settings.customKeybindings[ShortcutActionID.niriFocusLeft.rawValue] = KeyChord(key: .x, modifiers: [.command]) - - let binding = registry.primaryBinding(for: .niriFocusLeft, settings: settings) - - XCTAssertEqual(binding?.key, .x) - XCTAssertEqual(binding?.modifiers, [.command]) - } - - func testValidatorDetectsConflictingCustomBindings() { - let validator = ShortcutValidator() - var settings = AppSettings() - settings.keybindingMode = .custom - let duplicate = KeyChord(key: .q, modifiers: [.command]) - settings.customKeybindings[ShortcutActionID.closeSession.rawValue] = duplicate - settings.customKeybindings[ShortcutActionID.closePane.rawValue] = duplicate - - let conflicts = validator.conflicts(for: settings) - - XCTAssertFalse(conflicts.isEmpty) - } - - func testCheckForUpdatesActionIsRegistered() { - let registry = ShortcutRegistry.shared - let descriptor = registry.descriptor(for: .checkForUpdates) - - XCTAssertEqual(descriptor?.title, "Check for Updates") - XCTAssertNil(registry.primaryBinding(for: .checkForUpdates, settings: AppSettings())) - } + func testDefaultSettingsHaveNoShortcutConflicts() { + let validator = ShortcutValidator() + let conflicts = validator.conflicts(for: AppSettings()) + XCTAssertTrue( + conflicts.isEmpty, + conflicts.map(\.message).joined(separator: "\n") + ) + } + + func testPrimaryBindingUsesMacDefaultInBothMode() { + let registry = ShortcutRegistry.shared + var settings = AppSettings() + settings.keybindingMode = .both + settings.modKeySetting = .commandOption + + let binding = registry.primaryBinding(for: .niriFocusLeft, settings: settings) + + XCTAssertEqual(binding?.key, .leftArrow) + XCTAssertEqual(binding?.modifiers, [.command, .option]) + } + + func testPrimaryBindingUsesNiriDefaultInNiriFirstMode() { + let registry = ShortcutRegistry.shared + var settings = AppSettings() + settings.keybindingMode = .niriFirst + settings.modKeySetting = .commandOption + + let binding = registry.primaryBinding(for: .niriFocusLeft, settings: settings) + + XCTAssertEqual(binding?.key, .h) + XCTAssertEqual(binding?.modifiers, [.command, .option]) + } + + func testNiriBindingUsesConfiguredModKey() { + let registry = ShortcutRegistry.shared + var settings = AppSettings() + settings.keybindingMode = .niriFirst + settings.modKeySetting = .control + + let binding = registry.primaryBinding(for: .niriFocusRight, settings: settings) + + XCTAssertEqual(binding?.key, .l) + XCTAssertEqual(binding?.modifiers, [.control]) + } + + func testNiriAddTerminalRightUsesModTInNiriFirstMode() { + let registry = ShortcutRegistry.shared + var settings = AppSettings() + settings.keybindingMode = .niriFirst + settings.modKeySetting = .commandOption + + let binding = registry.primaryBinding(for: .niriAddTerminalRight, settings: settings) + + XCTAssertEqual(binding?.key, .t) + XCTAssertEqual(binding?.modifiers, [.command, .option]) + } + + func testClosePaneUsesModWInNiriFirstMode() { + let registry = ShortcutRegistry.shared + var settings = AppSettings() + settings.keybindingMode = .niriFirst + settings.modKeySetting = .commandOption + + let binding = registry.primaryBinding(for: .closePane, settings: settings) + + XCTAssertEqual(binding?.key, .w) + XCTAssertEqual(binding?.modifiers, [.command, .option]) + } + + func testNiriTabbedToggleUsesModShiftTInNiriFirstMode() { + let registry = ShortcutRegistry.shared + var settings = AppSettings() + settings.keybindingMode = .niriFirst + settings.modKeySetting = .commandOption + + let binding = registry.primaryBinding(for: .niriToggleColumnTabbedDisplay, settings: settings) + + XCTAssertEqual(binding?.key, .t) + XCTAssertEqual(binding?.modifiers, [.command, .option, .shift]) + } + + func testCustomBindingOverridesPrimaryBinding() { + let registry = ShortcutRegistry.shared + var settings = AppSettings() + settings.keybindingMode = .custom + settings.customKeybindings[ShortcutActionID.niriFocusLeft.rawValue] = KeyChord(key: .x, modifiers: [.command]) + + let binding = registry.primaryBinding(for: .niriFocusLeft, settings: settings) + + XCTAssertEqual(binding?.key, .x) + XCTAssertEqual(binding?.modifiers, [.command]) + } + + func testValidatorDetectsConflictingCustomBindings() { + let validator = ShortcutValidator() + var settings = AppSettings() + settings.keybindingMode = .custom + let duplicate = KeyChord(key: .q, modifiers: [.command]) + settings.customKeybindings[ShortcutActionID.closeSession.rawValue] = duplicate + settings.customKeybindings[ShortcutActionID.closePane.rawValue] = duplicate + + let conflicts = validator.conflicts(for: settings) + + XCTAssertFalse(conflicts.isEmpty) + } + + func testCheckForUpdatesActionIsRegistered() { + let registry = ShortcutRegistry.shared + let descriptor = registry.descriptor(for: .checkForUpdates) + + XCTAssertEqual(descriptor?.title, "Check for Updates") + XCTAssertNil(registry.primaryBinding(for: .checkForUpdates, settings: AppSettings())) + } }