Add main window with bug detail view and in-app editing
Phase 1 — read-only detail experience: - New main window (NavigationSplitView): searchable bug list sidebar with Active/All filter, detail pane with full metadata, description, and per-bug Open in Feishu / Copy Link - Popover row click now opens the in-app detail (Open in Feishu kept via context menu and footer) - Display extra table fields: module, source, flow status, fix notes (new field mappings with Chinese defaults), and screenshot attachments with in-app thumbnails (media download with bitablePerm extra auth) - FieldMappings now decodes tolerantly so older persisted configs survive Phase 2 — editing (status, fix notes, screenshots): - OAuth scope upgraded from bitable:app:readonly to bitable:app (requires re-auth; permission errors prompt to reconnect) - Status picker driven by live field metadata (option names + colors) - Fix notes text editor with Save/Cancel - Add screenshots: image picker -> media upload (upload_all) -> append file tokens to the record - AttachmentLoader and BugEditService share one field-metadata cache Note: flow status (流转状态) is a Bitable workflow/Stage field and cannot be written via the OpenAPI; it stays read-only.
This commit is contained in:
parent
44e53e373d
commit
72cff00f72
|
|
@ -7,72 +7,84 @@
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
15550246A9E8DAF25E690478 /* BuggerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDB7AD270E93EA343975AC6 /* BuggerApp.swift */; };
|
F6750BB1FBEE5BC90DD6BDBC /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 207CA4381D0CFEC95263A2DE; };
|
||||||
1E3C1F40E2FF64A041856909 /* BugListPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEABFC668F7B082847F65D38 /* BugListPopover.swift */; };
|
15550246A9E8DAF25E690478 /* BuggerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDB7AD270E93EA343975AC6; };
|
||||||
1FB7E762C0B6568C47B48EF0 /* URL+Feishu.swift in Sources */ = {isa = PBXBuildFile; fileRef = F888AA5F655343B19839B4C2 /* URL+Feishu.swift */; };
|
58F16D20CC26D8248055CC2C /* AppConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBEF4F77A6A2E2D041EF581; };
|
||||||
29AE37B117A1422EA21570CA /* DateFormatter+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87C935E6B4D1813AB01186A0 /* DateFormatter+Extensions.swift */; };
|
4EA9BDBA3F98AF4A41712A18 /* Bug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 846CA001267896C54A78441A; };
|
||||||
29EE2FE56882F97E14AAF58F /* FeishuService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21AC33F380B7DC34C39B61A6 /* FeishuService.swift */; };
|
7AD8DF0FC133A2AE26F3E3C2 /* BugAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08C488C534B87C0560AF960F; };
|
||||||
3467A50268399ABE9804D4D4 /* BugRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1129D6F630904F03D06B5DC4 /* BugRow.swift */; };
|
DDB9EB345FEBB95DD9976364 /* BugChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B24E1E1D32F7B774D12DD85; };
|
||||||
4309E8C70AE99466545A3E37 /* FeishuAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A2C50E47966E8ADB0446A0C /* FeishuAuthService.swift */; };
|
A33561284CA44A0045902BCC /* BugPriority.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5359156BD70B765D2BF4E718; };
|
||||||
47ED652FA0AC818086F85DF3 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ED0A90DFCB8D4968E4E3CA1 /* Logger.swift */; };
|
E4CBAE27491AF1C52944FA91 /* BugStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 646C676356A4125B8E8D5FA5; };
|
||||||
485A69E15EF1D805F50DF364 /* FloatingWidgetWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2FE32B404AB4388A066991 /* FloatingWidgetWindow.swift */; };
|
D2855F82E68FADA8BEA43086 /* AppStateService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1DD178095EFCE7BC121C1C4; };
|
||||||
4EA9BDBA3F98AF4A41712A18 /* Bug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 846CA001267896C54A78441A /* Bug.swift */; };
|
BD123E3404B92204E7CB96DF /* AttachmentLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCF76E668F6EFF64241248E3; };
|
||||||
56D44DCBAF4FE0E1D9E620FA /* PollerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F4628E7B599B69B499E09E9 /* PollerService.swift */; };
|
A31640CA64AEAD8FEC551137 /* BitableEventService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E834D262A6EE1D5406686761; };
|
||||||
58F16D20CC26D8248055CC2C /* AppConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBEF4F77A6A2E2D041EF581 /* AppConfig.swift */; };
|
660734535DE7B5840B0DD24B /* BugEditService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3CA07056AACE5378CF9D421; };
|
||||||
6148104B311B60C79B3B8572 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59CC175DE8E9FE3250757D33 /* NotificationService.swift */; };
|
BCF7698E979D7452AAC4612A /* CalibrationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52C52B8EDA29A06D9DAA6E5D; };
|
||||||
64F1EE978536D1CB02B8F0D3 /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF06E1030AA21DA8190169A8 /* KeychainHelper.swift */; };
|
4309E8C70AE99466545A3E37 /* FeishuAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A2C50E47966E8ADB0446A0C; };
|
||||||
7B0EAA6D83D40A593E36EE31 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3693AA93F823859D15256D /* SettingsView.swift */; };
|
CB529549DB1DBB5F02884300 /* FeishuError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A83E76FBB83849E4B6704D3; };
|
||||||
A33561284CA44A0045902BCC /* BugPriority.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5359156BD70B765D2BF4E718 /* BugPriority.swift */; };
|
CAE624453A3F0375D872170A /* FeishuModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE9FBC39502FA635EF1A6A5C; };
|
||||||
AECED74595B57A400437B9FD /* BugStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 344F52FB34B285D91A3E4670 /* BugStore.swift */; };
|
29EE2FE56882F97E14AAF58F /* FeishuService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21AC33F380B7DC34C39B61A6; };
|
||||||
B066D78DA6F57A882A61C0B2 /* OAuthSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C0A744D4F5DB88B1496176B /* OAuthSetupView.swift */; };
|
6148104B311B60C79B3B8572 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59CC175DE8E9FE3250757D33; };
|
||||||
B882D310EB2B5D032375CDDF /* TokenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E7E3775F77DD36C27160A8C /* TokenManager.swift */; };
|
56D44DCBAF4FE0E1D9E620FA /* PollerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F4628E7B599B69B499E09E9; };
|
||||||
C3F7FEB179B079BDDD2AA212 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 09B9A4D84F3F604C49171A89 /* Assets.xcassets */; };
|
B882D310EB2B5D032375CDDF /* TokenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E7E3775F77DD36C27160A8C; };
|
||||||
C5D9B1FACE813CD43236BB77 /* Color+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F99094B0F609809EBDB5F4A /* Color+Extensions.swift */; };
|
C5D9B1FACE813CD43236BB77 /* Color+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F99094B0F609809EBDB5F4A; };
|
||||||
CAE624453A3F0375D872170A /* FeishuModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE9FBC39502FA635EF1A6A5C /* FeishuModels.swift */; };
|
29AE37B117A1422EA21570CA /* DateFormatter+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87C935E6B4D1813AB01186A0; };
|
||||||
CB529549DB1DBB5F02884300 /* FeishuError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A83E76FBB83849E4B6704D3 /* FeishuError.swift */; };
|
64F1EE978536D1CB02B8F0D3 /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF06E1030AA21DA8190169A8; };
|
||||||
D2855F82E68FADA8BEA43086 /* AppStateService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1DD178095EFCE7BC121C1C4 /* AppStateService.swift */; };
|
EEC5853D00ED565270DC867F /* LocalOAuthServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34D11523ACB81DBB9D412995; };
|
||||||
7E1F2A3B4C5D6E7F8091A2B3 /* BitableEventService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F2E3D4C5B6A7988061A2B3C /* BitableEventService.swift */; };
|
47ED652FA0AC818086F85DF3 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ED0A90DFCB8D4968E4E3CA1; };
|
||||||
A1B2C3D4E5F60718293A4B5C /* CalibrationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F70819293A4B5 /* CalibrationService.swift */; };
|
1FB7E762C0B6568C47B48EF0 /* URL+Feishu.swift in Sources */ = {isa = PBXBuildFile; fileRef = F888AA5F655343B19839B4C2; };
|
||||||
DDB9EB345FEBB95DD9976364 /* BugChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B24E1E1D32F7B774D12DD85 /* BugChange.swift */; };
|
AECED74595B57A400437B9FD /* BugStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 344F52FB34B285D91A3E4670; };
|
||||||
E4CBAE27491AF1C52944FA91 /* BugStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 646C676356A4125B8E8D5FA5 /* BugStatus.swift */; };
|
AD91D07ED264D88DA92E49DA /* MainWindowState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A45A7E013FA764EFAD7C9463; };
|
||||||
EEC5853D00ED565270DC867F /* LocalOAuthServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34D11523ACB81DBB9D412995 /* LocalOAuthServer.swift */; };
|
485A69E15EF1D805F50DF364 /* FloatingWidgetWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2FE32B404AB4388A066991; };
|
||||||
F6750BB1FBEE5BC90DD6BDBC /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 207CA4381D0CFEC95263A2DE /* AppDelegate.swift */; };
|
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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
09B9A4D84F3F604C49171A89 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
|
||||||
0B24E1E1D32F7B774D12DD85 /* BugChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugChange.swift; sourceTree = "<group>"; };
|
|
||||||
0E7E3775F77DD36C27160A8C /* TokenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenManager.swift; sourceTree = "<group>"; };
|
|
||||||
0ED0A90DFCB8D4968E4E3CA1 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
|
|
||||||
0F99094B0F609809EBDB5F4A /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = "<group>"; };
|
|
||||||
1129D6F630904F03D06B5DC4 /* BugRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugRow.swift; sourceTree = "<group>"; };
|
|
||||||
207CA4381D0CFEC95263A2DE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
|
||||||
21AC33F380B7DC34C39B61A6 /* FeishuService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuService.swift; sourceTree = "<group>"; };
|
|
||||||
2A83E76FBB83849E4B6704D3 /* FeishuError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuError.swift; sourceTree = "<group>"; };
|
|
||||||
2D3693AA93F823859D15256D /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
|
||||||
344F52FB34B285D91A3E4670 /* BugStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugStore.swift; sourceTree = "<group>"; };
|
|
||||||
34D11523ACB81DBB9D412995 /* LocalOAuthServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalOAuthServer.swift; sourceTree = "<group>"; };
|
|
||||||
3C0A744D4F5DB88B1496176B /* OAuthSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthSetupView.swift; sourceTree = "<group>"; };
|
|
||||||
4F4628E7B599B69B499E09E9 /* PollerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollerService.swift; sourceTree = "<group>"; };
|
|
||||||
5359156BD70B765D2BF4E718 /* BugPriority.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugPriority.swift; sourceTree = "<group>"; };
|
|
||||||
59CC175DE8E9FE3250757D33 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
|
|
||||||
646C676356A4125B8E8D5FA5 /* BugStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugStatus.swift; sourceTree = "<group>"; };
|
|
||||||
6A2C50E47966E8ADB0446A0C /* FeishuAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuAuthService.swift; sourceTree = "<group>"; };
|
|
||||||
6E2FE32B404AB4388A066991 /* FloatingWidgetWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingWidgetWindow.swift; sourceTree = "<group>"; };
|
|
||||||
73AB234B54673C838E33AC7F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
|
||||||
846CA001267896C54A78441A /* Bug.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bug.swift; sourceTree = "<group>"; };
|
|
||||||
87C935E6B4D1813AB01186A0 /* DateFormatter+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DateFormatter+Extensions.swift"; sourceTree = "<group>"; };
|
|
||||||
9FBEF4F77A6A2E2D041EF581 /* AppConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfig.swift; sourceTree = "<group>"; };
|
|
||||||
CEABFC668F7B082847F65D38 /* BugListPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugListPopover.swift; sourceTree = "<group>"; };
|
|
||||||
DDDB7AD270E93EA343975AC6 /* BuggerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuggerApp.swift; sourceTree = "<group>"; };
|
|
||||||
E9B6FE6E06295095649B24A7 /* Bugger.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Bugger.entitlements; sourceTree = "<group>"; };
|
|
||||||
EF0182DF85A7CD0319333F24 /* Bugger.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Bugger.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
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 = "<group>"; };
|
207CA4381D0CFEC95263A2DE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
DDDB7AD270E93EA343975AC6 /* BuggerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuggerApp.swift; sourceTree = "<group>"; };
|
||||||
|
9FBEF4F77A6A2E2D041EF581 /* AppConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfig.swift; sourceTree = "<group>"; };
|
||||||
|
846CA001267896C54A78441A /* Bug.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bug.swift; sourceTree = "<group>"; };
|
||||||
|
08C488C534B87C0560AF960F /* BugAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugAttachment.swift; sourceTree = "<group>"; };
|
||||||
|
0B24E1E1D32F7B774D12DD85 /* BugChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugChange.swift; sourceTree = "<group>"; };
|
||||||
|
5359156BD70B765D2BF4E718 /* BugPriority.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugPriority.swift; sourceTree = "<group>"; };
|
||||||
|
646C676356A4125B8E8D5FA5 /* BugStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugStatus.swift; sourceTree = "<group>"; };
|
||||||
F1DD178095EFCE7BC121C1C4 /* AppStateService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateService.swift; sourceTree = "<group>"; };
|
F1DD178095EFCE7BC121C1C4 /* AppStateService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateService.swift; sourceTree = "<group>"; };
|
||||||
8F2E3D4C5B6A7988061A2B3C /* BitableEventService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitableEventService.swift; sourceTree = "<group>"; };
|
CCF76E668F6EFF64241248E3 /* AttachmentLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentLoader.swift; sourceTree = "<group>"; };
|
||||||
1A2B3C4D5E6F70819293A4B5 /* CalibrationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalibrationService.swift; sourceTree = "<group>"; };
|
E834D262A6EE1D5406686761 /* BitableEventService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitableEventService.swift; sourceTree = "<group>"; };
|
||||||
F888AA5F655343B19839B4C2 /* URL+Feishu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Feishu.swift"; sourceTree = "<group>"; };
|
D3CA07056AACE5378CF9D421 /* BugEditService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugEditService.swift; sourceTree = "<group>"; };
|
||||||
|
52C52B8EDA29A06D9DAA6E5D /* CalibrationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalibrationService.swift; sourceTree = "<group>"; };
|
||||||
|
6A2C50E47966E8ADB0446A0C /* FeishuAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuAuthService.swift; sourceTree = "<group>"; };
|
||||||
|
2A83E76FBB83849E4B6704D3 /* FeishuError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuError.swift; sourceTree = "<group>"; };
|
||||||
FE9FBC39502FA635EF1A6A5C /* FeishuModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuModels.swift; sourceTree = "<group>"; };
|
FE9FBC39502FA635EF1A6A5C /* FeishuModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuModels.swift; sourceTree = "<group>"; };
|
||||||
|
21AC33F380B7DC34C39B61A6 /* FeishuService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuService.swift; sourceTree = "<group>"; };
|
||||||
|
59CC175DE8E9FE3250757D33 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
|
||||||
|
4F4628E7B599B69B499E09E9 /* PollerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollerService.swift; sourceTree = "<group>"; };
|
||||||
|
0E7E3775F77DD36C27160A8C /* TokenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenManager.swift; sourceTree = "<group>"; };
|
||||||
|
0F99094B0F609809EBDB5F4A /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = "<group>"; };
|
||||||
|
87C935E6B4D1813AB01186A0 /* DateFormatter+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DateFormatter+Extensions.swift"; sourceTree = "<group>"; };
|
||||||
|
EF06E1030AA21DA8190169A8 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = "<group>"; };
|
||||||
|
34D11523ACB81DBB9D412995 /* LocalOAuthServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalOAuthServer.swift; sourceTree = "<group>"; };
|
||||||
|
0ED0A90DFCB8D4968E4E3CA1 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
|
||||||
|
F888AA5F655343B19839B4C2 /* URL+Feishu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Feishu.swift"; sourceTree = "<group>"; };
|
||||||
|
344F52FB34B285D91A3E4670 /* BugStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugStore.swift; sourceTree = "<group>"; };
|
||||||
|
A45A7E013FA764EFAD7C9463 /* MainWindowState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindowState.swift; sourceTree = "<group>"; };
|
||||||
|
6E2FE32B404AB4388A066991 /* FloatingWidgetWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingWidgetWindow.swift; sourceTree = "<group>"; };
|
||||||
|
AD8CEC0D176C83BC46992923 /* BugDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugDetailView.swift; sourceTree = "<group>"; };
|
||||||
|
C9AB6328F122ACEDACC9A2FB /* MainWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindowView.swift; sourceTree = "<group>"; };
|
||||||
|
CEABFC668F7B082847F65D38 /* BugListPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugListPopover.swift; sourceTree = "<group>"; };
|
||||||
|
1129D6F630904F03D06B5DC4 /* BugRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugRow.swift; sourceTree = "<group>"; };
|
||||||
|
3C0A744D4F5DB88B1496176B /* OAuthSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthSetupView.swift; sourceTree = "<group>"; };
|
||||||
|
2D3693AA93F823859D15256D /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||||
|
09B9A4D84F3F604C49171A89 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
|
73AB234B54673C838E33AC7F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
E9B6FE6E06295095649B24A7 /* Bugger.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Bugger.entitlements; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
|
@ -86,7 +98,7 @@
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
14ED1DECD6D906F498B9ECB5 = {
|
14ED1DECD6D906F498B9ECB5 /* Root */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
287BE755981C092238309A9A /* Sources */,
|
287BE755981C092238309A9A /* Sources */,
|
||||||
|
|
@ -109,22 +121,12 @@
|
||||||
path = Sources;
|
path = Sources;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
31DF436612EC1C3CD1194AA1 /* Views */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
E7E4AD241EEE594C1EEB9724 /* FloatingWidget */,
|
|
||||||
EA9ED591BB8A18FEBD1B58A3 /* MenuBar */,
|
|
||||||
74AD95EF49A7D01C83865FAC /* OAuth */,
|
|
||||||
B70D2E54AD6B19DD455A8F82 /* Settings */,
|
|
||||||
);
|
|
||||||
path = Views;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
65997CD9BD6FFEF8851BF59E /* Models */ = {
|
65997CD9BD6FFEF8851BF59E /* Models */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
9FBEF4F77A6A2E2D041EF581 /* AppConfig.swift */,
|
9FBEF4F77A6A2E2D041EF581 /* AppConfig.swift */,
|
||||||
846CA001267896C54A78441A /* Bug.swift */,
|
846CA001267896C54A78441A /* Bug.swift */,
|
||||||
|
08C488C534B87C0560AF960F /* BugAttachment.swift */,
|
||||||
0B24E1E1D32F7B774D12DD85 /* BugChange.swift */,
|
0B24E1E1D32F7B774D12DD85 /* BugChange.swift */,
|
||||||
5359156BD70B765D2BF4E718 /* BugPriority.swift */,
|
5359156BD70B765D2BF4E718 /* BugPriority.swift */,
|
||||||
646C676356A4125B8E8D5FA5 /* BugStatus.swift */,
|
646C676356A4125B8E8D5FA5 /* BugStatus.swift */,
|
||||||
|
|
@ -132,30 +134,20 @@
|
||||||
path = Models;
|
path = Models;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
6AB9730AA11690EFC8F21473 /* ViewModels */ = {
|
A8C46168E4DFCE29B056A4E2 /* Services */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
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;
|
path = Services;
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
74AD95EF49A7D01C83865FAC /* OAuth */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
3C0A744D4F5DB88B1496176B /* OAuthSetupView.swift */,
|
|
||||||
);
|
|
||||||
path = OAuth;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
8AD6A16612A2DB7B2F899787 /* Resources */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
09B9A4D84F3F604C49171A89 /* Assets.xcassets */,
|
|
||||||
73AB234B54673C838E33AC7F /* Info.plist */,
|
|
||||||
E9B6FE6E06295095649B24A7 /* Bugger.entitlements */,
|
|
||||||
);
|
|
||||||
path = Resources;
|
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
A6646A2065F53ED50AD2CCA7 /* Utils */ = {
|
A6646A2065F53ED50AD2CCA7 /* Utils */ = {
|
||||||
|
|
@ -171,34 +163,25 @@
|
||||||
path = Utils;
|
path = Utils;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
A8C46168E4DFCE29B056A4E2 /* Services */ = {
|
6AB9730AA11690EFC8F21473 /* ViewModels */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
F1DD178095EFCE7BC121C1C4 /* AppStateService.swift */,
|
344F52FB34B285D91A3E4670 /* BugStore.swift */,
|
||||||
8F2E3D4C5B6A7988061A2B3C /* BitableEventService.swift */,
|
A45A7E013FA764EFAD7C9463 /* MainWindowState.swift */,
|
||||||
1A2B3C4D5E6F70819293A4B5 /* CalibrationService.swift */,
|
|
||||||
59CC175DE8E9FE3250757D33 /* NotificationService.swift */,
|
|
||||||
4F4628E7B599B69B499E09E9 /* PollerService.swift */,
|
|
||||||
0E7E3775F77DD36C27160A8C /* TokenManager.swift */,
|
|
||||||
E0E23D3899D89889546FB428 /* Feishu */,
|
|
||||||
);
|
);
|
||||||
path = Services;
|
path = ViewModels;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
B23D89B2D2DF3745A003AEB1 /* Products */ = {
|
31DF436612EC1C3CD1194AA1 /* Views */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
EF0182DF85A7CD0319333F24 /* Bugger.app */,
|
E7E4AD241EEE594C1EEB9724 /* FloatingWidget */,
|
||||||
|
5C01CC809DFAF826A51F2AEF /* MainWindow */,
|
||||||
|
EA9ED591BB8A18FEBD1B58A3 /* MenuBar */,
|
||||||
|
74AD95EF49A7D01C83865FAC /* OAuth */,
|
||||||
|
B70D2E54AD6B19DD455A8F82 /* Settings */,
|
||||||
);
|
);
|
||||||
name = Products;
|
path = Views;
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
B70D2E54AD6B19DD455A8F82 /* Settings */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
2D3693AA93F823859D15256D /* SettingsView.swift */,
|
|
||||||
);
|
|
||||||
path = Settings;
|
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
E0E23D3899D89889546FB428 /* Feishu */ = {
|
E0E23D3899D89889546FB428 /* Feishu */ = {
|
||||||
|
|
@ -220,6 +203,15 @@
|
||||||
path = FloatingWidget;
|
path = FloatingWidget;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
5C01CC809DFAF826A51F2AEF /* MainWindow */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
AD8CEC0D176C83BC46992923 /* BugDetailView.swift */,
|
||||||
|
C9AB6328F122ACEDACC9A2FB /* MainWindowView.swift */,
|
||||||
|
);
|
||||||
|
path = MainWindow;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
EA9ED591BB8A18FEBD1B58A3 /* MenuBar */ = {
|
EA9ED591BB8A18FEBD1B58A3 /* MenuBar */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
|
@ -229,6 +221,40 @@
|
||||||
path = MenuBar;
|
path = MenuBar;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
74AD95EF49A7D01C83865FAC /* OAuth */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
3C0A744D4F5DB88B1496176B /* OAuthSetupView.swift */,
|
||||||
|
);
|
||||||
|
path = OAuth;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
B70D2E54AD6B19DD455A8F82 /* Settings */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
2D3693AA93F823859D15256D /* SettingsView.swift */,
|
||||||
|
);
|
||||||
|
path = Settings;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
8AD6A16612A2DB7B2F899787 /* Resources */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
09B9A4D84F3F604C49171A89 /* Assets.xcassets */,
|
||||||
|
73AB234B54673C838E33AC7F /* Info.plist */,
|
||||||
|
E9B6FE6E06295095649B24A7 /* Bugger.entitlements */,
|
||||||
|
);
|
||||||
|
path = Resources;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
B23D89B2D2DF3745A003AEB1 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
EF0182DF85A7CD0319333F24 /* Bugger.app */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
|
|
@ -302,12 +328,15 @@
|
||||||
15550246A9E8DAF25E690478 /* BuggerApp.swift in Sources */,
|
15550246A9E8DAF25E690478 /* BuggerApp.swift in Sources */,
|
||||||
58F16D20CC26D8248055CC2C /* AppConfig.swift in Sources */,
|
58F16D20CC26D8248055CC2C /* AppConfig.swift in Sources */,
|
||||||
4EA9BDBA3F98AF4A41712A18 /* Bug.swift in Sources */,
|
4EA9BDBA3F98AF4A41712A18 /* Bug.swift in Sources */,
|
||||||
|
7AD8DF0FC133A2AE26F3E3C2 /* BugAttachment.swift in Sources */,
|
||||||
DDB9EB345FEBB95DD9976364 /* BugChange.swift in Sources */,
|
DDB9EB345FEBB95DD9976364 /* BugChange.swift in Sources */,
|
||||||
A33561284CA44A0045902BCC /* BugPriority.swift in Sources */,
|
A33561284CA44A0045902BCC /* BugPriority.swift in Sources */,
|
||||||
E4CBAE27491AF1C52944FA91 /* BugStatus.swift in Sources */,
|
E4CBAE27491AF1C52944FA91 /* BugStatus.swift in Sources */,
|
||||||
D2855F82E68FADA8BEA43086 /* AppStateService.swift in Sources */,
|
D2855F82E68FADA8BEA43086 /* AppStateService.swift in Sources */,
|
||||||
7E1F2A3B4C5D6E7F8091A2B3 /* BitableEventService.swift in Sources */,
|
BD123E3404B92204E7CB96DF /* AttachmentLoader.swift in Sources */,
|
||||||
A1B2C3D4E5F60718293A4B5C /* CalibrationService.swift in Sources */,
|
A31640CA64AEAD8FEC551137 /* BitableEventService.swift in Sources */,
|
||||||
|
660734535DE7B5840B0DD24B /* BugEditService.swift in Sources */,
|
||||||
|
BCF7698E979D7452AAC4612A /* CalibrationService.swift in Sources */,
|
||||||
4309E8C70AE99466545A3E37 /* FeishuAuthService.swift in Sources */,
|
4309E8C70AE99466545A3E37 /* FeishuAuthService.swift in Sources */,
|
||||||
CB529549DB1DBB5F02884300 /* FeishuError.swift in Sources */,
|
CB529549DB1DBB5F02884300 /* FeishuError.swift in Sources */,
|
||||||
CAE624453A3F0375D872170A /* FeishuModels.swift in Sources */,
|
CAE624453A3F0375D872170A /* FeishuModels.swift in Sources */,
|
||||||
|
|
@ -322,7 +351,10 @@
|
||||||
47ED652FA0AC818086F85DF3 /* Logger.swift in Sources */,
|
47ED652FA0AC818086F85DF3 /* Logger.swift in Sources */,
|
||||||
1FB7E762C0B6568C47B48EF0 /* URL+Feishu.swift in Sources */,
|
1FB7E762C0B6568C47B48EF0 /* URL+Feishu.swift in Sources */,
|
||||||
AECED74595B57A400437B9FD /* BugStore.swift in Sources */,
|
AECED74595B57A400437B9FD /* BugStore.swift in Sources */,
|
||||||
|
AD91D07ED264D88DA92E49DA /* MainWindowState.swift in Sources */,
|
||||||
485A69E15EF1D805F50DF364 /* FloatingWidgetWindow.swift in Sources */,
|
485A69E15EF1D805F50DF364 /* FloatingWidgetWindow.swift in Sources */,
|
||||||
|
F5855822732C5D79A6BFF1A6 /* BugDetailView.swift in Sources */,
|
||||||
|
899C203A1CCFE8532BB228A2 /* MainWindowView.swift in Sources */,
|
||||||
1E3C1F40E2FF64A041856909 /* BugListPopover.swift in Sources */,
|
1E3C1F40E2FF64A041856909 /* BugListPopover.swift in Sources */,
|
||||||
3467A50268399ABE9804D4D4 /* BugRow.swift in Sources */,
|
3467A50268399ABE9804D4D4 /* BugRow.swift in Sources */,
|
||||||
B066D78DA6F57A882A61C0B2 /* OAuthSetupView.swift in Sources */,
|
B066D78DA6F57A882A61C0B2 /* OAuthSetupView.swift in Sources */,
|
||||||
|
|
@ -362,15 +394,12 @@
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 5;
|
CURRENT_PROJECT_VERSION = 5;
|
||||||
FEISHU_APP_ID = cli_aacb82a1c0b8dcc0;
|
FEISHU_APP_ID = "$(FEISHU_APP_ID)";
|
||||||
FEISHU_APP_SECRET = jUkU9ygRuQ0KaZFV2BSPfe33iXCahzNM;
|
FEISHU_APP_SECRET = "$(FEISHU_APP_SECRET)";
|
||||||
FEISHU_BASE_DOMAIN = xmi5p6bgsnf8.feishu.cn;
|
FEISHU_BASE_DOMAIN = "$(FEISHU_BASE_DOMAIN)";
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = Resources/Info.plist;
|
INFOPLIST_FILE = Resources/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks";
|
||||||
"$(inherited)",
|
|
||||||
"@executable_path/../Frameworks",
|
|
||||||
);
|
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 1.5;
|
MARKETING_VERSION = 1.5;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.xorbitlab.bugger;
|
PRODUCT_BUNDLE_IDENTIFIER = com.xorbitlab.bugger;
|
||||||
|
|
@ -386,15 +415,12 @@
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 5;
|
CURRENT_PROJECT_VERSION = 5;
|
||||||
FEISHU_APP_ID = cli_aacb82a1c0b8dcc0;
|
FEISHU_APP_ID = "$(FEISHU_APP_ID)";
|
||||||
FEISHU_APP_SECRET = jUkU9ygRuQ0KaZFV2BSPfe33iXCahzNM;
|
FEISHU_APP_SECRET = "$(FEISHU_APP_SECRET)";
|
||||||
FEISHU_BASE_DOMAIN = xmi5p6bgsnf8.feishu.cn;
|
FEISHU_BASE_DOMAIN = "$(FEISHU_BASE_DOMAIN)";
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = Resources/Info.plist;
|
INFOPLIST_FILE = Resources/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks";
|
||||||
"$(inherited)",
|
|
||||||
"@executable_path/../Frameworks",
|
|
||||||
);
|
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 1.5;
|
MARKETING_VERSION = 1.5;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.xorbitlab.bugger;
|
PRODUCT_BUNDLE_IDENTIFIER = com.xorbitlab.bugger;
|
||||||
|
|
@ -426,6 +452,7 @@
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
};
|
};
|
||||||
rootObject = 46F86FAA6BBF9AC94A7E4595 /* Project object */;
|
rootObject = 46F86FAA6BBF9AC94A7E4595 /* Project object */;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
popover.performClose(nil)
|
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() {
|
func showFloatingWidget() {
|
||||||
guard floatingWidget == nil else { return }
|
guard floatingWidget == nil else { return }
|
||||||
floatingWidget = FloatingWidgetWindow()
|
floatingWidget = FloatingWidgetWindow()
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,11 @@ struct BuggerApp: App {
|
||||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
|
Window("Bugger", id: "main") {
|
||||||
|
MainWindowView()
|
||||||
|
}
|
||||||
|
.defaultSize(width: 900, height: 600)
|
||||||
|
|
||||||
Settings {
|
Settings {
|
||||||
SettingsView()
|
SettingsView()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,19 @@ struct AppConfig: Codable, Equatable {
|
||||||
var customerField: String = "客户名称"
|
var customerField: String = "客户名称"
|
||||||
var createdAtField: String = "提出时间"
|
var createdAtField: String = "提出时间"
|
||||||
var updatedAtField: 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.
|
/// Map Feishu display values to standard BugStatus cases.
|
||||||
/// Keys are the Feishu select-option text; values are the standard status.
|
/// Keys are the Feishu select-option text; values are the standard status.
|
||||||
|
|
@ -59,6 +72,37 @@ struct AppConfig: Codable, Equatable {
|
||||||
"转需求": .closed,
|
"转需求": .closed,
|
||||||
"验收不通过": .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 {
|
var isConfigured: Bool {
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,25 @@ import Foundation
|
||||||
struct Bug: Identifiable, Equatable, Hashable {
|
struct Bug: Identifiable, Equatable, Hashable {
|
||||||
let id: String
|
let id: String
|
||||||
let title: String
|
let title: String
|
||||||
|
let description: String?
|
||||||
let priority: BugPriority
|
let priority: BugPriority
|
||||||
let status: BugStatus
|
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 assignee: String
|
||||||
let reporter: String?
|
let reporter: String?
|
||||||
let customer: 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 createdAt: Date
|
||||||
let updatedAt: Date
|
let updatedAt: Date
|
||||||
let feishuURL: URL?
|
let feishuURL: URL?
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -36,7 +36,7 @@ final class FeishuAuthService {
|
||||||
components.queryItems = [
|
components.queryItems = [
|
||||||
URLQueryItem(name: "app_id", value: appId),
|
URLQueryItem(name: "app_id", value: appId),
|
||||||
URLQueryItem(name: "redirect_uri", value: redirectURI),
|
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!
|
return components.url!
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,35 @@ struct RecordListData: Decodable {
|
||||||
let total: Int?
|
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 {
|
struct RecordItem: Decodable {
|
||||||
let recordId: String
|
let recordId: String
|
||||||
let fields: [String: JSONValue]
|
let fields: [String: JSONValue]
|
||||||
|
|
@ -126,6 +155,30 @@ enum JSONValue: Decodable {
|
||||||
}
|
}
|
||||||
return nil
|
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 {
|
enum BugMapper {
|
||||||
|
|
@ -139,11 +192,18 @@ enum BugMapper {
|
||||||
return Bug(
|
return Bug(
|
||||||
id: record.recordId,
|
id: record.recordId,
|
||||||
title: stringValue(in: fields, key: mappings.titleField) ?? "Untitled",
|
title: stringValue(in: fields, key: mappings.titleField) ?? "Untitled",
|
||||||
|
description: description(in: fields, mappings: mappings),
|
||||||
priority: BugPriority(rawValue: stringValue(in: fields, key: mappings.priorityField) ?? "") ?? .unknown,
|
priority: BugPriority(rawValue: stringValue(in: fields, key: mappings.priorityField) ?? "") ?? .unknown,
|
||||||
status: mapStatus(stringValue(in: fields, key: mappings.statusField), mappings: mappings),
|
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",
|
assignee: userName(in: fields, key: mappings.assigneeField) ?? "Unknown",
|
||||||
reporter: userName(in: fields, key: mappings.reporterField),
|
reporter: userName(in: fields, key: mappings.reporterField),
|
||||||
customer: rawValue(in: fields, key: mappings.customerField).map { "\($0)" },
|
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,
|
createdAt: FeishuDateParser.parse(rawValue(in: fields, key: mappings.createdAtField)) ?? .distantPast,
|
||||||
updatedAt: FeishuDateParser.parse(rawValue(in: fields, key: mappings.updatedAtField)) ?? .distantPast,
|
updatedAt: FeishuDateParser.parse(rawValue(in: fields, key: mappings.updatedAtField)) ?? .distantPast,
|
||||||
feishuURL: FeishuURLBuilder.recordURL(
|
feishuURL: FeishuURLBuilder.recordURL(
|
||||||
|
|
@ -174,6 +234,36 @@ enum BugMapper {
|
||||||
fields[key]?.textValue
|
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? {
|
private static func userName(in fields: [String: JSONValue], key: String) -> String? {
|
||||||
fields[key]?.firstUserName ?? fields[key]?.stringValue
|
fields[key]?.firstUserName ?? fields[key]?.stringValue
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,213 @@ final class FeishuService {
|
||||||
return pageData
|
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":{"<fieldId>":{"<recordId>":["<fileToken>"]}}}}`.
|
||||||
|
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<FieldListData>.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) ?? "<binary \(data.count)B>"
|
||||||
|
BuggerLog.debug("updateRecord: HTTP \(httpResponse.statusCode) body=\(String(rawBody.prefix(500)))")
|
||||||
|
|
||||||
|
if httpResponse.statusCode == 401 {
|
||||||
|
throw FeishuError.unauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
let apiResponse = try decoder.decode(FeishuAPIResponse<JSONValue>.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) ?? "<binary \(responseData.count)B>"
|
||||||
|
BuggerLog.debug("uploadMedia: HTTP \(httpResponse.statusCode) body=\(String(rawBody.prefix(500)))")
|
||||||
|
|
||||||
|
let apiResponse = try decoder.decode(FeishuAPIResponse<UploadMediaData>.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)
|
// MARK: - Search endpoint (POST /records/search)
|
||||||
|
|
||||||
private func searchRecords(
|
private func searchRecords(
|
||||||
|
|
|
||||||
|
|
@ -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() {}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ struct BugListPopover: View {
|
||||||
@State private var bugStore = BugStore.shared
|
@State private var bugStore = BugStore.shared
|
||||||
@State private var tokenManager = TokenManager.shared
|
@State private var tokenManager = TokenManager.shared
|
||||||
@Environment(\.openSettings) private var openSettings
|
@Environment(\.openSettings) private var openSettings
|
||||||
|
@Environment(\.openWindow) private var openWindow
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
|
@ -49,6 +50,19 @@ struct BugListPopover: View {
|
||||||
AppDelegate.shared?.updateBadge(count: bugStore.activeBugs.count)
|
AppDelegate.shared?.updateBadge(count: bugStore.activeBugs.count)
|
||||||
}
|
}
|
||||||
.font(.caption)
|
.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(.horizontal)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
|
|
@ -71,9 +85,11 @@ struct BugListPopover: View {
|
||||||
isUnseen: bugStore.unseenBugs.contains(bug.id)
|
isUnseen: bugStore.unseenBugs.contains(bug.id)
|
||||||
)
|
)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
openInFeishu(bug)
|
openDetails(bug)
|
||||||
bugStore.markSeen(bug.id)
|
}
|
||||||
AppDelegate.shared?.updateBadge(count: bugStore.activeBugs.count)
|
.contextMenu {
|
||||||
|
Button("Open Details") { openDetails(bug) }
|
||||||
|
Button("Open in Feishu") { openInFeishu(bug) }
|
||||||
}
|
}
|
||||||
Divider().padding(.leading, 44)
|
Divider().padding(.leading, 44)
|
||||||
}
|
}
|
||||||
|
|
@ -113,9 +129,24 @@ struct BugListPopover: View {
|
||||||
.font(.caption)
|
.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) {
|
private func openInFeishu(_ bug: Bug) {
|
||||||
guard let url = bug.feishuURL else { return }
|
guard let url = bug.feishuURL else { return }
|
||||||
NSWorkspace.shared.open(url)
|
NSWorkspace.shared.open(url)
|
||||||
|
bugStore.markSeen(bug.id)
|
||||||
|
AppDelegate.shared?.updateBadge(count: bugStore.activeBugs.count)
|
||||||
AppDelegate.shared?.closePopover()
|
AppDelegate.shared?.closePopover()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -236,6 +236,34 @@ struct SettingsView: View {
|
||||||
TextField("", text: $config.fieldMappings.updatedAtField)
|
TextField("", text: $config.fieldMappings.updatedAtField)
|
||||||
.textFieldStyle(.roundedBorder)
|
.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()
|
Divider()
|
||||||
|
|
|
||||||
|
|
@ -340,7 +340,7 @@ def build_pbxproj() -> str:
|
||||||
("CURRENT_PROJECT_VERSION", "5"),
|
("CURRENT_PROJECT_VERSION", "5"),
|
||||||
("FEISHU_APP_ID", '"$(FEISHU_APP_ID)"'),
|
("FEISHU_APP_ID", '"$(FEISHU_APP_ID)"'),
|
||||||
("FEISHU_APP_SECRET", '"$(FEISHU_APP_SECRET)"'),
|
("FEISHU_APP_SECRET", '"$(FEISHU_APP_SECRET)"'),
|
||||||
("FEISHU_BASE_DOMAIN", '"xorbitlab.feishu.cn"'),
|
("FEISHU_BASE_DOMAIN", '"$(FEISHU_BASE_DOMAIN)"'),
|
||||||
("GENERATE_INFOPLIST_FILE", "NO"),
|
("GENERATE_INFOPLIST_FILE", "NO"),
|
||||||
("INFOPLIST_FILE", "Resources/Info.plist"),
|
("INFOPLIST_FILE", "Resources/Info.plist"),
|
||||||
("LD_RUNPATH_SEARCH_PATHS",
|
("LD_RUNPATH_SEARCH_PATHS",
|
||||||
|
|
@ -431,6 +431,13 @@ def build_xcscheme() -> str:
|
||||||
</BuildActionEntry>
|
</BuildActionEntry>
|
||||||
</BuildActionEntries>
|
</BuildActionEntries>
|
||||||
</BuildAction>
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
shouldAutocreateTestPlan = "YES">
|
||||||
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
|
@ -452,6 +459,20 @@ def build_xcscheme() -> str:
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
</LaunchAction>
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
</Scheme>
|
</Scheme>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue