diff --git a/Bugger.xcodeproj/project.pbxproj b/Bugger.xcodeproj/project.pbxproj index dc31b2b..c17e858 100644 --- a/Bugger.xcodeproj/project.pbxproj +++ b/Bugger.xcodeproj/project.pbxproj @@ -7,72 +7,84 @@ objects = { /* Begin PBXBuildFile section */ - 15550246A9E8DAF25E690478 /* BuggerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDB7AD270E93EA343975AC6 /* BuggerApp.swift */; }; - 1E3C1F40E2FF64A041856909 /* BugListPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEABFC668F7B082847F65D38 /* BugListPopover.swift */; }; - 1FB7E762C0B6568C47B48EF0 /* URL+Feishu.swift in Sources */ = {isa = PBXBuildFile; fileRef = F888AA5F655343B19839B4C2 /* URL+Feishu.swift */; }; - 29AE37B117A1422EA21570CA /* DateFormatter+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87C935E6B4D1813AB01186A0 /* DateFormatter+Extensions.swift */; }; - 29EE2FE56882F97E14AAF58F /* FeishuService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21AC33F380B7DC34C39B61A6 /* FeishuService.swift */; }; - 3467A50268399ABE9804D4D4 /* BugRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1129D6F630904F03D06B5DC4 /* BugRow.swift */; }; - 4309E8C70AE99466545A3E37 /* FeishuAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A2C50E47966E8ADB0446A0C /* FeishuAuthService.swift */; }; - 47ED652FA0AC818086F85DF3 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ED0A90DFCB8D4968E4E3CA1 /* Logger.swift */; }; - 485A69E15EF1D805F50DF364 /* FloatingWidgetWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2FE32B404AB4388A066991 /* FloatingWidgetWindow.swift */; }; - 4EA9BDBA3F98AF4A41712A18 /* Bug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 846CA001267896C54A78441A /* Bug.swift */; }; - 56D44DCBAF4FE0E1D9E620FA /* PollerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F4628E7B599B69B499E09E9 /* PollerService.swift */; }; - 58F16D20CC26D8248055CC2C /* AppConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBEF4F77A6A2E2D041EF581 /* AppConfig.swift */; }; - 6148104B311B60C79B3B8572 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59CC175DE8E9FE3250757D33 /* NotificationService.swift */; }; - 64F1EE978536D1CB02B8F0D3 /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF06E1030AA21DA8190169A8 /* KeychainHelper.swift */; }; - 7B0EAA6D83D40A593E36EE31 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3693AA93F823859D15256D /* SettingsView.swift */; }; - A33561284CA44A0045902BCC /* BugPriority.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5359156BD70B765D2BF4E718 /* BugPriority.swift */; }; - AECED74595B57A400437B9FD /* BugStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 344F52FB34B285D91A3E4670 /* BugStore.swift */; }; - B066D78DA6F57A882A61C0B2 /* OAuthSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C0A744D4F5DB88B1496176B /* OAuthSetupView.swift */; }; - B882D310EB2B5D032375CDDF /* TokenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E7E3775F77DD36C27160A8C /* TokenManager.swift */; }; - C3F7FEB179B079BDDD2AA212 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 09B9A4D84F3F604C49171A89 /* Assets.xcassets */; }; - C5D9B1FACE813CD43236BB77 /* Color+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F99094B0F609809EBDB5F4A /* Color+Extensions.swift */; }; - CAE624453A3F0375D872170A /* FeishuModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE9FBC39502FA635EF1A6A5C /* FeishuModels.swift */; }; - CB529549DB1DBB5F02884300 /* FeishuError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A83E76FBB83849E4B6704D3 /* FeishuError.swift */; }; - D2855F82E68FADA8BEA43086 /* AppStateService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1DD178095EFCE7BC121C1C4 /* AppStateService.swift */; }; - 7E1F2A3B4C5D6E7F8091A2B3 /* BitableEventService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F2E3D4C5B6A7988061A2B3C /* BitableEventService.swift */; }; - A1B2C3D4E5F60718293A4B5C /* CalibrationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F70819293A4B5 /* CalibrationService.swift */; }; - DDB9EB345FEBB95DD9976364 /* BugChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B24E1E1D32F7B774D12DD85 /* BugChange.swift */; }; - E4CBAE27491AF1C52944FA91 /* BugStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 646C676356A4125B8E8D5FA5 /* BugStatus.swift */; }; - EEC5853D00ED565270DC867F /* LocalOAuthServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34D11523ACB81DBB9D412995 /* LocalOAuthServer.swift */; }; - F6750BB1FBEE5BC90DD6BDBC /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 207CA4381D0CFEC95263A2DE /* AppDelegate.swift */; }; + F6750BB1FBEE5BC90DD6BDBC /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 207CA4381D0CFEC95263A2DE; }; + 15550246A9E8DAF25E690478 /* BuggerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDB7AD270E93EA343975AC6; }; + 58F16D20CC26D8248055CC2C /* AppConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBEF4F77A6A2E2D041EF581; }; + 4EA9BDBA3F98AF4A41712A18 /* Bug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 846CA001267896C54A78441A; }; + 7AD8DF0FC133A2AE26F3E3C2 /* BugAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08C488C534B87C0560AF960F; }; + DDB9EB345FEBB95DD9976364 /* BugChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B24E1E1D32F7B774D12DD85; }; + A33561284CA44A0045902BCC /* BugPriority.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5359156BD70B765D2BF4E718; }; + E4CBAE27491AF1C52944FA91 /* BugStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 646C676356A4125B8E8D5FA5; }; + D2855F82E68FADA8BEA43086 /* AppStateService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1DD178095EFCE7BC121C1C4; }; + BD123E3404B92204E7CB96DF /* AttachmentLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCF76E668F6EFF64241248E3; }; + A31640CA64AEAD8FEC551137 /* BitableEventService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E834D262A6EE1D5406686761; }; + 660734535DE7B5840B0DD24B /* BugEditService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3CA07056AACE5378CF9D421; }; + BCF7698E979D7452AAC4612A /* CalibrationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52C52B8EDA29A06D9DAA6E5D; }; + 4309E8C70AE99466545A3E37 /* FeishuAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A2C50E47966E8ADB0446A0C; }; + CB529549DB1DBB5F02884300 /* FeishuError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A83E76FBB83849E4B6704D3; }; + CAE624453A3F0375D872170A /* FeishuModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE9FBC39502FA635EF1A6A5C; }; + 29EE2FE56882F97E14AAF58F /* FeishuService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21AC33F380B7DC34C39B61A6; }; + 6148104B311B60C79B3B8572 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59CC175DE8E9FE3250757D33; }; + 56D44DCBAF4FE0E1D9E620FA /* PollerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F4628E7B599B69B499E09E9; }; + B882D310EB2B5D032375CDDF /* TokenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E7E3775F77DD36C27160A8C; }; + C5D9B1FACE813CD43236BB77 /* Color+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F99094B0F609809EBDB5F4A; }; + 29AE37B117A1422EA21570CA /* DateFormatter+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87C935E6B4D1813AB01186A0; }; + 64F1EE978536D1CB02B8F0D3 /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF06E1030AA21DA8190169A8; }; + EEC5853D00ED565270DC867F /* LocalOAuthServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34D11523ACB81DBB9D412995; }; + 47ED652FA0AC818086F85DF3 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ED0A90DFCB8D4968E4E3CA1; }; + 1FB7E762C0B6568C47B48EF0 /* URL+Feishu.swift in Sources */ = {isa = PBXBuildFile; fileRef = F888AA5F655343B19839B4C2; }; + AECED74595B57A400437B9FD /* BugStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 344F52FB34B285D91A3E4670; }; + AD91D07ED264D88DA92E49DA /* MainWindowState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A45A7E013FA764EFAD7C9463; }; + 485A69E15EF1D805F50DF364 /* FloatingWidgetWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2FE32B404AB4388A066991; }; + F5855822732C5D79A6BFF1A6 /* BugDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD8CEC0D176C83BC46992923; }; + 899C203A1CCFE8532BB228A2 /* MainWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9AB6328F122ACEDACC9A2FB; }; + 1E3C1F40E2FF64A041856909 /* BugListPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEABFC668F7B082847F65D38; }; + 3467A50268399ABE9804D4D4 /* BugRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1129D6F630904F03D06B5DC4; }; + B066D78DA6F57A882A61C0B2 /* OAuthSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C0A744D4F5DB88B1496176B; }; + 7B0EAA6D83D40A593E36EE31 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3693AA93F823859D15256D; }; + C3F7FEB179B079BDDD2AA212 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 09B9A4D84F3F604C49171A89; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - 09B9A4D84F3F604C49171A89 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 0B24E1E1D32F7B774D12DD85 /* BugChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugChange.swift; sourceTree = ""; }; - 0E7E3775F77DD36C27160A8C /* TokenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenManager.swift; sourceTree = ""; }; - 0ED0A90DFCB8D4968E4E3CA1 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; - 0F99094B0F609809EBDB5F4A /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = ""; }; - 1129D6F630904F03D06B5DC4 /* BugRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugRow.swift; sourceTree = ""; }; - 207CA4381D0CFEC95263A2DE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 21AC33F380B7DC34C39B61A6 /* FeishuService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuService.swift; sourceTree = ""; }; - 2A83E76FBB83849E4B6704D3 /* FeishuError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuError.swift; sourceTree = ""; }; - 2D3693AA93F823859D15256D /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; - 344F52FB34B285D91A3E4670 /* BugStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugStore.swift; sourceTree = ""; }; - 34D11523ACB81DBB9D412995 /* LocalOAuthServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalOAuthServer.swift; sourceTree = ""; }; - 3C0A744D4F5DB88B1496176B /* OAuthSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthSetupView.swift; sourceTree = ""; }; - 4F4628E7B599B69B499E09E9 /* PollerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollerService.swift; sourceTree = ""; }; - 5359156BD70B765D2BF4E718 /* BugPriority.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugPriority.swift; sourceTree = ""; }; - 59CC175DE8E9FE3250757D33 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; - 646C676356A4125B8E8D5FA5 /* BugStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugStatus.swift; sourceTree = ""; }; - 6A2C50E47966E8ADB0446A0C /* FeishuAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuAuthService.swift; sourceTree = ""; }; - 6E2FE32B404AB4388A066991 /* FloatingWidgetWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingWidgetWindow.swift; sourceTree = ""; }; - 73AB234B54673C838E33AC7F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 846CA001267896C54A78441A /* Bug.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bug.swift; sourceTree = ""; }; - 87C935E6B4D1813AB01186A0 /* DateFormatter+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DateFormatter+Extensions.swift"; sourceTree = ""; }; - 9FBEF4F77A6A2E2D041EF581 /* AppConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfig.swift; sourceTree = ""; }; - CEABFC668F7B082847F65D38 /* BugListPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugListPopover.swift; sourceTree = ""; }; - DDDB7AD270E93EA343975AC6 /* BuggerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuggerApp.swift; sourceTree = ""; }; - E9B6FE6E06295095649B24A7 /* Bugger.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Bugger.entitlements; sourceTree = ""; }; EF0182DF85A7CD0319333F24 /* Bugger.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Bugger.app; sourceTree = BUILT_PRODUCTS_DIR; }; - EF06E1030AA21DA8190169A8 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = ""; }; + 207CA4381D0CFEC95263A2DE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + DDDB7AD270E93EA343975AC6 /* BuggerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuggerApp.swift; sourceTree = ""; }; + 9FBEF4F77A6A2E2D041EF581 /* AppConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfig.swift; sourceTree = ""; }; + 846CA001267896C54A78441A /* Bug.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bug.swift; sourceTree = ""; }; + 08C488C534B87C0560AF960F /* BugAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugAttachment.swift; sourceTree = ""; }; + 0B24E1E1D32F7B774D12DD85 /* BugChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugChange.swift; sourceTree = ""; }; + 5359156BD70B765D2BF4E718 /* BugPriority.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugPriority.swift; sourceTree = ""; }; + 646C676356A4125B8E8D5FA5 /* BugStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugStatus.swift; sourceTree = ""; }; F1DD178095EFCE7BC121C1C4 /* AppStateService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateService.swift; sourceTree = ""; }; - 8F2E3D4C5B6A7988061A2B3C /* BitableEventService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitableEventService.swift; sourceTree = ""; }; - 1A2B3C4D5E6F70819293A4B5 /* CalibrationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalibrationService.swift; sourceTree = ""; }; - F888AA5F655343B19839B4C2 /* URL+Feishu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Feishu.swift"; sourceTree = ""; }; + CCF76E668F6EFF64241248E3 /* AttachmentLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentLoader.swift; sourceTree = ""; }; + E834D262A6EE1D5406686761 /* BitableEventService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitableEventService.swift; sourceTree = ""; }; + D3CA07056AACE5378CF9D421 /* BugEditService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugEditService.swift; sourceTree = ""; }; + 52C52B8EDA29A06D9DAA6E5D /* CalibrationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalibrationService.swift; sourceTree = ""; }; + 6A2C50E47966E8ADB0446A0C /* FeishuAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuAuthService.swift; sourceTree = ""; }; + 2A83E76FBB83849E4B6704D3 /* FeishuError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuError.swift; sourceTree = ""; }; FE9FBC39502FA635EF1A6A5C /* FeishuModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuModels.swift; sourceTree = ""; }; + 21AC33F380B7DC34C39B61A6 /* FeishuService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuService.swift; sourceTree = ""; }; + 59CC175DE8E9FE3250757D33 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; + 4F4628E7B599B69B499E09E9 /* PollerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollerService.swift; sourceTree = ""; }; + 0E7E3775F77DD36C27160A8C /* TokenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenManager.swift; sourceTree = ""; }; + 0F99094B0F609809EBDB5F4A /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = ""; }; + 87C935E6B4D1813AB01186A0 /* DateFormatter+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DateFormatter+Extensions.swift"; sourceTree = ""; }; + EF06E1030AA21DA8190169A8 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = ""; }; + 34D11523ACB81DBB9D412995 /* LocalOAuthServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalOAuthServer.swift; sourceTree = ""; }; + 0ED0A90DFCB8D4968E4E3CA1 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; + F888AA5F655343B19839B4C2 /* URL+Feishu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Feishu.swift"; sourceTree = ""; }; + 344F52FB34B285D91A3E4670 /* BugStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugStore.swift; sourceTree = ""; }; + A45A7E013FA764EFAD7C9463 /* MainWindowState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindowState.swift; sourceTree = ""; }; + 6E2FE32B404AB4388A066991 /* FloatingWidgetWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingWidgetWindow.swift; sourceTree = ""; }; + AD8CEC0D176C83BC46992923 /* BugDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugDetailView.swift; sourceTree = ""; }; + C9AB6328F122ACEDACC9A2FB /* MainWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindowView.swift; sourceTree = ""; }; + CEABFC668F7B082847F65D38 /* BugListPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugListPopover.swift; sourceTree = ""; }; + 1129D6F630904F03D06B5DC4 /* BugRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugRow.swift; sourceTree = ""; }; + 3C0A744D4F5DB88B1496176B /* OAuthSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthSetupView.swift; sourceTree = ""; }; + 2D3693AA93F823859D15256D /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 09B9A4D84F3F604C49171A89 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 73AB234B54673C838E33AC7F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + E9B6FE6E06295095649B24A7 /* Bugger.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Bugger.entitlements; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -86,7 +98,7 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 14ED1DECD6D906F498B9ECB5 = { + 14ED1DECD6D906F498B9ECB5 /* Root */ = { isa = PBXGroup; children = ( 287BE755981C092238309A9A /* Sources */, @@ -109,22 +121,12 @@ path = Sources; sourceTree = ""; }; - 31DF436612EC1C3CD1194AA1 /* Views */ = { - isa = PBXGroup; - children = ( - E7E4AD241EEE594C1EEB9724 /* FloatingWidget */, - EA9ED591BB8A18FEBD1B58A3 /* MenuBar */, - 74AD95EF49A7D01C83865FAC /* OAuth */, - B70D2E54AD6B19DD455A8F82 /* Settings */, - ); - path = Views; - sourceTree = ""; - }; 65997CD9BD6FFEF8851BF59E /* Models */ = { isa = PBXGroup; children = ( 9FBEF4F77A6A2E2D041EF581 /* AppConfig.swift */, 846CA001267896C54A78441A /* Bug.swift */, + 08C488C534B87C0560AF960F /* BugAttachment.swift */, 0B24E1E1D32F7B774D12DD85 /* BugChange.swift */, 5359156BD70B765D2BF4E718 /* BugPriority.swift */, 646C676356A4125B8E8D5FA5 /* BugStatus.swift */, @@ -132,30 +134,20 @@ path = Models; sourceTree = ""; }; - 6AB9730AA11690EFC8F21473 /* ViewModels */ = { + A8C46168E4DFCE29B056A4E2 /* Services */ = { isa = PBXGroup; children = ( - 344F52FB34B285D91A3E4670 /* BugStore.swift */, + F1DD178095EFCE7BC121C1C4 /* AppStateService.swift */, + CCF76E668F6EFF64241248E3 /* AttachmentLoader.swift */, + E834D262A6EE1D5406686761 /* BitableEventService.swift */, + D3CA07056AACE5378CF9D421 /* BugEditService.swift */, + 52C52B8EDA29A06D9DAA6E5D /* CalibrationService.swift */, + 59CC175DE8E9FE3250757D33 /* NotificationService.swift */, + 4F4628E7B599B69B499E09E9 /* PollerService.swift */, + 0E7E3775F77DD36C27160A8C /* TokenManager.swift */, + E0E23D3899D89889546FB428 /* Feishu */, ); - path = ViewModels; - sourceTree = ""; - }; - 74AD95EF49A7D01C83865FAC /* OAuth */ = { - isa = PBXGroup; - children = ( - 3C0A744D4F5DB88B1496176B /* OAuthSetupView.swift */, - ); - path = OAuth; - sourceTree = ""; - }; - 8AD6A16612A2DB7B2F899787 /* Resources */ = { - isa = PBXGroup; - children = ( - 09B9A4D84F3F604C49171A89 /* Assets.xcassets */, - 73AB234B54673C838E33AC7F /* Info.plist */, - E9B6FE6E06295095649B24A7 /* Bugger.entitlements */, - ); - path = Resources; + path = Services; sourceTree = ""; }; A6646A2065F53ED50AD2CCA7 /* Utils */ = { @@ -171,34 +163,25 @@ path = Utils; sourceTree = ""; }; - A8C46168E4DFCE29B056A4E2 /* Services */ = { + 6AB9730AA11690EFC8F21473 /* ViewModels */ = { isa = PBXGroup; children = ( - F1DD178095EFCE7BC121C1C4 /* AppStateService.swift */, - 8F2E3D4C5B6A7988061A2B3C /* BitableEventService.swift */, - 1A2B3C4D5E6F70819293A4B5 /* CalibrationService.swift */, - 59CC175DE8E9FE3250757D33 /* NotificationService.swift */, - 4F4628E7B599B69B499E09E9 /* PollerService.swift */, - 0E7E3775F77DD36C27160A8C /* TokenManager.swift */, - E0E23D3899D89889546FB428 /* Feishu */, + 344F52FB34B285D91A3E4670 /* BugStore.swift */, + A45A7E013FA764EFAD7C9463 /* MainWindowState.swift */, ); - path = Services; + path = ViewModels; sourceTree = ""; }; - B23D89B2D2DF3745A003AEB1 /* Products */ = { + 31DF436612EC1C3CD1194AA1 /* Views */ = { isa = PBXGroup; children = ( - EF0182DF85A7CD0319333F24 /* Bugger.app */, + E7E4AD241EEE594C1EEB9724 /* FloatingWidget */, + 5C01CC809DFAF826A51F2AEF /* MainWindow */, + EA9ED591BB8A18FEBD1B58A3 /* MenuBar */, + 74AD95EF49A7D01C83865FAC /* OAuth */, + B70D2E54AD6B19DD455A8F82 /* Settings */, ); - name = Products; - sourceTree = ""; - }; - B70D2E54AD6B19DD455A8F82 /* Settings */ = { - isa = PBXGroup; - children = ( - 2D3693AA93F823859D15256D /* SettingsView.swift */, - ); - path = Settings; + path = Views; sourceTree = ""; }; E0E23D3899D89889546FB428 /* Feishu */ = { @@ -220,6 +203,15 @@ path = FloatingWidget; sourceTree = ""; }; + 5C01CC809DFAF826A51F2AEF /* MainWindow */ = { + isa = PBXGroup; + children = ( + AD8CEC0D176C83BC46992923 /* BugDetailView.swift */, + C9AB6328F122ACEDACC9A2FB /* MainWindowView.swift */, + ); + path = MainWindow; + sourceTree = ""; + }; EA9ED591BB8A18FEBD1B58A3 /* MenuBar */ = { isa = PBXGroup; children = ( @@ -229,6 +221,40 @@ path = MenuBar; sourceTree = ""; }; + 74AD95EF49A7D01C83865FAC /* OAuth */ = { + isa = PBXGroup; + children = ( + 3C0A744D4F5DB88B1496176B /* OAuthSetupView.swift */, + ); + path = OAuth; + sourceTree = ""; + }; + B70D2E54AD6B19DD455A8F82 /* Settings */ = { + isa = PBXGroup; + children = ( + 2D3693AA93F823859D15256D /* SettingsView.swift */, + ); + path = Settings; + sourceTree = ""; + }; + 8AD6A16612A2DB7B2F899787 /* Resources */ = { + isa = PBXGroup; + children = ( + 09B9A4D84F3F604C49171A89 /* Assets.xcassets */, + 73AB234B54673C838E33AC7F /* Info.plist */, + E9B6FE6E06295095649B24A7 /* Bugger.entitlements */, + ); + path = Resources; + sourceTree = ""; + }; + B23D89B2D2DF3745A003AEB1 /* Products */ = { + isa = PBXGroup; + children = ( + EF0182DF85A7CD0319333F24 /* Bugger.app */, + ); + name = Products; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -302,12 +328,15 @@ 15550246A9E8DAF25E690478 /* BuggerApp.swift in Sources */, 58F16D20CC26D8248055CC2C /* AppConfig.swift in Sources */, 4EA9BDBA3F98AF4A41712A18 /* Bug.swift in Sources */, + 7AD8DF0FC133A2AE26F3E3C2 /* BugAttachment.swift in Sources */, DDB9EB345FEBB95DD9976364 /* BugChange.swift in Sources */, A33561284CA44A0045902BCC /* BugPriority.swift in Sources */, E4CBAE27491AF1C52944FA91 /* BugStatus.swift in Sources */, D2855F82E68FADA8BEA43086 /* AppStateService.swift in Sources */, - 7E1F2A3B4C5D6E7F8091A2B3 /* BitableEventService.swift in Sources */, - A1B2C3D4E5F60718293A4B5C /* CalibrationService.swift in Sources */, + BD123E3404B92204E7CB96DF /* AttachmentLoader.swift in Sources */, + A31640CA64AEAD8FEC551137 /* BitableEventService.swift in Sources */, + 660734535DE7B5840B0DD24B /* BugEditService.swift in Sources */, + BCF7698E979D7452AAC4612A /* CalibrationService.swift in Sources */, 4309E8C70AE99466545A3E37 /* FeishuAuthService.swift in Sources */, CB529549DB1DBB5F02884300 /* FeishuError.swift in Sources */, CAE624453A3F0375D872170A /* FeishuModels.swift in Sources */, @@ -322,7 +351,10 @@ 47ED652FA0AC818086F85DF3 /* Logger.swift in Sources */, 1FB7E762C0B6568C47B48EF0 /* URL+Feishu.swift in Sources */, AECED74595B57A400437B9FD /* BugStore.swift in Sources */, + AD91D07ED264D88DA92E49DA /* MainWindowState.swift in Sources */, 485A69E15EF1D805F50DF364 /* FloatingWidgetWindow.swift in Sources */, + F5855822732C5D79A6BFF1A6 /* BugDetailView.swift in Sources */, + 899C203A1CCFE8532BB228A2 /* MainWindowView.swift in Sources */, 1E3C1F40E2FF64A041856909 /* BugListPopover.swift in Sources */, 3467A50268399ABE9804D4D4 /* BugRow.swift in Sources */, B066D78DA6F57A882A61C0B2 /* OAuthSetupView.swift in Sources */, @@ -362,15 +394,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 5; - FEISHU_APP_ID = cli_aacb82a1c0b8dcc0; - FEISHU_APP_SECRET = jUkU9ygRuQ0KaZFV2BSPfe33iXCahzNM; - FEISHU_BASE_DOMAIN = xmi5p6bgsnf8.feishu.cn; + FEISHU_APP_ID = "$(FEISHU_APP_ID)"; + FEISHU_APP_SECRET = "$(FEISHU_APP_SECRET)"; + FEISHU_BASE_DOMAIN = "$(FEISHU_BASE_DOMAIN)"; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Resources/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.5; PRODUCT_BUNDLE_IDENTIFIER = com.xorbitlab.bugger; @@ -386,15 +415,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 5; - FEISHU_APP_ID = cli_aacb82a1c0b8dcc0; - FEISHU_APP_SECRET = jUkU9ygRuQ0KaZFV2BSPfe33iXCahzNM; - FEISHU_BASE_DOMAIN = xmi5p6bgsnf8.feishu.cn; + FEISHU_APP_ID = "$(FEISHU_APP_ID)"; + FEISHU_APP_SECRET = "$(FEISHU_APP_SECRET)"; + FEISHU_BASE_DOMAIN = "$(FEISHU_BASE_DOMAIN)"; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Resources/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.5; PRODUCT_BUNDLE_IDENTIFIER = com.xorbitlab.bugger; @@ -426,6 +452,7 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + }; rootObject = 46F86FAA6BBF9AC94A7E4595 /* Project object */; } diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 3330251..eb19f01 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -75,6 +75,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { popover.performClose(nil) } + /// Opens the popover programmatically (e.g. to show the OAuth setup view + /// when a write fails due to a missing scope). + func openPopover() { + guard let button = statusItem?.button, !popover.isShown else { return } + popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + popover.contentViewController?.view.window?.makeKey() + } + func showFloatingWidget() { guard floatingWidget == nil else { return } floatingWidget = FloatingWidgetWindow() diff --git a/Sources/BuggerApp.swift b/Sources/BuggerApp.swift index 9224075..1c4fce5 100644 --- a/Sources/BuggerApp.swift +++ b/Sources/BuggerApp.swift @@ -5,6 +5,11 @@ struct BuggerApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate var body: some Scene { + Window("Bugger", id: "main") { + MainWindowView() + } + .defaultSize(width: 900, height: 600) + Settings { SettingsView() } diff --git a/Sources/Models/AppConfig.swift b/Sources/Models/AppConfig.swift index 25eb869..5406075 100644 --- a/Sources/Models/AppConfig.swift +++ b/Sources/Models/AppConfig.swift @@ -37,6 +37,19 @@ struct AppConfig: Codable, Equatable { var customerField: String = "客户名称" var createdAtField: String = "提出时间" var updatedAtField: String = "最后更新时间" + /// Field shown as the description in the bug detail window. + /// Empty = not mapped (description section hidden). + var descriptionField: String = "" + /// 对应模块 — module (multi-select) shown in the bug detail window. + var moduleField: String = "对应模块" + /// 来源 — where the bug was reported. + var sourceField: String = "来源" + /// 流转状态 — workflow status shown as raw text in the detail window. + var flowStatusField: String = "流转状态" + /// 修复备注 — fix notes shown in the bug detail window. + var fixNotesField: String = "修复备注" + /// 相关截图 — attachment field whose images are shown in the detail window. + var screenshotsField: String = "相关截图" /// Map Feishu display values to standard BugStatus cases. /// Keys are the Feishu select-option text; values are the standard status. @@ -59,6 +72,37 @@ struct AppConfig: Codable, Equatable { "转需求": .closed, "验收不通过": .closed, ] + + // Tolerant decoding: configs persisted before a field was added + // (missing key) must not fail to decode and wipe settings. + enum CodingKeys: String, CodingKey { + case titleField, priorityField, statusField, assigneeField + case reporterField, customerField, createdAtField, updatedAtField + case descriptionField, statusMappings + case moduleField, sourceField, flowStatusField, fixNotesField, screenshotsField + } + + init() {} + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let defaults = FieldMappings() + titleField = try c.decodeIfPresent(String.self, forKey: .titleField) ?? defaults.titleField + priorityField = try c.decodeIfPresent(String.self, forKey: .priorityField) ?? defaults.priorityField + statusField = try c.decodeIfPresent(String.self, forKey: .statusField) ?? defaults.statusField + assigneeField = try c.decodeIfPresent(String.self, forKey: .assigneeField) ?? defaults.assigneeField + reporterField = try c.decodeIfPresent(String.self, forKey: .reporterField) ?? defaults.reporterField + customerField = try c.decodeIfPresent(String.self, forKey: .customerField) ?? defaults.customerField + createdAtField = try c.decodeIfPresent(String.self, forKey: .createdAtField) ?? defaults.createdAtField + updatedAtField = try c.decodeIfPresent(String.self, forKey: .updatedAtField) ?? defaults.updatedAtField + descriptionField = try c.decodeIfPresent(String.self, forKey: .descriptionField) ?? defaults.descriptionField + moduleField = try c.decodeIfPresent(String.self, forKey: .moduleField) ?? defaults.moduleField + sourceField = try c.decodeIfPresent(String.self, forKey: .sourceField) ?? defaults.sourceField + flowStatusField = try c.decodeIfPresent(String.self, forKey: .flowStatusField) ?? defaults.flowStatusField + fixNotesField = try c.decodeIfPresent(String.self, forKey: .fixNotesField) ?? defaults.fixNotesField + screenshotsField = try c.decodeIfPresent(String.self, forKey: .screenshotsField) ?? defaults.screenshotsField + statusMappings = try c.decodeIfPresent([String: BugStatus].self, forKey: .statusMappings) ?? defaults.statusMappings + } } var isConfigured: Bool { diff --git a/Sources/Models/Bug.swift b/Sources/Models/Bug.swift index b6811f3..043c5f2 100644 --- a/Sources/Models/Bug.swift +++ b/Sources/Models/Bug.swift @@ -3,11 +3,25 @@ import Foundation struct Bug: Identifiable, Equatable, Hashable { let id: String let title: String + let description: String? let priority: BugPriority let status: BugStatus + /// Raw Feishu status option text (e.g. "验收完毕") — needed to preselect + /// the editable status picker; `status` alone loses it via enum mapping. + let statusRaw: String? let assignee: String let reporter: String? let customer: String? + /// 对应模块 — module tags, joined into a single display string. + let module: String? + /// 来源 — where the bug was reported (e.g. 运营发现 / 客户提出). + let source: String? + /// 流转状态 — raw workflow status text (e.g. Verify / 已结束). + let flowStatus: String? + /// 修复备注 — fix notes. + let fixNotes: String? + /// 相关截图 — attached screenshots. + let screenshots: [BugAttachment] let createdAt: Date let updatedAt: Date let feishuURL: URL? diff --git a/Sources/Models/BugAttachment.swift b/Sources/Models/BugAttachment.swift new file mode 100644 index 0000000..c1345eb --- /dev/null +++ b/Sources/Models/BugAttachment.swift @@ -0,0 +1,18 @@ +import Foundation + +/// A file attached to a Bitable attachment field (e.g. 相关截图). +struct BugAttachment: Equatable, Hashable { + let fileToken: String + let name: String + let size: Int + let mimeType: String? + /// Record the attachment belongs to — required to build the `extra` + /// auth parameter when downloading from permissioned Bitables. + let recordId: String + + var isImage: Bool { + if let mimeType, mimeType.hasPrefix("image/") { return true } + let ext = (name as NSString).pathExtension.lowercased() + return ["png", "jpg", "jpeg", "gif", "webp", "bmp", "heic"].contains(ext) + } +} diff --git a/Sources/Services/AttachmentLoader.swift b/Sources/Services/AttachmentLoader.swift new file mode 100644 index 0000000..4367815 --- /dev/null +++ b/Sources/Services/AttachmentLoader.swift @@ -0,0 +1,141 @@ +import AppKit +import Foundation + +/// Downloads and caches Bitable attachments (screenshots) for the detail window. +@MainActor +@Observable +final class AttachmentLoader { + static let shared = AttachmentLoader() + + enum State: Equatable { + case loading + case loaded + case failed + } + + private(set) var states: [String: State] = [:] + private var images: [String: NSImage] = [:] + private var fileData: [String: Data] = [:] + + /// Set after the first 400 so later downloads skip the plain attempt. + private var needsExtraParam = false + + /// Field IDs come from BugEditService's shared field-metadata cache. + private func screenshotsFieldID(named name: String) async -> String? { + await BugEditService.shared.ensureFieldsLoaded() + return BugEditService.shared.fieldID(named: name) + } + + private init() {} + + func image(for attachment: BugAttachment) -> NSImage? { + images[attachment.fileToken] + } + + func state(for attachment: BugAttachment) -> State? { + states[attachment.fileToken] + } + + /// Downloads the attachment unless already loaded/loading. Safe to call repeatedly. + func load(_ attachment: BugAttachment) { + guard states[attachment.fileToken] == nil else { return } + performLoad(attachment, openAfter: false) + } + + func retry(_ attachment: BugAttachment) { + states[attachment.fileToken] = nil + performLoad(attachment, openAfter: false) + } + + /// Opens the attachment in the system viewer, downloading first if needed. + func loadAndOpen(_ attachment: BugAttachment) { + if fileData[attachment.fileToken] != nil { + open(attachment) + } else { + performLoad(attachment, openAfter: true) + } + } + + private func performLoad(_ attachment: BugAttachment, openAfter: Bool) { + let token = attachment.fileToken + states[token] = .loading + Task { + do { + let accessToken = try await TokenManager.shared.getAccessToken() + let data = try await download(attachment, accessToken: accessToken) + fileData[token] = data + images[token] = NSImage(data: data) + states[token] = .loaded + if openAfter { open(attachment) } + } catch { + BuggerLog.error("AttachmentLoader: \(attachment.name) failed: \(error.localizedDescription)") + states[token] = .failed + } + } + } + + /// Plain download first; Bitables with advanced permissions answer 400 and + /// need an `extra` query param carrying table ID, field ID, record ID. + private func download(_ attachment: BugAttachment, accessToken: String) async throws -> Data { + let service = FeishuService() + if needsExtraParam { + return try await service.downloadAttachment( + fileToken: attachment.fileToken, + accessToken: accessToken, + extra: try buildExtra(for: attachment) + ) + } + do { + return try await service.downloadAttachment( + fileToken: attachment.fileToken, + accessToken: accessToken + ) + } catch FeishuError.apiError(let code, _) where code == 400 { + needsExtraParam = true + return try await service.downloadAttachment( + fileToken: attachment.fileToken, + accessToken: accessToken, + extra: try buildExtra(for: attachment) + ) + } + } + + private func buildExtra(for attachment: BugAttachment) async throws -> String { + guard let config = AppStateService.shared.config else { + throw FeishuError.notConfigured + } + let fieldName = config.fieldMappings.screenshotsField + guard let fieldId = await screenshotsFieldID(named: fieldName) else { + throw FeishuError.invalidConfiguration( + "Field \"\(fieldName)\" not found in table; check the Screenshots field mapping." + ) + } + let extra: [String: Any] = [ + "bitablePerm": [ + "tableId": config.tableId, + "attachments": [fieldId: [attachment.recordId: [attachment.fileToken]]] + ] + ] + guard let data = try? JSONSerialization.data(withJSONObject: extra), + let string = String(data: data, encoding: .utf8) else { + throw FeishuError.invalidConfiguration("Could not build attachment extra parameter.") + } + return string + } + + private func open(_ attachment: BugAttachment) { + guard let data = fileData[attachment.fileToken] else { return } + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("bugger-attachments", isDirectory: true) + // Prefix with the token so same-named attachments don't overwrite each other. + let safeName = attachment.name.replacingOccurrences(of: "/", with: "_") + let url = directory.appendingPathComponent("\(attachment.fileToken)-\(safeName)") + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try data.write(to: url) + NSWorkspace.shared.open(url) + } catch { + BuggerLog.error("AttachmentLoader: could not write \(url.path): \(error.localizedDescription)") + } + } +} diff --git a/Sources/Services/BugEditService.swift b/Sources/Services/BugEditService.swift new file mode 100644 index 0000000..3bbe7ef --- /dev/null +++ b/Sources/Services/BugEditService.swift @@ -0,0 +1,147 @@ +import Foundation + +enum EditError: Error, LocalizedError { + case needsReauth + case notConfigured + + var errorDescription: String? { + switch self { + case .needsReauth: + return "Missing write permission. Re-authorize Feishu to enable editing (and make sure you have edit rights on the Bitable)." + case .notConfigured: + return "Feishu table not configured. Open Settings." + } + } +} + +/// Writes bug fields back to the Bitable. No optimistic local mutation — after a +/// successful write it triggers the normal fetch pipeline, which is the source +/// of truth (and drives change notifications). +@MainActor +@Observable +final class BugEditService { + static let shared = BugEditService() + + private(set) var fields: [FieldItem] = [] + private var fieldsLoadedFor: String? + + private init() {} + + // MARK: - Field metadata + + /// Loads table field metadata once per appToken/tableId (status options, + /// field IDs for attachment downloads). + func ensureFieldsLoaded() async { + guard let config = AppStateService.shared.config, config.isConfigured else { return } + let key = "\(config.appToken)/\(config.tableId)" + guard fieldsLoadedFor != key else { return } + do { + let token = try await TokenManager.shared.getAccessToken() + fields = try await FeishuService().fetchFields( + appToken: config.appToken, + tableId: config.tableId, + accessToken: token + ) + fieldsLoadedFor = key + } catch { + BuggerLog.error("BugEditService: fetchFields failed: \(error.localizedDescription)") + } + } + + func fieldID(named name: String) -> String? { + fields.first { $0.fieldName == name }?.fieldId + } + + var statusOptions: [SelectOption] { + guard let config = AppStateService.shared.config else { return [] } + return fields + .first { $0.fieldName == config.fieldMappings.statusField }? + .property?.options ?? [] + } + + // MARK: - Edits + + func updateStatus(_ bug: Bug, to optionName: String) async throws { + guard let field = AppStateService.shared.config?.fieldMappings.statusField else { + throw EditError.notConfigured + } + try await update(bug, fields: [field: optionName]) + } + + func updateFixNotes(_ bug: Bug, text: String) async throws { + guard let field = AppStateService.shared.config?.fieldMappings.fixNotesField else { + throw EditError.notConfigured + } + try await update(bug, fields: [field: text]) + } + + /// Uploads each image (sequentially — the API rejects concurrent uploads) + /// and appends all new file tokens to the attachment field in one update. + func addScreenshots(_ bug: Bug, images: [(name: String, data: Data, mimeType: String)]) async throws { + guard let config = AppStateService.shared.config, config.isConfigured else { + throw EditError.notConfigured + } + let maxBytes = 20 * 1024 * 1024 + if let oversized = images.first(where: { $0.data.count > maxBytes }) { + throw FeishuError.invalidConfiguration( + "\"\(oversized.name)\" exceeds the 20 MB upload limit." + ) + } + + let service = FeishuService() + let accessToken = try await TokenManager.shared.getAccessToken() + + var newTokens: [String] = [] + for image in images { + do { + let token = try await service.uploadMedia( + appToken: config.appToken, + fileName: image.name, + data: image.data, + mimeType: image.mimeType, + accessToken: accessToken + ) + newTokens.append(token) + } catch { + throw Self.mapError(error) + } + } + + // Attachment fields are replaced wholesale, so resend existing tokens. + let allTokens = (bug.screenshots.map(\.fileToken) + newTokens).map { + ["file_token": $0] + } + try await update(bug, fields: [config.fieldMappings.screenshotsField: allTokens]) + } + + // MARK: - Helpers + + private func update(_ bug: Bug, fields: [String: Any]) async throws { + guard let config = AppStateService.shared.config, config.isConfigured else { + throw EditError.notConfigured + } + let accessToken = try await TokenManager.shared.getAccessToken() + do { + try await FeishuService().updateRecord( + appToken: config.appToken, + tableId: config.tableId, + recordId: bug.id, + fields: fields, + accessToken: accessToken + ) + } catch { + throw Self.mapError(error) + } + await PollerService.shared.fetchNow() + } + + /// Permission failures mean either the token still has the old readonly + /// scope (re-auth needed) or the user lacks edit rights on the Bitable. + static func mapError(_ error: Error) -> Error { + if case let FeishuError.apiError(code, _) = error, + [1254302, 1254304, 1061004, 99991672, 99991679].contains(code) { + return EditError.needsReauth + } + return error + } +} diff --git a/Sources/Services/Feishu/FeishuAuthService.swift b/Sources/Services/Feishu/FeishuAuthService.swift index 734ebf0..cee0af3 100644 --- a/Sources/Services/Feishu/FeishuAuthService.swift +++ b/Sources/Services/Feishu/FeishuAuthService.swift @@ -36,7 +36,7 @@ final class FeishuAuthService { components.queryItems = [ URLQueryItem(name: "app_id", value: appId), URLQueryItem(name: "redirect_uri", value: redirectURI), - URLQueryItem(name: "scope", value: "bitable:app:readonly offline_access") + URLQueryItem(name: "scope", value: "bitable:app offline_access") ] return components.url! } diff --git a/Sources/Services/Feishu/FeishuModels.swift b/Sources/Services/Feishu/FeishuModels.swift index 9c140a4..e2cb881 100644 --- a/Sources/Services/Feishu/FeishuModels.swift +++ b/Sources/Services/Feishu/FeishuModels.swift @@ -29,6 +29,35 @@ struct RecordListData: Decodable { let total: Int? } +struct FieldListData: Decodable { + let items: [FieldItem] + let hasMore: Bool + let pageToken: String? +} + +struct FieldItem: Decodable { + let fieldId: String + let fieldName: String + let type: Int? + let uiType: String? + let property: FieldProperty? +} + +struct FieldProperty: Decodable { + let options: [SelectOption]? +} + +struct SelectOption: Decodable, Equatable, Hashable { + let id: String + let name: String + let color: Int? +} + +struct UploadMediaData: Decodable { + // file_token → fileToken via .convertFromSnakeCase + let fileToken: String +} + struct RecordItem: Decodable { let recordId: String let fields: [String: JSONValue] @@ -126,6 +155,30 @@ enum JSONValue: Decodable { } return nil } + + /// Attachment field values are arrays of objects like + /// `[{"file_token": "...", "name": "x.png", "size": 123, "type": "image/png"}]`. + func attachmentsValue(recordId: String) -> [BugAttachment] { + guard case let .array(items) = self else { return [] } + return items.compactMap { item in + guard case let .object(fields) = item, + let token = fields["file_token"]?.stringValue, + let name = fields["name"]?.stringValue else { return nil } + var size = 0 + if case let .number(number)? = fields["size"] { + size = Int(number) + } else if let string = fields["size"]?.stringValue { + size = Int(string) ?? 0 + } + return BugAttachment( + fileToken: token, + name: name, + size: size, + mimeType: fields["type"]?.stringValue, + recordId: recordId + ) + } + } } enum BugMapper { @@ -139,11 +192,18 @@ enum BugMapper { return Bug( id: record.recordId, title: stringValue(in: fields, key: mappings.titleField) ?? "Untitled", + description: description(in: fields, mappings: mappings), priority: BugPriority(rawValue: stringValue(in: fields, key: mappings.priorityField) ?? "") ?? .unknown, status: mapStatus(stringValue(in: fields, key: mappings.statusField), mappings: mappings), + statusRaw: stringValue(in: fields, key: mappings.statusField), assignee: userName(in: fields, key: mappings.assigneeField) ?? "Unknown", reporter: userName(in: fields, key: mappings.reporterField), customer: rawValue(in: fields, key: mappings.customerField).map { "\($0)" }, + module: joinedValue(in: fields, key: mappings.moduleField), + source: optionalStringValue(in: fields, key: mappings.sourceField), + flowStatus: optionalStringValue(in: fields, key: mappings.flowStatusField), + fixNotes: optionalStringValue(in: fields, key: mappings.fixNotesField), + screenshots: optionalAttachments(in: fields, key: mappings.screenshotsField, recordId: record.recordId), createdAt: FeishuDateParser.parse(rawValue(in: fields, key: mappings.createdAtField)) ?? .distantPast, updatedAt: FeishuDateParser.parse(rawValue(in: fields, key: mappings.updatedAtField)) ?? .distantPast, feishuURL: FeishuURLBuilder.recordURL( @@ -174,6 +234,36 @@ enum BugMapper { fields[key]?.textValue } + private static func description(in fields: [String: JSONValue], mappings: AppConfig.FieldMappings) -> String? { + guard !mappings.descriptionField.isEmpty else { return nil } + let text = stringValue(in: fields, key: mappings.descriptionField)? + .trimmingCharacters(in: .whitespacesAndNewlines) + return text?.isEmpty == false ? text : nil + } + + /// Like stringValue, but nil when the mapping is empty or the value is blank. + private static func optionalStringValue(in fields: [String: JSONValue], key: String) -> String? { + guard !key.isEmpty else { return nil } + let text = stringValue(in: fields, key: key)?.trimmingCharacters(in: .whitespacesAndNewlines) + return text?.isEmpty == false ? text : nil + } + + /// Multi-select fields decode as arrays of option strings; join them for display. + private static func joinedValue(in fields: [String: JSONValue], key: String) -> String? { + guard !key.isEmpty, let value = fields[key] else { return nil } + if case let .array(items) = value { + let parts = items.compactMap(\.textValue) + return parts.isEmpty ? nil : parts.joined(separator: ", ") + } + let text = value.textValue?.trimmingCharacters(in: .whitespacesAndNewlines) + return text?.isEmpty == false ? text : nil + } + + private static func optionalAttachments(in fields: [String: JSONValue], key: String, recordId: String) -> [BugAttachment] { + guard !key.isEmpty else { return [] } + return fields[key]?.attachmentsValue(recordId: recordId) ?? [] + } + private static func userName(in fields: [String: JSONValue], key: String) -> String? { fields[key]?.firstUserName ?? fields[key]?.stringValue } diff --git a/Sources/Services/Feishu/FeishuService.swift b/Sources/Services/Feishu/FeishuService.swift index b9ead7b..c461df5 100644 --- a/Sources/Services/Feishu/FeishuService.swift +++ b/Sources/Services/Feishu/FeishuService.swift @@ -195,6 +195,213 @@ final class FeishuService { return pageData } + // MARK: - Attachment download (GET /drive/v1/medias/{file_token}/download) + + /// Downloads a Bitable attachment by its file_token. Bitables with advanced + /// permissions require an `extra` query param for auth, e.g. + /// `{"bitablePerm":{"tableId":"...","attachments":{"":{"":[""]}}}}`. + func downloadAttachment(fileToken: String, accessToken: String, extra: String? = nil) async throws -> Data { + guard var components = URLComponents( + string: "\(baseURL)/drive/v1/medias/\(fileToken)/download" + ) else { + throw FeishuError.invalidConfiguration("Invalid attachment file token.") + } + if let extra { + components.queryItems = [URLQueryItem(name: "extra", value: extra)] + } + guard let url = components.url else { + throw FeishuError.invalidConfiguration("Could not build attachment download URL.") + } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + request.timeoutInterval = 60 + + let (data, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw FeishuError.networkError(nil) + } + if httpResponse.statusCode == 401 { + throw FeishuError.unauthorized + } + guard (200..<300).contains(httpResponse.statusCode) else { + let body = String(data: data, encoding: .utf8) ?? "" + BuggerLog.error("downloadAttachment: HTTP \(httpResponse.statusCode) body=\(String(body.prefix(300)))") + throw FeishuError.apiError(code: httpResponse.statusCode, message: String(body.prefix(200))) + } + return data + } + + // MARK: - Field metadata (GET /fields) + + /// Returns the table's field metadata. Select fields carry their options + /// (name/id/color); field IDs (fldXXX) are needed to build the `extra` + /// auth parameter for attachment downloads. + func fetchFields( + appToken: String, + tableId: String, + accessToken: String + ) async throws -> [FieldItem] { + var result: [FieldItem] = [] + var pageToken: String? + + repeat { + guard var components = URLComponents( + string: "\(baseURL)/bitable/v1/apps/\(appToken)/tables/\(tableId)/fields" + ) else { + throw FeishuError.invalidConfiguration("App token or table ID contains invalid characters.") + } + var queryItems = [URLQueryItem(name: "page_size", value: "100")] + if let pageToken { + queryItems.append(URLQueryItem(name: "page_token", value: pageToken)) + } + components.queryItems = queryItems + + guard let url = components.url else { + throw FeishuError.invalidConfiguration("Could not build fields request URL.") + } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + request.timeoutInterval = 30 + + let (data, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw FeishuError.networkError(nil) + } + if httpResponse.statusCode == 401 { + throw FeishuError.unauthorized + } + + let apiResponse = try decoder.decode(FeishuAPIResponse.self, from: data) + guard apiResponse.code == 0, let pageData = apiResponse.data else { + throw FeishuError.apiError(code: apiResponse.code, message: apiResponse.msg) + } + result.append(contentsOf: pageData.items) + pageToken = pageData.hasMore ? pageData.pageToken : nil + } while pageToken != nil + + return result + } + + // MARK: - Update record (PUT /records/{record_id}) + + /// Updates fields of a single record. Keys are field names. Included fields + /// are fully replaced; omitted fields are untouched. Select fields take the + /// option name; text fields take a plain string; attachment fields take a + /// full array of ["file_token": ...] entries. + func updateRecord( + appToken: String, + tableId: String, + recordId: String, + fields: [String: Any], + accessToken: String + ) async throws { + guard let url = URL( + string: "\(baseURL)/bitable/v1/apps/\(appToken)/tables/\(tableId)/records/\(recordId)" + ) else { + throw FeishuError.invalidConfiguration("Could not build record update URL.") + } + + var request = URLRequest(url: url) + request.httpMethod = "PUT" + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") + request.timeoutInterval = 30 + request.httpBody = try JSONSerialization.data(withJSONObject: ["fields": fields]) + + let (data, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw FeishuError.networkError(nil) + } + + let rawBody = String(data: data, encoding: .utf8) ?? "" + BuggerLog.debug("updateRecord: HTTP \(httpResponse.statusCode) body=\(String(rawBody.prefix(500)))") + + if httpResponse.statusCode == 401 { + throw FeishuError.unauthorized + } + + let apiResponse = try decoder.decode(FeishuAPIResponse.self, from: data) + guard apiResponse.code == 0 else { + throw FeishuError.apiError(code: apiResponse.code, message: apiResponse.msg) + } + } + + // MARK: - Media upload (POST /drive/v1/medias/upload_all) + + /// Uploads an image into the Bitable's media store and returns its + /// file_token, which can then be added to an attachment field via + /// updateRecord. Max 20 MB; uploads must be sequential. + func uploadMedia( + appToken: String, + fileName: String, + data: Data, + mimeType: String, + accessToken: String + ) async throws -> String { + guard let url = URL(string: "\(baseURL)/drive/v1/medias/upload_all") else { + throw FeishuError.invalidConfiguration("Could not build media upload URL.") + } + + let boundary = "BuggerBoundary-\(UUID().uuidString)" + var body = Data() + + func appendField(_ name: String, _ value: String) { + body.append("--\(boundary)\r\n".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".data(using: .utf8)!) + body.append("\(value)\r\n".data(using: .utf8)!) + } + + appendField("file_name", fileName) + appendField("parent_type", "bitable_image") + appendField("parent_node", appToken) + appendField("size", String(data.count)) + appendField("checksum", String(adler32(data))) + appendField("extra", "{\"drive_route_token\":\"\(appToken)\"}") + + body.append("--\(boundary)\r\n".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!) + body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!) + body.append(data) + body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + request.timeoutInterval = 120 + request.httpBody = body + + let (responseData, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw FeishuError.networkError(nil) + } + if httpResponse.statusCode == 401 { + throw FeishuError.unauthorized + } + + let rawBody = String(data: responseData, encoding: .utf8) ?? "" + BuggerLog.debug("uploadMedia: HTTP \(httpResponse.statusCode) body=\(String(rawBody.prefix(500)))") + + let apiResponse = try decoder.decode(FeishuAPIResponse.self, from: responseData) + guard apiResponse.code == 0, let token = apiResponse.data?.fileToken else { + throw FeishuError.apiError(code: apiResponse.code, message: apiResponse.msg) + } + return token + } + + /// Adler-32 checksum as required by the media upload API. + private func adler32(_ data: Data) -> UInt32 { + var a: UInt32 = 1 + var b: UInt32 = 0 + for byte in data { + a = (a + UInt32(byte)) % 65521 + b = (b + a) % 65521 + } + return (b << 16) | a + } + // MARK: - Search endpoint (POST /records/search) private func searchRecords( diff --git a/Sources/ViewModels/MainWindowState.swift b/Sources/ViewModels/MainWindowState.swift new file mode 100644 index 0000000..225a937 --- /dev/null +++ b/Sources/ViewModels/MainWindowState.swift @@ -0,0 +1,15 @@ +import Foundation + +@Observable +final class MainWindowState { + static let shared = MainWindowState() + + var selectedBugID: String? + + var selectedBug: Bug? { + guard let selectedBugID else { return nil } + return BugStore.shared.bugs.first { $0.id == selectedBugID } + } + + private init() {} +} diff --git a/Sources/Views/MainWindow/BugDetailView.swift b/Sources/Views/MainWindow/BugDetailView.swift new file mode 100644 index 0000000..f54707e --- /dev/null +++ b/Sources/Views/MainWindow/BugDetailView.swift @@ -0,0 +1,479 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers + +struct BugDetailView: View { + let bug: Bug + let isUnseen: Bool + + @State private var editService = BugEditService.shared + @State private var fixNotesDraft: String = "" + @State private var isSavingStatus = false + @State private var isSavingFixNotes = false + @State private var isUploading = false + @State private var editError: Error? + @State private var showNeedsReauth = false + + private var fixNotesDirty: Bool { + fixNotesDraft != (bug.fixNotes ?? "") + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + header + Divider() + metadata + if let description = bug.description { + Divider() + descriptionSection(description) + } + Divider() + fixNotesSection + Divider() + screenshotsSection + } + .padding(24) + .frame(maxWidth: .infinity, alignment: .leading) + } + .task { + await editService.ensureFieldsLoaded() + } + .onAppear { + fixNotesDraft = bug.fixNotes ?? "" + } + .onChange(of: bug.id) { _, _ in + fixNotesDraft = bug.fixNotes ?? "" + } + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { + Button { + copyLink() + } label: { + Label("Copy Link", systemImage: "link") + } + .disabled(bug.feishuURL == nil) + + Button { + openInFeishu() + } label: { + Label("Open in Feishu", systemImage: "arrow.up.forward.app") + } + .disabled(bug.feishuURL == nil) + } + } + .alert( + "Failed to Save", + isPresented: Binding( + get: { editError != nil }, + set: { if !$0 { editError = nil } } + ), + presenting: editError + ) { _ in + Button("OK") { editError = nil } + } message: { error in + Text(error.localizedDescription) + } + .alert("需要重新授权", isPresented: $showNeedsReauth) { + Button("重新连接 Feishu") { + TokenManager.shared.clearTokens() + AppDelegate.shared?.openPopover() + } + Button("取消", role: .cancel) {} + } message: { + Text("保存需要写权限。请重新授权飞书(已升级为可编辑权限范围);若重新授权后仍失败,请确认你在该多维表格中有编辑权限。") + } + } + + // MARK: - Sections + + private var header: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + PriorityBadge(priority: bug.priority) + StatusPill(status: bug.status) + if isUnseen { + Circle() + .fill(.blue) + .frame(width: 8, height: 8) + } + Spacer() + } + Text(bug.title) + .font(.title2) + .fontWeight(.semibold) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + } + } + + private var metadata: some View { + Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) { + GridRow { + metadataLabel("Status") + statusPicker + } + GridRow { + metadataLabel("Assignee") + Text(bug.assignee).textSelection(.enabled) + } + if let reporter = bug.reporter { + GridRow { + metadataLabel("Reporter") + Text(reporter).textSelection(.enabled) + } + } + if let customer = bug.customer { + GridRow { + metadataLabel("Customer") + Text(customer).textSelection(.enabled) + } + } + if let module = bug.module { + GridRow { + metadataLabel("Module") + Text(module).textSelection(.enabled) + } + } + if let source = bug.source { + GridRow { + metadataLabel("Source") + Text(source).textSelection(.enabled) + } + } + if let flowStatus = bug.flowStatus { + GridRow { + metadataLabel("Flow Status") + // Workflow (流程) fields can't be written via the API — + // advance the flow in Feishu ("Open in Feishu" above). + Text(flowStatus).textSelection(.enabled) + } + } + GridRow { + metadataLabel("Created") + Text(bug.createdAt, style: .date) + + Text(" ") + Text(bug.createdAt, style: .time) + } + GridRow { + metadataLabel("Updated") + Text(bug.updatedAt, style: .date) + + Text(" ") + Text(bug.updatedAt, style: .time) + } + GridRow { + metadataLabel("Age") + Text(ageString(bug.age)) + } + } + } + + private func metadataLabel(_ title: String) -> some View { + Text(title) + .foregroundStyle(.secondary) + .frame(width: 90, alignment: .trailing) + } + + private func descriptionSection(_ description: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Description") + .font(.headline) + Text(description) + .font(.body) + .foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + } + } + + // MARK: - Status editing + + private var statusPicker: some View { + HStack(spacing: 6) { + Menu { + ForEach(editService.statusOptions, id: \.id) { option in + Button { + changeStatus(to: option.name) + } label: { + HStack { + Circle() + .fill(feishuOptionColor(option.color)) + .frame(width: 8, height: 8) + Text(option.name) + if option.name == bug.statusRaw { + Image(systemName: "checkmark") + } + } + } + } + } label: { + HStack(spacing: 4) { + Text(bug.statusRaw ?? "Set status") + .foregroundStyle(bug.statusRaw == nil ? .secondary : .primary) + Image(systemName: "chevron.up.chevron.down") + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Color.secondary.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 5)) + } + .menuStyle(.borderlessButton) + .fixedSize() + .disabled(isSavingStatus || editService.statusOptions.isEmpty) + + if isSavingStatus { + ProgressView() + .scaleEffect(0.6) + .frame(width: 14, height: 14) + } + } + } + + private func changeStatus(to optionName: String) { + guard optionName != bug.statusRaw else { return } + isSavingStatus = true + Task { + do { + try await editService.updateStatus(bug, to: optionName) + } catch { + handleEditError(error) + } + isSavingStatus = false + } + } + + // MARK: - Fix notes editing + + private var fixNotesSection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Fix Notes") + .font(.headline) + if isSavingFixNotes { + ProgressView() + .scaleEffect(0.6) + .frame(width: 14, height: 14) + } + } + TextEditor(text: $fixNotesDraft) + .font(.body) + .frame(minHeight: 60, maxHeight: 200) + .scrollContentBackground(.hidden) + .padding(6) + .background(Color.secondary.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(Color.secondary.opacity(0.2)) + ) + if fixNotesDirty && !isSavingFixNotes { + HStack(spacing: 8) { + Button("Save") { saveFixNotes() } + .keyboardShortcut(.return, modifiers: .command) + Button("Cancel") { fixNotesDraft = bug.fixNotes ?? "" } + } + .controlSize(.small) + } + } + } + + private func saveFixNotes() { + isSavingFixNotes = true + Task { + do { + try await editService.updateFixNotes(bug, text: fixNotesDraft) + } catch { + handleEditError(error) + } + isSavingFixNotes = false + } + } + + // MARK: - Screenshots + + private var screenshotsSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Screenshots") + .font(.headline) + ScrollView(.horizontal) { + HStack(spacing: 12) { + ForEach(bug.screenshots, id: \.fileToken) { attachment in + ScreenshotThumbnail(attachment: attachment) + } + addScreenshotTile + } + .padding(.vertical, 2) + } + } + } + + private var addScreenshotTile: some View { + Button(action: pickScreenshots) { + VStack(spacing: 4) { + if isUploading { + ProgressView() + } else { + Image(systemName: "plus") + .font(.title2) + .foregroundStyle(.secondary) + Text("Add") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .frame(width: 60, height: 120) + .background(Color.secondary.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(Color.secondary.opacity(0.3), style: StrokeStyle(dash: [4])) + ) + } + .buttonStyle(.plain) + .disabled(isUploading) + .help("Add screenshot") + } + + private func pickScreenshots() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.image] + panel.allowsMultipleSelection = true + panel.canChooseDirectories = false + panel.begin { response in + guard response == .OK else { return } + let images: [(name: String, data: Data, mimeType: String)] = panel.urls.compactMap { url in + guard let data = try? Data(contentsOf: url) else { return nil } + let ext = url.pathExtension.lowercased() + let mime = UTType(filenameExtension: ext)?.preferredMIMEType ?? "image/png" + return (url.lastPathComponent, data, mime) + } + guard !images.isEmpty else { return } + isUploading = true + Task { + do { + try await editService.addScreenshots(bug, images: images) + } catch { + handleEditError(error) + } + isUploading = false + } + } + } + + // MARK: - Helpers + + private func handleEditError(_ error: Error) { + if let editError = error as? EditError, editError == .needsReauth { + showNeedsReauth = true + } else { + self.editError = error + } + } + + /// Approximate palette for Feishu select-option color indices. + private func feishuOptionColor(_ index: Int?) -> Color { + let palette: [Color] = [ + .gray, .red, .orange, .yellow, .green, .teal, .cyan, + .blue, .indigo, .purple, .pink, .brown, .mint + ] + guard let index else { return .gray } + return palette[abs(index) % palette.count] + } + + private func openInFeishu() { + guard let url = bug.feishuURL else { return } + NSWorkspace.shared.open(url) + } + + private func copyLink() { + guard let url = bug.feishuURL else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(url.absoluteString, forType: .string) + } + + private func ageString(_ age: TimeInterval) -> String { + let days = Int(age / 86_400) + let hours = Int(age / 3_600) + if days > 0 { return days == 1 ? "1 day" : "\(days) days" } + if hours > 0 { return hours == 1 ? "1 hour" : "\(hours) hours" } + return "less than an hour" + } +} + +/// Thumbnail for a single attachment. Images are downloaded and shown inline; +/// tapping opens the file in the system viewer. Failed downloads retry on tap. +private struct ScreenshotThumbnail: View { + let attachment: BugAttachment + @State private var loader = AttachmentLoader.shared + + var body: some View { + VStack(spacing: 4) { + ZStack { + if let image = loader.image(for: attachment) { + Image(nsImage: image) + .resizable() + .scaledToFit() + } else { + placeholder + } + } + .frame(width: 180, height: 120) + .background(Color.secondary.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(Color.secondary.opacity(0.2))) + + Text(attachment.name) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .frame(width: 180) + } + .onAppear { + if attachment.isImage { + loader.load(attachment) + } + } + .onTapGesture { + switch loader.state(for: attachment) { + case .loaded: + loader.loadAndOpen(attachment) + case .failed: + loader.retry(attachment) + case .loading: + break + case nil: + loader.loadAndOpen(attachment) + } + } + .help(attachment.name) + } + + @ViewBuilder + private var placeholder: some View { + switch loader.state(for: attachment) { + case .loading: + ProgressView() + case .failed: + VStack(spacing: 4) { + Image(systemName: "exclamationmark.arrow.triangle.2.circlepath") + .font(.title3) + .foregroundStyle(.orange) + Text("Tap to retry") + .font(.caption2) + .foregroundStyle(.secondary) + } + default: + VStack(spacing: 4) { + Image(systemName: attachment.isImage ? "photo" : "doc") + .font(.title2) + .foregroundStyle(.secondary) + if !attachment.isImage { + Text("Tap to open") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + } +} diff --git a/Sources/Views/MainWindow/MainWindowView.swift b/Sources/Views/MainWindow/MainWindowView.swift new file mode 100644 index 0000000..4d422e7 --- /dev/null +++ b/Sources/Views/MainWindow/MainWindowView.swift @@ -0,0 +1,116 @@ +import SwiftUI + +struct MainWindowView: View { + @State private var bugStore = BugStore.shared + @State private var windowState = MainWindowState.shared + @State private var showResolved = false + @State private var searchText = "" + + private var filteredBugs: [Bug] { + let base = showResolved + ? bugStore.bugs.sorted { lhs, rhs in + if lhs.priority != rhs.priority { return lhs.priority < rhs.priority } + return lhs.createdAt < rhs.createdAt + } + : bugStore.bugsSortedByPriority + guard !searchText.isEmpty else { return base } + return base.filter { bug in + bug.title.localizedCaseInsensitiveContains(searchText) + || bug.customer?.localizedCaseInsensitiveContains(searchText) == true + || bug.reporter?.localizedCaseInsensitiveContains(searchText) == true + } + } + + var body: some View { + NavigationSplitView { + VStack(spacing: 0) { + Picker("", selection: $showResolved) { + Text("Active").tag(false) + Text("All").tag(true) + } + .pickerStyle(.segmented) + .labelsHidden() + .padding(.horizontal, 12) + .padding(.vertical, 8) + + List(selection: $windowState.selectedBugID) { + ForEach(filteredBugs) { bug in + SidebarBugRow( + bug: bug, + isUnseen: bugStore.unseenBugs.contains(bug.id) + ) + .tag(bug.id) + } + } + } + .navigationSplitViewColumnWidth(min: 240, ideal: 280, max: 360) + .searchable(text: $searchText, placement: .sidebar, prompt: "Search bugs") + } detail: { + if let selectedID = windowState.selectedBugID { + if let bug = bugStore.bugs.first(where: { $0.id == selectedID }) { + BugDetailView(bug: bug, isUnseen: bugStore.unseenBugs.contains(bug.id)) + } else { + ContentUnavailableView( + "Bug Not Found", + systemImage: "questionmark.circle", + description: Text("This bug is no longer in the list.") + ) + } + } else { + ContentUnavailableView( + "Select a Bug", + systemImage: "ant", + description: Text("Choose a bug from the list to see its details.") + ) + } + } + .onChange(of: windowState.selectedBugID) { _, newID in + guard let newID else { return } + bugStore.markSeen(newID) + AppDelegate.shared?.updateBadge(count: bugStore.activeBugs.count) + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + Task { await PollerService.shared.fetchNow() } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .disabled(bugStore.isLoading) + } + } + } +} + +private struct SidebarBugRow: View { + let bug: Bug + let isUnseen: Bool + + var body: some View { + HStack(spacing: 8) { + PriorityBadge(priority: bug.priority) + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(bug.title) + .font(.system(size: 13)) + .lineLimit(1) + if isUnseen { + Circle() + .fill(.blue) + .frame(width: 6, height: 6) + } + } + HStack(spacing: 6) { + StatusPill(status: bug.status) + if let customer = bug.customer { + Text(customer) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + } + .padding(.vertical, 2) + } +} diff --git a/Sources/Views/MenuBar/BugListPopover.swift b/Sources/Views/MenuBar/BugListPopover.swift index 00006cb..88d5d27 100644 --- a/Sources/Views/MenuBar/BugListPopover.swift +++ b/Sources/Views/MenuBar/BugListPopover.swift @@ -5,6 +5,7 @@ struct BugListPopover: View { @State private var bugStore = BugStore.shared @State private var tokenManager = TokenManager.shared @Environment(\.openSettings) private var openSettings + @Environment(\.openWindow) private var openWindow var body: some View { VStack(spacing: 0) { @@ -49,6 +50,19 @@ struct BugListPopover: View { AppDelegate.shared?.updateBadge(count: bugStore.activeBugs.count) } .font(.caption) + Button { + openWindow(id: "main") + DispatchQueue.main.async { + NSRunningApplication.current.activate(options: [.activateAllWindows]) + } + AppDelegate.shared?.closePopover() + } label: { + Image(systemName: "macwindow") + .font(.caption) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .help("Open main window") } .padding(.horizontal) .padding(.vertical, 8) @@ -71,9 +85,11 @@ struct BugListPopover: View { isUnseen: bugStore.unseenBugs.contains(bug.id) ) .onTapGesture { - openInFeishu(bug) - bugStore.markSeen(bug.id) - AppDelegate.shared?.updateBadge(count: bugStore.activeBugs.count) + openDetails(bug) + } + .contextMenu { + Button("Open Details") { openDetails(bug) } + Button("Open in Feishu") { openInFeishu(bug) } } Divider().padding(.leading, 44) } @@ -113,9 +129,24 @@ struct BugListPopover: View { .font(.caption) } + private func openDetails(_ bug: Bug) { + MainWindowState.shared.selectedBugID = bug.id + openWindow(id: "main") + // Accessory app: activate on the next runloop pass so the new + // window comes to the front instead of opening behind other apps. + DispatchQueue.main.async { + NSRunningApplication.current.activate(options: [.activateAllWindows]) + } + bugStore.markSeen(bug.id) + AppDelegate.shared?.updateBadge(count: bugStore.activeBugs.count) + AppDelegate.shared?.closePopover() + } + private func openInFeishu(_ bug: Bug) { guard let url = bug.feishuURL else { return } NSWorkspace.shared.open(url) + bugStore.markSeen(bug.id) + AppDelegate.shared?.updateBadge(count: bugStore.activeBugs.count) AppDelegate.shared?.closePopover() } diff --git a/Sources/Views/Settings/SettingsView.swift b/Sources/Views/Settings/SettingsView.swift index 316d1d7..edc8963 100644 --- a/Sources/Views/Settings/SettingsView.swift +++ b/Sources/Views/Settings/SettingsView.swift @@ -236,6 +236,34 @@ struct SettingsView: View { TextField("", text: $config.fieldMappings.updatedAtField) .textFieldStyle(.roundedBorder) } + labeledRow("Description field") { + TextField("optional", text: $config.fieldMappings.descriptionField) + .textFieldStyle(.roundedBorder) + } + Text("Shown in the bug detail window. Leave empty to hide the description.") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.leading, kLabelWidth + 8) + labeledRow("Module field") { + TextField("对应模块", text: $config.fieldMappings.moduleField) + .textFieldStyle(.roundedBorder) + } + labeledRow("Source field") { + TextField("来源", text: $config.fieldMappings.sourceField) + .textFieldStyle(.roundedBorder) + } + labeledRow("Flow Status field") { + TextField("流转状态", text: $config.fieldMappings.flowStatusField) + .textFieldStyle(.roundedBorder) + } + labeledRow("Fix Notes field") { + TextField("修复备注", text: $config.fieldMappings.fixNotesField) + .textFieldStyle(.roundedBorder) + } + labeledRow("Screenshots field") { + TextField("相关截图", text: $config.fieldMappings.screenshotsField) + .textFieldStyle(.roundedBorder) + } } Divider() diff --git a/scripts/generate_xcode_project.py b/scripts/generate_xcode_project.py index eddb104..da3457f 100644 --- a/scripts/generate_xcode_project.py +++ b/scripts/generate_xcode_project.py @@ -340,7 +340,7 @@ def build_pbxproj() -> str: ("CURRENT_PROJECT_VERSION", "5"), ("FEISHU_APP_ID", '"$(FEISHU_APP_ID)"'), ("FEISHU_APP_SECRET", '"$(FEISHU_APP_SECRET)"'), - ("FEISHU_BASE_DOMAIN", '"xorbitlab.feishu.cn"'), + ("FEISHU_BASE_DOMAIN", '"$(FEISHU_BASE_DOMAIN)"'), ("GENERATE_INFOPLIST_FILE", "NO"), ("INFOPLIST_FILE", "Resources/Info.plist"), ("LD_RUNPATH_SEARCH_PATHS", @@ -431,6 +431,13 @@ def build_xcscheme() -> str: + + str: + + + + + + """