diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e4b79bbe..6497c30e 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -12,12 +12,13 @@ name: "CodeQL Advanced" on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - schedule: - - cron: '31 2 * * 5' + workflow_dispatch: +# push: +# branches: [ "main" ] +# pull_request: +# branches: [ "main" ] +# schedule: +# - cron: '31 2 * * 5' jobs: analyze: @@ -87,7 +88,7 @@ jobs: - if: matrix.build-mode == 'manual' shell: bash run: | - sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer + sudo xcode-select -s /Applications/Xcode_26.0.app/Contents/Developer xcodebuild -scheme GitClient CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO -destination "platform=macOS" -disableAutomaticPackageResolution - name: Perform CodeQL Analysis diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 42665989..a5273077 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,11 +11,11 @@ jobs: permissions: contents: write pull-requests: write - runs-on: macos-15 + runs-on: macos-26 steps: - name: Select Xcode run: | - sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer + sudo xcode-select -s /Applications/Xcode_26.0.app/Contents/Developer - name: Checkout uses: actions/checkout@v4 with: @@ -46,22 +46,22 @@ jobs: security list-keychain -d user -s $KEYCHAIN_PATH - name: Archive App run: | - xcodebuild -scheme GitClient -configuration Release -archivePath Tempo.xcarchive archive + xcodebuild -scheme GitClient -configuration Release -archivePath Changes.xcarchive archive - name: Export App run: | - xcodebuild -exportArchive -archivePath Tempo.xcarchive -exportOptionsPlist ExportOptions.plist -exportPath Tempo + xcodebuild -exportArchive -archivePath Changes.xcarchive -exportOptionsPlist ExportOptions.plist -exportPath Changes - name: Create ZIP Archive run: | - ditto -c -k --keepParent Tempo/Tempo.app Tempo.zip + ditto -c -k --keepParent Changes/Changes.app Changes.zip - name: Notarize App env: AC_USERNAME: ${{ secrets.AC_USERNAME }} AC_PASSWORD: ${{ secrets.AC_PASSWORD }} run: | - xcrun notarytool submit Tempo.zip --apple-id "$AC_USERNAME" --password "$AC_PASSWORD" --team-id JRA6VW2DG4 --wait + xcrun notarytool submit Changes.zip --apple-id "$AC_USERNAME" --password "$AC_PASSWORD" --team-id JRA6VW2DG4 --wait - name: Generate SHA256 Checksum run: | - shasum -a 256 Tempo.zip > Checksum.txt + shasum -a 256 Changes.zip > Checksum.txt echo "SHA256 Checksum: $(cat Checksum.txt)" >> $GITHUB_STEP_SUMMARY - name: Upload Assets to Release uses: softprops/action-gh-release@v2 @@ -69,7 +69,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: files: | - Tempo.zip + Changes.zip Checksum.txt draft: true generate_release_notes: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8c2b4d5f..69cbb82d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,11 +11,11 @@ jobs: permissions: contents: read pull-requests: write - runs-on: macos-15 + runs-on: macos-26 steps: - name: Select Xcode run: | - sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer + sudo xcode-select -s /Applications/Xcode_26.0.app/Contents/Developer - name: Checkout uses: actions/checkout@v4 with: diff --git a/GitClient.xcodeproj/project.pbxproj b/GitClient.xcodeproj/project.pbxproj index aa37d0b1..21245f0b 100644 --- a/GitClient.xcodeproj/project.pbxproj +++ b/GitClient.xcodeproj/project.pbxproj @@ -13,9 +13,13 @@ 6103A5A028EE47790083A2F5 /* GitMerge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6103A59F28EE47790083A2F5 /* GitMerge.swift */; }; 61094D512DFA4D3A00D7B8AA /* GitRevParse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61094D502DFA4D3A00D7B8AA /* GitRevParse.swift */; }; 61094D532DFA4E7C00D7B8AA /* GitRevParseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61094D522DFA4E7C00D7B8AA /* GitRevParseTests.swift */; }; + 611176E52E7B81AE00A8E0A4 /* CommitDetailBottomBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 611176E42E7B81AE00A8E0A4 /* CommitDetailBottomBar.swift */; }; + 611176E72E7B83C300A8E0A4 /* CommitDetailHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 611176E62E7B83C300A8E0A4 /* CommitDetailHeaderView.swift */; }; 612208A92C032A0D0047B454 /* UnstagedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 612208A82C032A0D0047B454 /* UnstagedView.swift */; }; 612659BE2DA8911900F01F2C /* CommitGraphView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 612659BD2DA8910800F01F2C /* CommitGraphView.swift */; }; 612919F72CCD16090079FD0B /* URL+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 612919F62CCD16050079FD0B /* URL+.swift */; }; + 61297DF52E75055D003E4727 /* CommitMessageGenerationContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61297DF42E75055D003E4727 /* CommitMessageGenerationContentView.swift */; }; + 61297DF72E7505D8003E4727 /* CommitMessageGenerationUnavailableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61297DF62E7505D8003E4727 /* CommitMessageGenerationUnavailableView.swift */; }; 6130BDE62C957A450050E70F /* StashChangedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6130BDE52C957A450050E70F /* StashChangedView.swift */; }; 6130BDE82C95821C0050E70F /* Stash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6130BDE72C95821C0050E70F /* Stash.swift */; }; 6130BDEA2C958B710050E70F /* GitStashList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6130BDE92C958B710050E70F /* GitStashList.swift */; }; @@ -38,13 +42,14 @@ 614DE8162DA0C2E700FC582E /* GitShowShortstatTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 614DE8152DA0C2E700FC582E /* GitShowShortstatTests.swift */; }; 614DE81A2DA2238A00FC582E /* FileNameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 614DE8192DA2238A00FC582E /* FileNameView.swift */; }; 614DE81C2DA345E700FC582E /* GitBranchDelete.swift in Sources */ = {isa = PBXBuildFile; fileRef = 614DE81B2DA345DB00FC582E /* GitBranchDelete.swift */; }; - 6154D69C2C6739C700448AB4 /* CommitMessageSuggestionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6154D69B2C6739C700448AB4 /* CommitMessageSuggestionView.swift */; }; + 6154D69C2C6739C700448AB4 /* CommitMessageSnippetSuggestionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6154D69B2C6739C700448AB4 /* CommitMessageSnippetSuggestionView.swift */; }; 6154EEF82DE3228500103692 /* AuthorInitialIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6154EEF72DE3228500103692 /* AuthorInitialIcon.swift */; }; 6154EEFA2DE324C100103692 /* Icon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6154EEF92DE324C100103692 /* Icon.swift */; }; 6154EEFE2DE5E9BB00103692 /* GitFetchExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6154EEFD2DE5E9AC00103692 /* GitFetchExecutor.swift */; }; 6156E4EC2C9CE1AF00929F2F /* GitBranchPointsAt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6156E4EB2C9CE1AF00929F2F /* GitBranchPointsAt.swift */; }; 616156252D615C750034B1F1 /* FileDiffTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 616156242D615C740034B1F1 /* FileDiffTheme.swift */; }; 616156272D637E6A0034B1F1 /* Language.swift in Sources */ = {isa = PBXBuildFile; fileRef = 616156262D637E630034B1F1 /* Language.swift */; }; + 616A46AF2E6C15170072BBBC /* CommitMessageEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 616A46AE2E6C15170072BBBC /* CommitMessageEditor.swift */; }; 616C4C1C28E88828000E7154 /* GitCommit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 616C4C1B28E88828000E7154 /* GitCommit.swift */; }; 616C4C1E28E8894D000E7154 /* GitAdd.swift in Sources */ = {isa = PBXBuildFile; fileRef = 616C4C1D28E8894D000E7154 /* GitAdd.swift */; }; 616C4C2028E896FA000E7154 /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = 616C4C1F28E896FA000E7154 /* Log.swift */; }; @@ -55,6 +60,8 @@ 616C6FC92C97BC8200A419DE /* StashChangedContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 616C6FC82C97BC8200A419DE /* StashChangedContentView.swift */; }; 616C6FCC2C97C3CC00A419DE /* GitStashApply.swift in Sources */ = {isa = PBXBuildFile; fileRef = 616C6FCB2C97C3CC00A419DE /* GitStashApply.swift */; }; 616CFE7A2DBB02E100CAFCE6 /* ErrorTextSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 616CFE792DBB02E100CAFCE6 /* ErrorTextSheet.swift */; }; + 616EE8722E017B0B00524800 /* SystemLanguageModelService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 616EE8712E017AEC00524800 /* SystemLanguageModelService.swift */; }; + 616EE8742E0181D700524800 /* SystemLanguageModelServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 616EE8732E0181D700524800 /* SystemLanguageModelServiceTests.swift */; }; 61702D092C9E7FA100FE7E35 /* GitRevert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61702D082C9E7FA100FE7E35 /* GitRevert.swift */; }; 61702D0B2C9E8A1B00FE7E35 /* GitTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61702D0A2C9E8A1600FE7E35 /* GitTag.swift */; }; 61702D0D2C9E8BFF00FE7E35 /* GitTagCreate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61702D0C2C9E8BF900FE7E35 /* GitTagCreate.swift */; }; @@ -75,9 +82,11 @@ 6185846D2DA562C00038EBBE /* RenameBranchSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6185846C2DA562B10038EBBE /* RenameBranchSheet.swift */; }; 6186EBF92DA5ED4800DCC20E /* AmendCommitSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6186EBF82DA5ED4800DCC20E /* AmendCommitSheet.swift */; }; 618978A42DD55BE80013B21E /* CommitDiffView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618978A32DD55BE80013B21E /* CommitDiffView.swift */; }; + 618AA5302E50666800AEB995 /* CommitMessageGenerationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618AA52F2E50666800AEB995 /* CommitMessageGenerationView.swift */; }; 6193DDCD2DB8CA1400B156C4 /* CommitLogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6193DDCC2DB8CA1400B156C4 /* CommitLogView.swift */; }; 6193DDCF2DB8E18300B156C4 /* FolderViewShowing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6193DDCE2DB8E18300B156C4 /* FolderViewShowing.swift */; }; 6193DDD12DB9B08A00B156C4 /* GitRevListCount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6193DDD02DB9B08200B156C4 /* GitRevListCount.swift */; }; + 6195C6392E6662BE0003A676 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 6195C6382E6662BE0003A676 /* AppIcon.icon */; }; 619759462C89F1EC00E9CA4F /* GitRestorePatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 619759452C89F1EC00E9CA4F /* GitRestorePatch.swift */; }; 619759482C8A756F00E9CA4F /* GitStatusShort.swift in Sources */ = {isa = PBXBuildFile; fileRef = 619759472C8A756F00E9CA4F /* GitStatusShort.swift */; }; 6197594A2C8A76DA00E9CA4F /* Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = 619759492C8A76DA00E9CA4F /* Status.swift */; }; @@ -89,7 +98,6 @@ 619759562C8C9A9300E9CA4F /* GitDiffNumStatTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 619759552C8C9A9300E9CA4F /* GitDiffNumStatTests.swift */; }; 6199ED392DA73E8800D916EE /* CommitRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6199ED382DA73E8800D916EE /* CommitRowView.swift */; }; 619D876128F154AB00DD1D4E /* CreateNewBranchSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 619D876028F154AB00DD1D4E /* CreateNewBranchSheet.swift */; }; - 619DA6932CA62A1000E58DF9 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 619DA6922CA62A1000E58DF9 /* SettingsView.swift */; }; 61A0D0F82D8502CC005AF36C /* StageFileDiffsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61A0D0F72D8502CC005AF36C /* StageFileDiffsView.swift */; }; 61A20B9D2DFD44FC00390F94 /* CommitGraph.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61A20B9C2DFD44FB00390F94 /* CommitGraph.swift */; }; 61A2D35728DE9CCC009A3EEC /* GenericError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61A2D35628DE9CCC009A3EEC /* GenericError.swift */; }; @@ -98,7 +106,9 @@ 61A5DF502D9D5A3800FAF078 /* SearchToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61A5DF4F2D9D5A3800FAF078 /* SearchToken.swift */; }; 61A5DF522D9DFB2100FAF078 /* SearchTokensHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61A5DF512D9DFB0A00FAF078 /* SearchTokensHandler.swift */; }; 61A5DF542D9DFD1B00FAF078 /* SearchTokensHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61A5DF532D9DFD1B00FAF078 /* SearchTokensHandlerTests.swift */; }; + 61A6F2F92E80BBFE0051A51E /* DiffSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61A6F2F82E80BBFE0051A51E /* DiffSummaryView.swift */; }; 61AC65812DA9F83700D80470 /* LogStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61AC65802DA9F82E00D80470 /* LogStoreTests.swift */; }; + 61AF934A2E08FAE700C656AE /* SearchArguments.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61AF93492E08FAE700C656AE /* SearchArguments.swift */; }; 61B3C55E2CB4A74C00021B36 /* GitShow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61B3C55D2CB4A74700021B36 /* GitShow.swift */; }; 61B3C5602CB4A9CE00021B36 /* CommitDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61B3C55F2CB4A9C500021B36 /* CommitDetail.swift */; }; 61B3C5622CB557D100021B36 /* CommitDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61B3C5612CB557CA00021B36 /* CommitDetailView.swift */; }; @@ -114,10 +124,7 @@ 61CAEDEC2DCEE266009AADD9 /* ExpandableModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61CAEDEB2DCEE266009AADD9 /* ExpandableModelTests.swift */; }; 61D140D72D640122007BD12D /* ChunkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61D140D62D640122007BD12D /* ChunkView.swift */; }; 61D2BC3D2C8D8BB10059317E /* SectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61D2BC3C2C8D8BB10059317E /* SectionHeader.swift */; }; - 61D34C7D2CA7A4080032A22A /* KeychainStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61D34C7C2CA7A3FC0032A22A /* KeychainStorage.swift */; }; - 61D34C802CA8548F0032A22A /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 61D34C7F2CA8548F0032A22A /* KeychainAccess */; }; 61D34C822CA85DC70032A22A /* EnviromentValues+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61D34C812CA85DBF0032A22A /* EnviromentValues+.swift */; }; - 61D34C842CA8D0E30032A22A /* AIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61D34C832CA8D0DD0032A22A /* AIService.swift */; }; 61D34C872CA91F620032A22A /* CommitMessageProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61D34C862CA91F5F0032A22A /* CommitMessageProperties.swift */; }; 61D34C8B2CA943540032A22A /* StagingChangesProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61D34C8A2CA943380032A22A /* StagingChangesProperties.swift */; }; 61D34C8D2CAC394A0032A22A /* GitLogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61D34C8C2CAC394A0032A22A /* GitLogTests.swift */; }; @@ -162,15 +169,19 @@ 6103A59F28EE47790083A2F5 /* GitMerge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitMerge.swift; sourceTree = ""; }; 61094D502DFA4D3A00D7B8AA /* GitRevParse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitRevParse.swift; sourceTree = ""; }; 61094D522DFA4E7C00D7B8AA /* GitRevParseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitRevParseTests.swift; sourceTree = ""; }; + 611176E42E7B81AE00A8E0A4 /* CommitDetailBottomBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommitDetailBottomBar.swift; sourceTree = ""; }; + 611176E62E7B83C300A8E0A4 /* CommitDetailHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommitDetailHeaderView.swift; sourceTree = ""; }; 612208A82C032A0D0047B454 /* UnstagedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnstagedView.swift; sourceTree = ""; }; 612659BD2DA8910800F01F2C /* CommitGraphView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommitGraphView.swift; sourceTree = ""; }; 612919F62CCD16050079FD0B /* URL+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+.swift"; sourceTree = ""; }; + 61297DF42E75055D003E4727 /* CommitMessageGenerationContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommitMessageGenerationContentView.swift; sourceTree = ""; }; + 61297DF62E7505D8003E4727 /* CommitMessageGenerationUnavailableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommitMessageGenerationUnavailableView.swift; sourceTree = ""; }; 6130BDE52C957A450050E70F /* StashChangedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StashChangedView.swift; sourceTree = ""; }; 6130BDE72C95821C0050E70F /* Stash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Stash.swift; sourceTree = ""; }; 6130BDE92C958B710050E70F /* GitStashList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitStashList.swift; sourceTree = ""; }; 6131AC862DF268BF00DFBCB5 /* GitStatusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitStatusTests.swift; sourceTree = ""; }; 61326BBB2BC27A3300472D64 /* Diff.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Diff.swift; sourceTree = ""; }; - 61347EEA28D5D16C00625FC4 /* Tempo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Tempo.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 61347EEA28D5D16C00625FC4 /* Changes.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Changes.app; sourceTree = BUILT_PRODUCTS_DIR; }; 61347EED28D5D16C00625FC4 /* GitClientApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitClientApp.swift; sourceTree = ""; }; 61347EEF28D5D16C00625FC4 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 61347EF128D5D16D00625FC4 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -192,13 +203,14 @@ 614DE8152DA0C2E700FC582E /* GitShowShortstatTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitShowShortstatTests.swift; sourceTree = ""; }; 614DE8192DA2238A00FC582E /* FileNameView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileNameView.swift; sourceTree = ""; }; 614DE81B2DA345DB00FC582E /* GitBranchDelete.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitBranchDelete.swift; sourceTree = ""; }; - 6154D69B2C6739C700448AB4 /* CommitMessageSuggestionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommitMessageSuggestionView.swift; sourceTree = ""; }; + 6154D69B2C6739C700448AB4 /* CommitMessageSnippetSuggestionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommitMessageSnippetSuggestionView.swift; sourceTree = ""; }; 6154EEF72DE3228500103692 /* AuthorInitialIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorInitialIcon.swift; sourceTree = ""; }; 6154EEF92DE324C100103692 /* Icon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Icon.swift; sourceTree = ""; }; 6154EEFD2DE5E9AC00103692 /* GitFetchExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitFetchExecutor.swift; sourceTree = ""; }; 6156E4EB2C9CE1AF00929F2F /* GitBranchPointsAt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitBranchPointsAt.swift; sourceTree = ""; }; 616156242D615C740034B1F1 /* FileDiffTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDiffTheme.swift; sourceTree = ""; }; 616156262D637E630034B1F1 /* Language.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Language.swift; sourceTree = ""; }; + 616A46AE2E6C15170072BBBC /* CommitMessageEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommitMessageEditor.swift; sourceTree = ""; }; 616C4C1B28E88828000E7154 /* GitCommit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitCommit.swift; sourceTree = ""; }; 616C4C1D28E8894D000E7154 /* GitAdd.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitAdd.swift; sourceTree = ""; }; 616C4C1F28E896FA000E7154 /* Log.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Log.swift; sourceTree = ""; }; @@ -209,6 +221,8 @@ 616C6FC82C97BC8200A419DE /* StashChangedContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StashChangedContentView.swift; sourceTree = ""; }; 616C6FCB2C97C3CC00A419DE /* GitStashApply.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitStashApply.swift; sourceTree = ""; }; 616CFE792DBB02E100CAFCE6 /* ErrorTextSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorTextSheet.swift; sourceTree = ""; }; + 616EE8712E017AEC00524800 /* SystemLanguageModelService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemLanguageModelService.swift; sourceTree = ""; }; + 616EE8732E0181D700524800 /* SystemLanguageModelServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemLanguageModelServiceTests.swift; sourceTree = ""; }; 61702D082C9E7FA100FE7E35 /* GitRevert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitRevert.swift; sourceTree = ""; }; 61702D0A2C9E8A1600FE7E35 /* GitTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitTag.swift; sourceTree = ""; }; 61702D0C2C9E8BF900FE7E35 /* GitTagCreate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitTagCreate.swift; sourceTree = ""; }; @@ -230,9 +244,11 @@ 6185846C2DA562B10038EBBE /* RenameBranchSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameBranchSheet.swift; sourceTree = ""; }; 6186EBF82DA5ED4800DCC20E /* AmendCommitSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AmendCommitSheet.swift; sourceTree = ""; }; 618978A32DD55BE80013B21E /* CommitDiffView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommitDiffView.swift; sourceTree = ""; }; + 618AA52F2E50666800AEB995 /* CommitMessageGenerationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommitMessageGenerationView.swift; sourceTree = ""; }; 6193DDCC2DB8CA1400B156C4 /* CommitLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommitLogView.swift; sourceTree = ""; }; 6193DDCE2DB8E18300B156C4 /* FolderViewShowing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderViewShowing.swift; sourceTree = ""; }; 6193DDD02DB9B08200B156C4 /* GitRevListCount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitRevListCount.swift; sourceTree = ""; }; + 6195C6382E6662BE0003A676 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIcon.icon; sourceTree = ""; }; 619759452C89F1EC00E9CA4F /* GitRestorePatch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitRestorePatch.swift; sourceTree = ""; }; 619759472C8A756F00E9CA4F /* GitStatusShort.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitStatusShort.swift; sourceTree = ""; }; 619759492C8A76DA00E9CA4F /* Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Status.swift; sourceTree = ""; }; @@ -244,7 +260,6 @@ 619759552C8C9A9300E9CA4F /* GitDiffNumStatTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitDiffNumStatTests.swift; sourceTree = ""; }; 6199ED382DA73E8800D916EE /* CommitRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommitRowView.swift; sourceTree = ""; }; 619D876028F154AB00DD1D4E /* CreateNewBranchSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateNewBranchSheet.swift; sourceTree = ""; }; - 619DA6922CA62A1000E58DF9 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 61A0D0F72D8502CC005AF36C /* StageFileDiffsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StageFileDiffsView.swift; sourceTree = ""; }; 61A20B9C2DFD44FB00390F94 /* CommitGraph.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommitGraph.swift; sourceTree = ""; }; 61A2D35628DE9CCC009A3EEC /* GenericError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericError.swift; sourceTree = ""; }; @@ -253,7 +268,9 @@ 61A5DF4F2D9D5A3800FAF078 /* SearchToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchToken.swift; sourceTree = ""; }; 61A5DF512D9DFB0A00FAF078 /* SearchTokensHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTokensHandler.swift; sourceTree = ""; }; 61A5DF532D9DFD1B00FAF078 /* SearchTokensHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTokensHandlerTests.swift; sourceTree = ""; }; + 61A6F2F82E80BBFE0051A51E /* DiffSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiffSummaryView.swift; sourceTree = ""; }; 61AC65802DA9F82E00D80470 /* LogStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogStoreTests.swift; sourceTree = ""; }; + 61AF93492E08FAE700C656AE /* SearchArguments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchArguments.swift; sourceTree = ""; }; 61B3C55D2CB4A74700021B36 /* GitShow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitShow.swift; sourceTree = ""; }; 61B3C55F2CB4A9C500021B36 /* CommitDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommitDetail.swift; sourceTree = ""; }; 61B3C5612CB557CA00021B36 /* CommitDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommitDetailView.swift; sourceTree = ""; }; @@ -269,9 +286,7 @@ 61CAEDEB2DCEE266009AADD9 /* ExpandableModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandableModelTests.swift; sourceTree = ""; }; 61D140D62D640122007BD12D /* ChunkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChunkView.swift; sourceTree = ""; }; 61D2BC3C2C8D8BB10059317E /* SectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SectionHeader.swift; sourceTree = ""; }; - 61D34C7C2CA7A3FC0032A22A /* KeychainStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStorage.swift; sourceTree = ""; }; 61D34C812CA85DBF0032A22A /* EnviromentValues+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EnviromentValues+.swift"; sourceTree = ""; }; - 61D34C832CA8D0DD0032A22A /* AIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIService.swift; sourceTree = ""; }; 61D34C862CA91F5F0032A22A /* CommitMessageProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommitMessageProperties.swift; sourceTree = ""; }; 61D34C8A2CA943380032A22A /* StagingChangesProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StagingChangesProperties.swift; sourceTree = ""; }; 61D34C8C2CAC394A0032A22A /* GitLogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitLogTests.swift; sourceTree = ""; }; @@ -303,7 +318,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 61D34C802CA8548F0032A22A /* KeychainAccess in Frameworks */, 61DE74122CCFCEA70085E8A4 /* Sourceful in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -332,7 +346,7 @@ 61347EEB28D5D16C00625FC4 /* Products */ = { isa = PBXGroup; children = ( - 61347EEA28D5D16C00625FC4 /* Tempo.app */, + 61347EEA28D5D16C00625FC4 /* Changes.app */, 61347EFB28D5D16D00625FC4 /* GitClientTests.xctest */, ); name = Products; @@ -342,10 +356,12 @@ isa = PBXGroup; children = ( 61A2D35828DE9D79009A3EEC /* Extensions */, + 61D34C852CA91F400032A22A /* AIService */, 6172E4C728D70EEC0015C958 /* Models */, 6172E4C828D76BAB0015C958 /* Views */, 61347EED28D5D16C00625FC4 /* GitClientApp.swift */, 61347EF128D5D16D00625FC4 /* Assets.xcassets */, + 6195C6382E6662BE0003A676 /* AppIcon.icon */, 61347EF628D5D16D00625FC4 /* GitClient.entitlements */, 61347EF328D5D16D00625FC4 /* Preview Content */, ); @@ -375,6 +391,7 @@ 61AC65802DA9F82E00D80470 /* LogStoreTests.swift */, 61CAEDEB2DCEE266009AADD9 /* ExpandableModelTests.swift */, 614A22D82DFCFB0000A4B12C /* DefaultMergeCommitMessageTests.swift */, + 616EE8732E0181D700524800 /* SystemLanguageModelServiceTests.swift */, ); path = GitClientTests; sourceTree = ""; @@ -442,7 +459,6 @@ 6172E4C728D70EEC0015C958 /* Models */ = { isa = PBXGroup; children = ( - 61D34C852CA91F400032A22A /* AIService */, 61E290DC28E884C200BCEB04 /* Commands */, 613D140D2DA7B7B6008D561E /* Observables */, 6172E4C328D70B3C0015C958 /* Folder.swift */, @@ -461,13 +477,13 @@ 619759492C8A76DA00E9CA4F /* Status.swift */, 619759532C8C95F700E9CA4F /* DiffStat.swift */, 6130BDE72C95821C0050E70F /* Stash.swift */, - 61D34C7C2CA7A3FC0032A22A /* KeychainStorage.swift */, 616156262D637E630034B1F1 /* Language.swift */, 61CAE5692D899E7E003541ED /* ExpandableModel.swift */, 6193DDCE2DB8E18300B156C4 /* FolderViewShowing.swift */, 6154EEFD2DE5E9AC00103692 /* GitFetchExecutor.swift */, 6173BD112DFB915E00180F9C /* DefaultMergeCommitMessage.swift */, 61A20B9C2DFD44FB00390F94 /* CommitGraph.swift */, + 61AF93492E08FAE700C656AE /* SearchArguments.swift */, ); path = Models; sourceTree = ""; @@ -483,7 +499,7 @@ 61347EEF28D5D16C00625FC4 /* ContentView.swift */, 616CFE792DBB02E100CAFCE6 /* ErrorTextSheet.swift */, 6180C0A728E916B900B3AEAD /* BranchesView.swift */, - 619DA6922CA62A1000E58DF9 /* SettingsView.swift */, + 61A6F2F82E80BBFE0051A51E /* DiffSummaryView.swift */, ); path = Views; sourceTree = ""; @@ -526,7 +542,7 @@ 61D34C852CA91F400032A22A /* AIService */ = { isa = PBXGroup; children = ( - 61D34C832CA8D0DD0032A22A /* AIService.swift */, + 616EE8712E017AEC00524800 /* SystemLanguageModelService.swift */, 61D34C862CA91F5F0032A22A /* CommitMessageProperties.swift */, 61D34C8A2CA943380032A22A /* StagingChangesProperties.swift */, ); @@ -581,16 +597,22 @@ isa = PBXGroup; children = ( 61E290DA28E7C66200BCEB04 /* CommitCreateView.swift */, + 616A46AE2E6C15170072BBBC /* CommitMessageEditor.swift */, + 618AA52F2E50666800AEB995 /* CommitMessageGenerationView.swift */, + 61297DF62E7505D8003E4727 /* CommitMessageGenerationUnavailableView.swift */, + 61297DF42E75055D003E4727 /* CommitMessageGenerationContentView.swift */, 6197594B2C8BE95900E9CA4F /* StagedView.swift */, 612208A82C032A0D0047B454 /* UnstagedView.swift */, 61A0D0F72D8502CC005AF36C /* StageFileDiffsView.swift */, 61C03CDF2D86F022008186F8 /* StageFileDiffView.swift */, 61CA2AC82D69B7D0007B8E56 /* StageFileDiffHeaderView.swift */, 61D2BC3C2C8D8BB10059317E /* SectionHeader.swift */, - 6154D69B2C6739C700448AB4 /* CommitMessageSuggestionView.swift */, + 6154D69B2C6739C700448AB4 /* CommitMessageSnippetSuggestionView.swift */, 61B3C5632CB93CE500021B36 /* CommitDetailStackView.swift */, 61B3C5612CB557CA00021B36 /* CommitDetailView.swift */, 61760DA92D9378B60038EE57 /* CommitDetailContentView.swift */, + 611176E62E7B83C300A8E0A4 /* CommitDetailHeaderView.swift */, + 611176E42E7B81AE00A8E0A4 /* CommitDetailBottomBar.swift */, 61F5B7AE2CBBB066005C736E /* MergeCommitContentView.swift */, 613EB49C2DD999C100545729 /* DiffView.swift */, 613EB49A2DD9994100545729 /* DiffCommitListContentView.swift */, @@ -625,11 +647,10 @@ ); name = GitClient; packageProductDependencies = ( - 61D34C7F2CA8548F0032A22A /* KeychainAccess */, 61DE74112CCFCEA70085E8A4 /* Sourceful */, ); productName = GitClient; - productReference = 61347EEA28D5D16C00625FC4 /* Tempo.app */; + productReference = 61347EEA28D5D16C00625FC4 /* Changes.app */; productType = "com.apple.product-type.application"; }; 61347EFA28D5D16D00625FC4 /* GitClientTests */ = { @@ -679,7 +700,6 @@ ); mainGroup = 61347EE128D5D16C00625FC4; packageReferences = ( - 61D34C7E2CA8548F0032A22A /* XCRemoteSwiftPackageReference "KeychainAccess" */, 61CA2ACA2D69BAC5007B8E56 /* XCRemoteSwiftPackageReference "Sourceful" */, ); productRefGroup = 61347EEB28D5D16C00625FC4 /* Products */; @@ -698,6 +718,7 @@ buildActionMask = 2147483647; files = ( 61347EF528D5D16D00625FC4 /* Preview Assets.xcassets in Resources */, + 6195C6392E6662BE0003A676 /* AppIcon.icon in Resources */, 61347EF228D5D16D00625FC4 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -724,10 +745,13 @@ 61D34C8F2CAEBDC40032A22A /* LogStore.swift in Sources */, 61E290C528E095AE00BCEB04 /* Process+Run.swift in Sources */, 619759482C8A756F00E9CA4F /* GitStatusShort.swift in Sources */, + 611176E72E7B83C300A8E0A4 /* CommitDetailHeaderView.swift in Sources */, 619D876128F154AB00DD1D4E /* CreateNewBranchSheet.swift in Sources */, 6154EEFA2DE324C100103692 /* Icon.swift in Sources */, 61C9A16E2C835167001A8903 /* GitRestore.swift in Sources */, 61702D112C9E8D5F00FE7E35 /* TagsView.swift in Sources */, + 611176E52E7B81AE00A8E0A4 /* CommitDetailBottomBar.swift in Sources */, + 616A46AF2E6C15170072BBBC /* CommitMessageEditor.swift in Sources */, 612919F72CCD16090079FD0B /* URL+.swift in Sources */, 61FAF4942C6835EC000B7ACB /* CommitMessageSnippetView.swift in Sources */, 616C6FC62C96E3D100A419DE /* StashChangedDetailContentView.swift in Sources */, @@ -737,13 +761,12 @@ 61D34C8B2CA943540032A22A /* StagingChangesProperties.swift in Sources */, 6173BD122DFB915E00180F9C /* DefaultMergeCommitMessage.swift in Sources */, 61E290D328E1EFC600BCEB04 /* GitDiff.swift in Sources */, - 61D34C842CA8D0E30032A22A /* AIService.swift in Sources */, 61F5B7AF2CBBB066005C736E /* MergeCommitContentView.swift in Sources */, + 61297DF72E7505D8003E4727 /* CommitMessageGenerationUnavailableView.swift in Sources */, 6197594E2C8C25EA00E9CA4F /* GitAddPathspec.swift in Sources */, 613EB49D2DD999C100545729 /* DiffView.swift in Sources */, 616C6FC02C96BF9F00A419DE /* GitStash.swift in Sources */, 61CA2AC92D69B7D0007B8E56 /* StageFileDiffHeaderView.swift in Sources */, - 619DA6932CA62A1000E58DF9 /* SettingsView.swift in Sources */, 61E290C328DFE29500BCEB04 /* FolderView.swift in Sources */, 61FAF4962C684569000B7ACB /* Notification+.swift in Sources */, 61702D0F2C9E8CAF00FE7E35 /* GitTagDelete.swift in Sources */, @@ -772,7 +795,6 @@ 61C03CE22D86F268008186F8 /* FileDiffView.swift in Sources */, 61A2D35D28DF5ACB009A3EEC /* View+.swift in Sources */, 616C6FCC2C97C3CC00A419DE /* GitStashApply.swift in Sources */, - 61D34C7D2CA7A4080032A22A /* KeychainStorage.swift in Sources */, 61702D0D2C9E8BFF00FE7E35 /* GitTagCreate.swift in Sources */, 61B3C55E2CB4A74C00021B36 /* GitShow.swift in Sources */, 618978A42DD55BE80013B21E /* CommitDiffView.swift in Sources */, @@ -782,12 +804,14 @@ 6193DDD12DB9B08A00B156C4 /* GitRevListCount.swift in Sources */, 61FAF49E2C68DBFB000B7ACB /* GitCommitAmend.swift in Sources */, 61B3C5642CB93CE500021B36 /* CommitDetailStackView.swift in Sources */, + 61AF934A2E08FAE700C656AE /* SearchArguments.swift in Sources */, 61EBD7D328E966190009ED92 /* GitSwitch.swift in Sources */, 61EBD7D128E940C30009ED92 /* Branch.swift in Sources */, 61D34C822CA85DC70032A22A /* EnviromentValues+.swift in Sources */, 61E290C928E098DB00BCEB04 /* GitLog.swift in Sources */, 610134C12C71D5860071677C /* GitFetch.swift in Sources */, 61A0D0F82D8502CC005AF36C /* StageFileDiffsView.swift in Sources */, + 61A6F2F92E80BBFE0051A51E /* DiffSummaryView.swift in Sources */, 61347EEE28D5D16C00625FC4 /* GitClientApp.swift in Sources */, 61E290DB28E7C66300BCEB04 /* CommitCreateView.swift in Sources */, 61B3C5602CB4A9CE00021B36 /* CommitDetail.swift in Sources */, @@ -795,13 +819,14 @@ 613EB4972DD995DB00545729 /* DiffTabView.swift in Sources */, 61760DAA2D9378B60038EE57 /* CommitDetailContentView.swift in Sources */, 61CAE56A2D899E7E003541ED /* ExpandableModel.swift in Sources */, - 6154D69C2C6739C700448AB4 /* CommitMessageSuggestionView.swift in Sources */, + 6154D69C2C6739C700448AB4 /* CommitMessageSnippetSuggestionView.swift in Sources */, 616C4C1E28E8894D000E7154 /* GitAdd.swift in Sources */, 61A5DF502D9D5A3800FAF078 /* SearchToken.swift in Sources */, 619759542C8C95F700E9CA4F /* DiffStat.swift in Sources */, 613EB4992DD9973700545729 /* DiffCommitListView.swift in Sources */, 61D140D72D640122007BD12D /* ChunkView.swift in Sources */, 612659BE2DA8911900F01F2C /* CommitGraphView.swift in Sources */, + 61297DF52E75055D003E4727 /* CommitMessageGenerationContentView.swift in Sources */, 61EBD7CF28E922510009ED92 /* GitBranch.swift in Sources */, 61FAF49C2C688181000B7ACB /* WindowID.swift in Sources */, 6199ED392DA73E8800D916EE /* CommitRowView.swift in Sources */, @@ -827,6 +852,8 @@ 616156272D637E6A0034B1F1 /* Language.swift in Sources */, 619759462C89F1EC00E9CA4F /* GitRestorePatch.swift in Sources */, 61B7F0492C5F3EBF00BFAA34 /* FileDiffsView.swift in Sources */, + 616EE8722E017B0B00524800 /* SystemLanguageModelService.swift in Sources */, + 618AA5302E50666800AEB995 /* CommitMessageGenerationView.swift in Sources */, 61702D162C9EE94600FE7E35 /* CreateNewTagSheet.swift in Sources */, 6186EBF92DA5ED4800DCC20E /* AmendCommitSheet.swift in Sources */, 6193DDCF2DB8E18300B156C4 /* FolderViewShowing.swift in Sources */, @@ -844,6 +871,7 @@ buildActionMask = 2147483647; files = ( 61AC65812DA9F83700D80470 /* LogStoreTests.swift in Sources */, + 616EE8742E0181D700524800 /* SystemLanguageModelServiceTests.swift in Sources */, 61347F0028D5D16D00625FC4 /* DiffTests.swift in Sources */, 6131AC872DF268BF00DFBCB5 /* GitStatusTests.swift in Sources */, 61094D532DFA4E7C00D7B8AA /* GitRevParseTests.swift in Sources */, @@ -1010,10 +1038,10 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 1.18; - PRODUCT_BUNDLE_IDENTIFIER = dev.aoyama.tempo; - PRODUCT_NAME = Tempo; + MACOSX_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 2.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.aoyama.changes; + PRODUCT_NAME = Changes; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 6.0; @@ -1043,10 +1071,10 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 1.18; - PRODUCT_BUNDLE_IDENTIFIER = dev.aoyama.tempo; - PRODUCT_NAME = Tempo; + MACOSX_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 2.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.aoyama.changes; + PRODUCT_NAME = Changes; PROVISIONING_PROFILE_SPECIFIER = ""; "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = ""; SWIFT_EMIT_LOC_STRINGS = YES; @@ -1063,13 +1091,13 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = JRA6VW2DG4; GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 14.6; + MACOSX_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = dev.aoyama.GitClientTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 6.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Tempo.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Tempo"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Changes.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Changes"; }; name = Debug; }; @@ -1082,13 +1110,13 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = JRA6VW2DG4; GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 14.6; + MACOSX_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = dev.aoyama.GitClientTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 6.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Tempo.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Tempo"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Changes.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Changes"; }; name = Release; }; @@ -1133,22 +1161,9 @@ minimumVersion = 1.1.0; }; }; - 61D34C7E2CA8548F0032A22A /* XCRemoteSwiftPackageReference "KeychainAccess" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/kishikawakatsumi/KeychainAccess"; - requirement = { - kind = exactVersion; - version = 4.2.2; - }; - }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 61D34C7F2CA8548F0032A22A /* KeychainAccess */ = { - isa = XCSwiftPackageProductDependency; - package = 61D34C7E2CA8548F0032A22A /* XCRemoteSwiftPackageReference "KeychainAccess" */; - productName = KeychainAccess; - }; 61DE74112CCFCEA70085E8A4 /* Sourceful */ = { isa = XCSwiftPackageProductDependency; productName = Sourceful; diff --git a/GitClient.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/GitClient.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index bf3a24e6..6961b2dc 100644 --- a/GitClient.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/GitClient.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,15 +1,6 @@ { - "originHash" : "e1dd0d2dea9847dcb776e4a5120c89390c001d0553606690bb57dbc7e196d5de", + "originHash" : "a75221a525042d173da23f8972ebc1eda34faf7f591677c94e6eb63fca4af624", "pins" : [ - { - "identity" : "keychainaccess", - "kind" : "remoteSourceControl", - "location" : "https://github.com/kishikawakatsumi/KeychainAccess", - "state" : { - "revision" : "84e546727d66f1adc5439debad16270d0fdd04e7", - "version" : "4.2.2" - } - }, { "identity" : "sourceful", "kind" : "remoteSourceControl", diff --git a/GitClient.xcodeproj/xcshareddata/xcschemes/GitClient.xcscheme b/GitClient.xcodeproj/xcshareddata/xcschemes/GitClient.xcscheme index 908a19f6..7591dab7 100644 --- a/GitClient.xcodeproj/xcshareddata/xcschemes/GitClient.xcscheme +++ b/GitClient.xcodeproj/xcshareddata/xcschemes/GitClient.xcscheme @@ -15,7 +15,7 @@ @@ -73,7 +73,7 @@ @@ -90,7 +90,7 @@ diff --git a/GitClient.xctestplan b/GitClient.xctestplan index 352d5c0d..d2272d97 100644 --- a/GitClient.xctestplan +++ b/GitClient.xctestplan @@ -24,6 +24,13 @@ "testTargets" : [ { "parallelizable" : false, + "skippedTests" : { + "suites" : [ + { + "name" : "SystemLanguageModelServiceTests" + } + ] + }, "target" : { "containerPath" : "container:GitClient.xcodeproj", "identifier" : "61347EFA28D5D16D00625FC4", diff --git a/GitClient/Models/AIService/CommitMessageProperties.swift b/GitClient/AIService/CommitMessageProperties.swift similarity index 100% rename from GitClient/Models/AIService/CommitMessageProperties.swift rename to GitClient/AIService/CommitMessageProperties.swift diff --git a/GitClient/Models/AIService/StagingChangesProperties.swift b/GitClient/AIService/StagingChangesProperties.swift similarity index 100% rename from GitClient/Models/AIService/StagingChangesProperties.swift rename to GitClient/AIService/StagingChangesProperties.swift diff --git a/GitClient/AIService/SystemLanguageModelService.swift b/GitClient/AIService/SystemLanguageModelService.swift new file mode 100644 index 00000000..603d0e9b --- /dev/null +++ b/GitClient/AIService/SystemLanguageModelService.swift @@ -0,0 +1,242 @@ +// +// SystemLanguageModelService.swift +// GitClient +// +// Created by Makoto Aoyama on 2025/06/17. +// + +import Foundation +import FoundationModels + +@Generable +struct GeneratedCommitMessage { + @Guide(description: "The commit message") + var commitMessage: String +} + +@Generable +struct GeneratedDiffSummary { + @Guide(description: "The summary of diff") + var summary: String +} + +@Generable +struct GeneratedStagingChanges { + @Guide(description: "The hunk to stage list") + var hunksToStage: [Bool] +} + +@Generable +struct GeneratedCommitHashes { + @Guide(description: "The commit hashes") + var commitHashes: [String] +} + +struct SystemLanguageModelService { + var availability: SystemLanguageModel.Availability { + SystemLanguageModel.default.availability + } + + func commitMessage(stagedDiff: String) -> LanguageModelSession.ResponseStream { + let instructions = """ +You are a good software engineer. When writing a commit message, it is not the initial commit. +The output format of git diff is as follows: +``` +diff --git a/filename b/filename +index abc1234..def5678 100644 +--- a/filename ++++ b/filename +@@ -start,count +start,count @@ optional context or function name +- line that was removed ++ line that was added + unchanged line (context) +``` +""" + let prompt = "Generate a commit message in the imperative mood for the following changes: \(stagedDiff)" + let session = LanguageModelSession(instructions: instructions) + return session.streamResponse(to: prompt, generating: GeneratedCommitMessage.self) + } + + func diffSummary( + _ diff: String, + language: String=Locale.preferredLanguages.first ?? "en" + ) -> LanguageModelSession.ResponseStream { + let instructions = """ +You are a good software engineer. +The output format of git diff is as follows: +``` +diff --git a/filename b/filename +index abc1234..def5678 100644 +--- a/filename ++++ b/filename +@@ -start,count +start,count @@ optional context or function name +- line that was removed ++ line that was added + unchanged line (context) +``` +""" + let prompt = "Generate a concise summary for the following changes in 200 characters or less in language \(language): \(diff)" + let session = LanguageModelSession(instructions: instructions) + return session.streamResponse(to: prompt, generating: GeneratedDiffSummary.self) + } + + /// Prefer commitMessage(stagedDiff: String) + /// Using the tool didn’t particularly improve accuracy. I thought it would at least help organize the input information, though... + func commitMessage(tools: [any Tool]) async throws -> String { + let instructions = """ +You are a good software engineer. When creating a commit message, it is not the initial commit. + +The output format of git diff is as follows: +``` +diff --git a/filename b/filename +index abc1234..def5678 100644 +--- a/filename ++++ b/filename +@@ -start,count +start,count @@ optional context or function name +- line that was removed ++ line that was added + unchanged line (context) +``` +""" + let prompt = "Please provide an appropriate commit message for staged changes" + let session = LanguageModelSession(tools: tools, instructions: instructions) + return try await session.respond(to: prompt, generating: GeneratedCommitMessage.self).content.commitMessage + } + + /// beta + func stagingChanges(unstagedDiff: String) async throws -> [Bool] { + let instructions = """ +You are a good software engineer. A hunk starts from @@ -start,count +start,count @@. +""" + let prompt = "Please indicate which hunks should be committed by answering with booleans so that the response can be used as input for git add -p.: \(unstagedDiff)" + let session = LanguageModelSession(instructions: instructions) + return try await session.respond(to: prompt, generating: GeneratedStagingChanges.self, options: .init(temperature: 1.0)).content.hunksToStage + } + + /// beta + func stagingChanges(tools: [any Tool]) async throws -> [Bool] { + let instructions = """ +You are a good software engineer. A hunk starts from @@ -start,count +start,count @@. +""" + let prompt = "Please indicate which unstaged changes should be committed by answering with booleans" + let session = LanguageModelSession(tools: tools, instructions: instructions) + return try await session.respond(to: prompt, generating: GeneratedStagingChanges.self, options: .init(temperature: 1.0)).content.hunksToStage + } + + func commitHashes(_ searchArgment: SearchArguments, prompt: [String], directory: URL) async throws -> [String] { + let instructions = """ + You are a good software engineer. + """ + let prompt = "Please provide the commit hashes using git log for the following: \(prompt.joined(separator: "\n"))" + let session = LanguageModelSession(tools: [GitLogTool(directory: directory, searchArguments: searchArgment)], instructions: instructions) + return try await session.respond(to: prompt, generating: GeneratedCommitHashes.self).content.commitHashes + } +} + +@Generable +struct UncommitedChanges { + @Guide(description: "The staged changes") + var stagedChanges: [String] + @Guide(description: "The unstaged changes") + var unstagedChanges: [String] +} + +struct UncommitedChangesTool: Tool { + @Generable + struct Arguments {} + + let name = "uncommitedChanges" + let description: String = "Get a uncommitted changes" + let directory: URL + + func call(arguments: Arguments) async throws -> some PromptRepresentable { + let gitDiff = try await Process.output(GitDiff(directory: directory)) + let gitDiffCached = try await Process.output(GitDiffCached(directory: directory)) + let diff = try Diff(raw: gitDiff).fileDiffs.map { $0.raw } + let cachedDiff = try Diff(raw: gitDiffCached).fileDiffs.map { $0.raw } + return UncommitedChanges(stagedChanges: cachedDiff, unstagedChanges: diff) + } +} + +struct StagedChangesTool: Tool { + @Generable + struct Arguments {} + + let name = "stagedChanges" + let description: String = "Get staged changes" + let directory: URL + + func call(arguments: Arguments) async throws -> some PromptRepresentable { + let gitDiffCached = try await Process.output(GitDiffCached(directory: directory)) + let cachedDiff = try Diff(raw: gitDiffCached).fileDiffs.map { $0.raw } + return cachedDiff + } +} + +struct UnstagedChangesTool: Tool { + @Generable + struct Arguments {} + + let name = "unstagedChanges" + let description: String = "Get unstaged changes" + let directory: URL + + func call(arguments: Arguments) async throws -> some PromptRepresentable { + let gitDiff = try await Process.output(GitDiff(directory: directory)) + let diff = try Diff(raw: gitDiff).fileDiffs.map { $0.raw } + return diff + } +} + +struct GitLogTool: Tool { + @Generable + struct Arguments { + @Guide(description: "Limit the number of commits to output", .range(1...100)) + var number: Int + @Guide(description: "Skip number commits before starting to show the commit output") + var skip: Int + } + + @Generable + struct GenerableCommit { + init(_ commit: Commit) { + self.hash = commit.hash + self.parentHashes = commit.parentHashes + self.author = commit.author + self.authorEmail = commit.authorEmail + self.authorDate = commit.authorDate + self.title = commit.title + self.body = commit.body + self.branches = commit.branches + self.tags = commit.tags + } + @Guide(description: "The commit hash") + var hash: String + var parentHashes: [String] + var author: String + var authorEmail: String + @Guide(description: "The date the commit was created") + var authorDate: String + @Guide(description: "The commit message title") + var title: String + @Guide(description: "The commit message body") + var body: String + @Guide(description: "The branches referencing this commit") + var branches: [String] + @Guide(description: "The tags referencing this commit") + var tags: [String] + } + + let name = "gitLog" + let description: String = "Get git commits" + var directory: URL + var searchArguments: SearchArguments + + func call(arguments: Arguments) async throws -> some PromptRepresentable { + let logs = try await Process.output( + GitLog(directory: directory, number: arguments.number, skip: arguments.skip, grep: searchArguments.grep, grepAllMatch: searchArguments.grepAllMatch, s: searchArguments.s, g: searchArguments.g, authors: searchArguments.authors, revisionRange: searchArguments.revisionRange, paths: searchArguments.paths) + ) + let commits = logs.map { GenerableCommit($0) } + return commits + } +} diff --git a/GitClient/AppIcon.icon/Assets/Oval.svg b/GitClient/AppIcon.icon/Assets/Oval.svg new file mode 100644 index 00000000..1ed97842 --- /dev/null +++ b/GitClient/AppIcon.icon/Assets/Oval.svg @@ -0,0 +1,17 @@ + + + Oval + + + + + + + + + + + + + + \ No newline at end of file diff --git a/GitClient/AppIcon.icon/Assets/Oval2.svg b/GitClient/AppIcon.icon/Assets/Oval2.svg new file mode 100644 index 00000000..b5224e57 --- /dev/null +++ b/GitClient/AppIcon.icon/Assets/Oval2.svg @@ -0,0 +1,7 @@ + + + Oval2 + + + + \ No newline at end of file diff --git a/GitClient/AppIcon.icon/icon.json b/GitClient/AppIcon.icon/icon.json new file mode 100644 index 00000000..56426f58 --- /dev/null +++ b/GitClient/AppIcon.icon/icon.json @@ -0,0 +1,65 @@ +{ + "fill" : { + "automatic-gradient" : "extended-gray:1.00000,1.00000" + }, + "groups" : [ + { + "blur-material" : null, + "hidden" : false, + "layers" : [ + { + "blend-mode" : "normal", + "fill" : { + "automatic-gradient" : "extended-srgb:0.00000,0.53333,1.00000,1.00000" + }, + "glass" : true, + "hidden" : false, + "image-name" : "Oval.svg", + "name" : "Oval", + "opacity" : 0.85, + "position" : { + "scale" : 1, + "translation-in-points" : [ + 0, + -97.828125 + ] + } + }, + { + "blend-mode" : "normal", + "fill" : { + "automatic-gradient" : "srgb:0.46202,0.83828,1.00000,1.00000" + }, + "glass" : true, + "hidden" : false, + "image-name" : "Oval2.svg", + "name" : "Oval2", + "opacity" : 0.85, + "position" : { + "scale" : 1, + "translation-in-points" : [ + -4.828125, + 96.2 + ] + } + } + ], + "lighting" : "individual", + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "specular" : true, + "translucency" : { + "enabled" : false, + "value" : 0.1 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/GitClient/Assets.xcassets/AppIcon.appiconset/Contents.json b/GitClient/Assets.xcassets/AppIcon.appiconset/Contents.json index d6ba337c..3f00db43 100644 --- a/GitClient/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/GitClient/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -46,7 +46,6 @@ "size" : "512x512" }, { - "filename" : "icon1024.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" diff --git a/GitClient/Assets.xcassets/AppIcon.appiconset/icon1024.png b/GitClient/Assets.xcassets/AppIcon.appiconset/icon1024.png deleted file mode 100644 index eda1bba1..00000000 Binary files a/GitClient/Assets.xcassets/AppIcon.appiconset/icon1024.png and /dev/null differ diff --git a/GitClient/GitClientApp.swift b/GitClient/GitClientApp.swift index 6e11f299..97f78d45 100644 --- a/GitClient/GitClientApp.swift +++ b/GitClient/GitClientApp.swift @@ -9,17 +9,14 @@ import SwiftUI @main struct GitClientApp: App { - @StateObject var keychainStorage = KeychainStorage() - @State var expandAllFiles: UUID? - @State var collapseAllFiles: UUID? + @State private var expandAllFiles: UUID? + @State private var collapseAllFiles: UUID? var body: some Scene { WindowGroup { ContentView() - .environment(\.openAIAPISecretKey, keychainStorage.openAIAPISecretKey) .environment(\.expandAllFiles, expandAllFiles) .environment(\.collapseAllFiles, collapseAllFiles) - .errorSheet($keychainStorage.error) } .commands { CommandGroup(before: .toolbar) { @@ -34,10 +31,7 @@ struct GitClientApp: App { Divider() } } - Settings { - SettingsView(openAIAPISecretKey: $keychainStorage.openAIAPISecretKey) - .environment(\.openAIAPISecretKey, keychainStorage.openAIAPISecretKey) - } + Window("Commit Message Snippets", id: WindowID.commitMessageSnippets.rawValue) { CommitMessageSnippetView() } diff --git a/GitClient/Models/AIService/AIService.swift b/GitClient/Models/AIService/AIService.swift deleted file mode 100644 index 30eb3d41..00000000 --- a/GitClient/Models/AIService/AIService.swift +++ /dev/null @@ -1,139 +0,0 @@ -// -// AIService.swift -// GitClient -// -// Created by Makoto Aoyama on 2024/09/29. -// - -import Foundation -import os - -struct AIService { - private struct Schema: Codable { - var type = "object" - var properties: T - var required: [String] - var additionalProperties = false - } - private struct JSONSchema: Codable { - var name: String - var schema: Schema - var strict = true - } - private struct ResponseFormat: Codable { - var type = "json_schema" - var jsonSchema: JSONSchema - - enum CodingKeys: String, CodingKey { - case type - case jsonSchema = "json_schema" - } - } - private struct Message: Codable { - var role: String - var content: String - } - private struct RequestBody: Codable { - var model = "gpt-4o-mini" - var messages: [Message] - var responseFormat: ResponseFormat - - enum CodingKeys: String, CodingKey { - case model - case messages - case responseFormat = "response_format" - } - } - private struct Choice: Codable, CustomStringConvertible { - var description: String { "Choice(message: \(message)"} - - struct Message: Codable { - var content: String - var refusal: String? - } - var message: Message - } - private struct Response: Codable, CustomStringConvertible { - var description: String { - "Response(choices: \(choices)" - } - - var choices: [Choice] - } - - var bearer: String - private let endpoint = URL(string: "https://api.openai.com/v1/chat/completions")! - private var jsonEncoder: JSONEncoder { - let jsonEncoder = JSONEncoder() - jsonEncoder.outputFormatting = .prettyPrinted - return jsonEncoder - } - - private func callEndpint(requestBody: RequestBody) async throws -> DecodeType { - let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "AIService") - var request = URLRequest(url: endpoint) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("Bearer \(bearer)", forHTTPHeaderField: "Authorization") - let bodyData = try jsonEncoder.encode(requestBody) - request.httpBody = bodyData - if let jsonString = String(data: bodyData, encoding: .utf8) { - logger.debug("Body data: \(jsonString, privacy: .public)") - } - let data = try await URLSession.shared.data(for: request) - let response = try JSONDecoder().decode(Response.self, from: data.0) - logger.debug("Response: \(response, privacy: .public)") - guard response.choices.count > 0 else { - throw GenericError(errorDescription: "OpenAI API response error") - } - if let refusal = response.choices[0].message.refusal, !refusal.isEmpty { - throw GenericError(errorDescription: "OpenAI API refusal error: " + refusal) - } - guard let contentData = response.choices[0].message.content.data(using: .utf8) else { - throw GenericError(errorDescription: "API Response handling error") - } - return try JSONDecoder().decode(DecodeType.self, from: contentData) - } - - func commitMessage(stagedDiff: String) async throws -> String { - let body = RequestBody( - messages: [ - .init(role: "system", content: "You are a good software engineer. Tell me commit message of these changes for git."), - .init(role: "user", content: stagedDiff) - ], - responseFormat: .init( - jsonSchema: .init( - name: "generated_git_commit_message", - schema: Schema(properties: CommitMessageProperties(), required: ["commitMessage"]) - ) - ) - ) - let message: GeneratedCommiMessage = try await callEndpint(requestBody: body) - return message.commitMessage - } - - func stagingChanges(stagedDiff: String, notStagedDiff: String, untrackedFiles: [String]) async throws -> StagingChanges { - let body = RequestBody( - messages: [ - .init(role: "system", content:""" -You are a good software engineer. -The first message is the diff that has already been staged. The second message is the unstaged diff. The third message consists of untracked files, separated by new lines. Please advise on what changes should be committed next. It's fine if you think it is appropriate to commit everything together. - -For the unstaged diff, please indicate which hunks should be committed by answering with booleans so that the response can be used as input for git add -p. For the untracked files, please also answer with booleans for each file. - -Additionally, please provide a good commit message for committing the changes that should be staged. -"""), - .init(role: "user", content: stagedDiff), - .init(role: "user", content: notStagedDiff), - .init(role: "user", content: untrackedFiles.joined(separator: "\n")) - ], - responseFormat: .init( - jsonSchema: .init( - name: "stage_changes", - schema: Schema(properties: StagingChangesProperties(), required: ["hunksToStage", "filesToStage", "commitMessage"]) - ) - ) - ) - return try await callEndpint(requestBody: body) - } -} diff --git a/GitClient/Models/Commands/GitLog.swift b/GitClient/Models/Commands/GitLog.swift index f484a49a..b4084217 100644 --- a/GitClient/Models/Commands/GitLog.swift +++ b/GitClient/Models/Commands/GitLog.swift @@ -40,9 +40,6 @@ struct GitLog: Git { if skip > 0 { args.append("--skip=\(skip)") } - if !revisionRange.isEmpty { - args.append(revisionRange) - } args = args + grep.map { "--grep=\($0)" } if grepAllMatch { args.append("--all-match") @@ -57,6 +54,14 @@ struct GitLog: Git { args.append(g) } args = args + authors.map { "--author=\($0)" } + if noWalk { + args.append("--no-walk") + } + + if !revisionRange.isEmpty { + args = args + revisionRange + } + if !paths.isEmpty { args.append("--") args = args + paths @@ -69,14 +74,15 @@ struct GitLog: Git { var reverse = false var number = 0 var skip = 0 - var revisionRange = "" var grep: [String] = [] var grepAllMatch = false var s = "" var g = "" var authors:[String] = [] + var noWalk = false + var revisionRange: [String] = [] var paths: [String] = [] - + func parse(for stdOut: String) throws -> [Commit] { guard !stdOut.isEmpty else { return [] } let dropped = stdOut.dropLast(String.componentSeparator.count) diff --git a/GitClient/Models/KeychainStorage.swift b/GitClient/Models/KeychainStorage.swift deleted file mode 100644 index 5721b7a8..00000000 --- a/GitClient/Models/KeychainStorage.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// KeychainStorage.swift -// GitClient -// -// Created by Makoto Aoyama on 2024/09/28. -// - -import Foundation -import KeychainAccess - -final class KeychainStorage: ObservableObject { - @Published var openAIAPISecretKey: String { - didSet { - do { - try db.label("OpenAI API Secret Key for Tempo.app").comment("The secret key used for AI-powered staging and commit message generation.").set(openAIAPISecretKey, key: key) - } catch { - self.error = error - } - } - } - @Published var error: Error? - private let db = Keychain(service: Bundle.main.bundleIdentifier! + ".openai-api-secret-key") - private let key = "OpenAIAPISecretKey" - - init() { - do { - let secretKey = try db.get(key) - self.openAIAPISecretKey = secretKey ?? "" - } catch { - self.openAIAPISecretKey = "" - self.error = error - } - } -} diff --git a/GitClient/Models/Observables/LogStore.swift b/GitClient/Models/Observables/LogStore.swift index 6a23f283..8ebed5f3 100644 --- a/GitClient/Models/Observables/LogStore.swift +++ b/GitClient/Models/Observables/LogStore.swift @@ -34,13 +34,27 @@ import Observation private var authors: [String] { searchTokens.filter { $0.kind == .author }.map { $0.text } } - private var searchTokenRevisionRange: String { - searchTokens.filter { $0.kind == .revisionRange }.map { $0.text }.first ?? "" + private var searchTokenRevisionRange: [String] { + searchTokens.filter { $0.kind == .revisionRange }.map { $0.text } } private var paths: [String] { searchTokens.filter { $0.kind == .path }.map { $0.text } } - +// private var promptForAI: [String] { +// searchTokens.filter { $0.kind == .ai }.map { $0.text } +// } + private var searchArgments: SearchArguments { + .init( + revisionRange: searchTokenRevisionRange, + grep: grep, + grepAllMatch: grepAllMatch, + s: s, + g: g, + authors: authors, + paths: paths + ) + } + private var commitHashesByAI: [String] = [] var searchTokens: [SearchToken] = [] var commits: [Commit] = [] var notCommitted: NotCommitted? @@ -52,18 +66,28 @@ import Observation var error: Error? private func gitLog(directory: URL, number: Int=0, skip: Int=0) -> GitLog { - GitLog( - directory: directory, - number: number, - skip: skip, - revisionRange: searchTokenRevisionRange, - grep: grep, - grepAllMatch: grepAllMatch, - s: s, - g: g, - authors: authors, - paths: paths - ) + if commitHashesByAI.isEmpty { + return GitLog( + directory: directory, + number: number, + skip: skip, + grep: grep, + grepAllMatch: grepAllMatch, + s: s, + g: g, + authors: authors, + revisionRange: searchTokenRevisionRange, + paths: paths + ) + } else { + return GitLog( + directory: directory, + number: number, + skip: skip, + noWalk: true, + revisionRange: commitHashesByAI + ) + } } func logs() -> [Log] { @@ -79,10 +103,17 @@ import Observation guard let directory else { notCommitted = nil commits = [] + commitHashesByAI = [] return } do { + commitHashesByAI = [] notCommitted = try await notCommitted(directory: directory) +// if !promptForAI.isEmpty { +// if #available(macOS 26.0, *) { +// commitHashesByAI = try await SystemLanguageModelService().commitHashes(searchArgments, prompt: promptForAI , directory: directory) +// } +// } commits = try await Process.output(gitLog(directory: directory, number: number)) try await loadTotalCommitsCount() } catch { diff --git a/GitClient/Models/Observables/SyncState.swift b/GitClient/Models/Observables/SyncState.swift index 7759eefa..5b5fe9b9 100644 --- a/GitClient/Models/Observables/SyncState.swift +++ b/GitClient/Models/Observables/SyncState.swift @@ -29,7 +29,7 @@ import Observation shouldPush = true return } - shouldPull = !(try await Process.output(GitLog(directory: folderURL, revisionRange: "\(branch.name)..origin/\(branch.name)")).isEmpty) - shouldPush = !(try await Process.output(GitLog(directory: folderURL, revisionRange: "origin/\(branch.name)..\(branch.name)")).isEmpty) + shouldPull = !(try await Process.output(GitLog(directory: folderURL, revisionRange: ["\(branch.name)..origin/\(branch.name)"])).isEmpty) + shouldPush = !(try await Process.output(GitLog(directory: folderURL, revisionRange: ["origin/\(branch.name)..\(branch.name)"])).isEmpty) } } diff --git a/GitClient/Models/SearchArguments.swift b/GitClient/Models/SearchArguments.swift new file mode 100644 index 00000000..42aeee15 --- /dev/null +++ b/GitClient/Models/SearchArguments.swift @@ -0,0 +1,18 @@ +// +// SearchArguments.swift +// GitClient +// +// Created by Makoto Aoyama on 2025/06/23. +// + +import Foundation + +struct SearchArguments { + var revisionRange:[String] = [] + var grep: [String] = [] + var grepAllMatch = false + var s = "" + var g = "" + var authors:[String] = [] + var paths: [String] = [] +} diff --git a/GitClient/Models/SearchKind.swift b/GitClient/Models/SearchKind.swift index 0b6a5651..7c5fef03 100644 --- a/GitClient/Models/SearchKind.swift +++ b/GitClient/Models/SearchKind.swift @@ -8,7 +8,7 @@ import Foundation enum SearchKind: Codable, CaseIterable { - case grep, grepAllMatch, g, s, author, revisionRange, path + case grep, grepAllMatch, g, s, author, revisionRange, path //, ai var label: String { switch self { @@ -26,6 +26,8 @@ enum SearchKind: Codable, CaseIterable { return "Revision Range" case .path: return "Path" +// case .ai: +// return "AI" } } @@ -45,6 +47,8 @@ enum SearchKind: Codable, CaseIterable { return "Revision Range" case .path: return "Path" +// case .ai: +// return "AI" } } @@ -64,6 +68,8 @@ enum SearchKind: Codable, CaseIterable { return "Search commits within the revision range specified by Git syntax. e.g., main.., v1.0.0...v2.0.0" case .path: return "Search commits that modify the specified file or directory path." +// case .ai: +// return "Search commits based on natural language input, powered by AI." } } } diff --git a/GitClient/Models/SearchTokensHandler.swift b/GitClient/Models/SearchTokensHandler.swift index 971850f9..e707b338 100644 --- a/GitClient/Models/SearchTokensHandler.swift +++ b/GitClient/Models/SearchTokensHandler.swift @@ -54,17 +54,10 @@ struct SearchTokensHandler { return true } } - case .revisionRange: - return newTokens.filter { token in - switch token.kind { - case .revisionRange: - return token == newToken - default: - return true - } - } - case .author, .path: + case .revisionRange, .author, .path: return newTokens +// case .ai: +// return newTokens } } else { return newTokens diff --git a/GitClient/Views/Commit/CommitCreateView.swift b/GitClient/Views/Commit/CommitCreateView.swift index 7506626f..a522d903 100644 --- a/GitClient/Views/Commit/CommitCreateView.swift +++ b/GitClient/Views/Commit/CommitCreateView.swift @@ -6,22 +6,15 @@ // import SwiftUI +import os struct CommitCreateView: View { - @Environment(\.openAIAPISecretKey) var openAIAPISecretKey: String @Environment(\.openSettings) var openSettings: OpenSettingsAction @Environment(\.appearsActive) private var appearsActive var folder: Folder @State private var cachedDiffShortStat = "" @State private var diffShortStat = "" - private var stagedHeaderCaption: String { - if cachedDiffShortStat.isEmpty { - return " No Changes" - } else { - return cachedDiffShortStat - } - } private var notStagedHeaderCaption: String { if let untrackedStat = status?.untrackedFilesShortStat, !untrackedStat.isEmpty { if diffShortStat.isEmpty { @@ -30,11 +23,7 @@ struct CommitCreateView: View { return diffShortStat + ", " + untrackedStat } } - if diffShortStat.isEmpty { - return " No Changes" - } else { - return diffShortStat - } + return diffShortStat } private var canStage: Bool { if !diffRaw.isEmpty { @@ -58,239 +47,168 @@ struct CommitCreateView: View { @State private var cachedDiffStat: DiffStat? @State private var updateChangesError: Error? @State private var commitMessage = "" + @State private var generatedCommitMessage = "" + @State private var generatedCommitMessageIsResponding = false @State private var error: Error? @State private var isAmend = false @State private var amendCommit: Commit? @State private var isStagingChanges = false - @State private var isGeneratingCommitMessage = false @Binding var isRefresh: Bool var onCommit: () -> Void var onStash: () -> Void - + var body: some View { - VStack(spacing: 0) { - ScrollView { - if cachedDiff != nil { - StagedView( - fileDiffs: $cachedExpandableFileDiffs, - onSelectFileDiff: { fileDiff in - if let newDiff = self.cachedDiff?.updateFileDiffStage(fileDiff, stage: false) { - restorePatch(newDiff) - } - }, - onSelectChunk: status?.unmergedFiles.isEmpty == false ? nil : { fileDiff, chunk in - if let newDiff = self.cachedDiff?.updateChunkStage(chunk, in: fileDiff, stage: false) { - restorePatch(newDiff) - } + ScrollView { + if cachedDiff != nil { + StagedView( + fileDiffs: $cachedExpandableFileDiffs, + status: cachedDiffShortStat, + onSelectFileDiff: { fileDiff in + if let newDiff = self.cachedDiff?.updateFileDiffStage(fileDiff, stage: false) { + restorePatch(newDiff) } - ) - .padding(.top) - } + }, + onSelectChunk: status?.unmergedFiles.isEmpty == false ? nil : { fileDiff, chunk in + if let newDiff = self.cachedDiff?.updateChunkStage(chunk, in: fileDiff, stage: false) { + restorePatch(newDiff) + } + } + ) + .padding(.top) + } - if diff != nil { - UnstagedView( - fileDiffs: $expandableFileDiffs, - untrackedFiles: status?.untrackedFiles ?? [], - onSelectFileDiff: { fileDiff in - if let newDiff = self.diff?.updateFileDiffStage(fileDiff, stage: true) { - addPatch(newDiff) - } - }, - onSelectChunk: status?.unmergedFiles.isEmpty == false ? nil : { fileDiff, chunk in - if let newDiff = self.diff?.updateChunkStage(chunk, in: fileDiff, stage: true) { - addPatch(newDiff) - } - }, - onSelectUntrackedFile: { file in - Task { - do { - try await Process.output(GitAddPathspec(directory: folder.url, pathspec: file)) - await updateChanges() - } catch { - self.error = error - } + if diff != nil { + UnstagedView( + fileDiffs: $expandableFileDiffs, + status: notStagedHeaderCaption, + untrackedFiles: status?.untrackedFiles ?? [], + onSelectFileDiff: { fileDiff in + if let newDiff = self.diff?.updateFileDiffStage(fileDiff, stage: true) { + addPatch(newDiff) + } + }, + onSelectChunk: status?.unmergedFiles.isEmpty == false ? nil : { fileDiff, chunk in + if let newDiff = self.diff?.updateChunkStage(chunk, in: fileDiff, stage: true) { + addPatch(newDiff) + } + }, + onSelectUntrackedFile: { file in + Task { + do { + try await Process.output(GitAddPathspec(directory: folder.url, pathspec: file)) + await updateChanges() + } catch { + self.error = error } } - ) - .padding(.bottom) - } + } + ) + .padding(.bottom) + } - if let updateChangesError { - Label(updateChangesError.localizedDescription, systemImage: "exclamationmark.octagon") - Text(cachedDiffRaw + diffRaw) - .padding() - .font(Font.system(.body, design: .monospaced)) - } + if let updateChangesError { + Label(updateChangesError.localizedDescription, systemImage: "exclamationmark.octagon") + Text(cachedDiffRaw + diffRaw) + .padding() + .font(Font.system(.body, design: .monospaced)) } - .safeAreaInset(edge: .top, spacing: 0, content: { - VStack(spacing: 0) { - HStack { - Button { - Task { - do { - try await Process.output(GitStash(directory: folder.url)) - onStash() - } catch { - self.error = error - } - } - } label: { - Image(systemName: "tray.and.arrow.down") - } - .help("Stash Include Untracked") - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 16) { - Text("Staged ").foregroundStyle(.secondary) - + Text(stagedHeaderCaption) - Text("Unstaged ").foregroundStyle(.secondary) - + Text(notStagedHeaderCaption) - } - .font(.callout) + } + .safeAreaBar(edge: .top, spacing: 0, content: { + VStack(spacing: 0) { + HStack { + Button { + cachedExpandableFileDiffs = cachedExpandableFileDiffs.map { + ExpandableModel(isExpanded: true, model: $0.model) } - .padding(.horizontal) - Button("Stage All") { - Task { - do { - try await Process.output(GitAdd(directory: folder.url)) - await updateChanges() - } catch { - self.error = error - } - } + expandableFileDiffs = expandableFileDiffs.map { ExpandableModel(isExpanded: true, model: $0.model) } - .disabled(!canStage) - .layoutPriority(2) - Button { - stageWithAIButtonAction() - } label: { - if isStagingChanges { - ProgressView() - .scaleEffect(x: 0.4, y: 0.4, anchor: .center) - .frame(width: 15, height: 10) - } else { - Image(systemName: "sparkle") - .foregroundStyle(openAIAPISecretKey.isEmpty ? .secondary : .primary) - .frame(width: 15, height: 10) - } + } label: { + Image(systemName: "arrow.up.and.line.horizontal.and.arrow.down") + } + .buttonStyle(.plain) + .help("Expand All Files") + Button { + cachedExpandableFileDiffs = cachedExpandableFileDiffs.map { + ExpandableModel(isExpanded: false, model: $0.model) } - .help("Stage with AI") - .disabled(!canStage || status?.unmergedFiles.isEmpty == false) - - Button("Unstage All") { - Task { - do { - try await Process.output(GitRestore(directory: folder.url)) - await updateChanges() - } catch { - self.error = error - } - } + expandableFileDiffs = expandableFileDiffs.map { ExpandableModel(isExpanded: false, model: $0.model) } - .disabled(cachedDiffRaw.isEmpty) - .padding(.leading, 7) - .layoutPriority(2) + } label: { + Image(systemName: "arrow.down.and.line.horizontal.and.arrow.up") } - .textSelection(.disabled) - .padding(.vertical, 10) - .padding(.horizontal) + .buttonStyle(.plain) + .help("Collapse All Files") + Divider() - } - .background(Color(nsColor: .textBackgroundColor)) - }) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .layoutPriority(1) - .background(Color(NSColor.textBackgroundColor)) - Divider() - HStack(alignment: .bottom, spacing: 0) { - VStack(spacing: 0) { - ZStack { - TextEditor(text: $commitMessage) - .padding(.top, 16) - .padding(.horizontal, 12) - if commitMessage.isEmpty { - Label("Enter commit message here", systemImage: "plus.bubble") - .foregroundColor(.secondary) - .allowsHitTesting(false) + .frame(height: 16) + + Button { + Task { + do { + try await Process.output(GitStash(directory: folder.url)) + onStash() + } catch { + self.error = error } + } + } label: { + Image(systemName: "tray.and.arrow.down") } - HStack(spacing: 0) { - CommitMessageSuggestionView() - Button { - guard !openAIAPISecretKey.isEmpty else { - openSettings() - return - } - Task { - isGeneratingCommitMessage = true - do { - commitMessage = try await AIService(bearer: openAIAPISecretKey).commitMessage(stagedDiff: cachedDiffRaw) - } catch { - self.error = error - } - isGeneratingCommitMessage = false - } - } label: { - if isGeneratingCommitMessage { - ProgressView() - .scaleEffect(x: 0.4, y: 0.4, anchor: .center) - .frame(width: 15, height: 10) - } else { - Image(systemName: "sparkle") - .foregroundStyle(openAIAPISecretKey.isEmpty ? .secondary : .primary) - .frame(width: 15, height: 10) + .help("Stash All Changes") + + Spacer() + + Button("Stage All") { + Task { + do { + try await Process.output(GitAdd(directory: folder.url)) + await updateChanges() + } catch { + self.error = error } } - .help("Generate Commit Message with AI") - .padding(.horizontal) - .disabled(cachedDiffRaw.isEmpty) - } - } - Divider() - VStack(alignment: .trailing, spacing: 11) { - VStack(alignment: .trailing, spacing: 2) { - Label(cachedDiffStat?.files.count.formatted() ?? "-" , systemImage: "doc") - Label(cachedDiffStat?.insertionsTotal.formatted() ?? "-", systemImage: "plus") - Label(cachedDiffStat?.deletionsTotal.formatted() ?? "-", systemImage: "minus") } - .font(.caption) - Button("Commit") { + .disabled(!canStage) + .layoutPriority(2) + + Button("Unstage All") { Task { do { - if isAmend { - try await Process.output(GitCommitAmend(directory: folder.url, message: commitMessage)) - } else { - try await Process.output(GitCommit(directory: folder.url, message: commitMessage)) - } - onCommit() + try await Process.output(GitRestore(directory: folder.url)) + await updateChanges() } catch { self.error = error } } } - .buttonStyle(.borderedProminent) - .keyboardShortcut(.init(.return)) - .disabled(cachedDiffRaw.isEmpty || commitMessage.isEmpty) - Toggle("Amend", isOn: $isAmend) - .font(.caption) - .padding(.trailing, 6) - } - .onChange(of: isAmend) { - if isAmend { - commitMessage = amendCommit?.rawBody ?? "" - } else { - commitMessage = "" - } + .disabled(cachedDiffRaw.isEmpty) + .layoutPriority(2) } - .padding() + .textSelection(.disabled) + .padding(.vertical, 10) + .padding(.horizontal) + Divider() } - .background(Color(NSColor.textBackgroundColor)) - .onReceive(NotificationCenter.default.publisher(for: .didSelectCommitMessageSnippetNotification), perform: { notification in - if let commitMessage = notification.object as? String { - self.commitMessage = commitMessage + }) + .scrollEdgeEffectStyle(.hard, for: .vertical) + .safeAreaBar(edge: .bottom, content: { + CommitMessageEditor( + folder: folder, + commitMessage: $commitMessage, + generatedCommitMessage: $generatedCommitMessage, + generatedCommitMessageIsResponding: $generatedCommitMessageIsResponding, + cachedDiffStat: $cachedDiffStat, + isAmend: $isAmend, + error: $error, + cachedDiffRaw: $cachedDiffRaw, + amendCommit: $amendCommit) { + onCommit() } - }) - } + .frame(height: 140) + }) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(NSColor.textBackgroundColor)) .onChange(of: isRefresh, { oldValue, newValue in if newValue { Task { @@ -362,40 +280,4 @@ struct CommitCreateView: View { } } } - - private func stageWithAIButtonAction() { - guard !openAIAPISecretKey.isEmpty else { - openSettings() - return - } - - Task { - isStagingChanges = true - do { - let res = try await AIService(bearer: openAIAPISecretKey).stagingChanges( - stagedDiff: cachedDiffRaw, - notStagedDiff: diffRaw, - untrackedFiles: status?.untrackedFiles ?? [] - ) - let _ = try await Process.output(GitAddPatch(directory: folder.url, inputs: res.hunksToStage.map { $0 ? "y" : "n" })) - let files = status?.untrackedFiles.enumerated().map({ e in - if let needsStage = res.filesToStage[safe: e.offset], needsStage { - return e.element - } - return "" - }) - if let files { - let filterd = files.filter { !$0.isEmpty } - for pathspec in filterd { - try await Process.output(GitAddPathspec(directory: folder.url, pathspec: pathspec)) - } - } - await updateChanges() - commitMessage = res.commitMessage - } catch { - self.error = error - } - isStagingChanges = false - } - } } diff --git a/GitClient/Views/Commit/CommitDetailBottomBar.swift b/GitClient/Views/Commit/CommitDetailBottomBar.swift new file mode 100644 index 00000000..052e1175 --- /dev/null +++ b/GitClient/Views/Commit/CommitDetailBottomBar.swift @@ -0,0 +1,60 @@ +// +// CommitDetailBottomBar.swift +// GitClient +// +// Created by Makoto Aoyama on 2025/09/18. +// + +import SwiftUI + +struct CommitDetailBottomBar: View { + var commit: Commit + var folder: Folder + @Binding var fileDiffs: [ExpandableModel] + @State private var shortstat = "" + + var body: some View { + VStack(spacing: 0) { + Spacer() + HStack { + Spacer() + Text(shortstat) + .minimumScaleFactor(0.3) + .foregroundStyle(.primary) + Spacer() + } + .font(.callout) + Spacer() + } + .frame(height: 40) + .overlay(alignment: .leading) { + if !fileDiffs.isEmpty { + HStack { + Button { + fileDiffs = fileDiffs.map { + ExpandableModel(isExpanded: true, model: $0.model) + } + } label: { + Image(systemName: "arrow.up.and.line.horizontal.and.arrow.down") + } + .help("Expand All Files") + Button { + fileDiffs = fileDiffs.map { + ExpandableModel(isExpanded: false, model: $0.model) + } + } label: { + Image(systemName: "arrow.down.and.line.horizontal.and.arrow.up") + } + .help("Collapse All Files") + } + .padding() + .buttonStyle(.plain) + } + } + .onChange(of: commit, initial: true, { _, commit in + Task { + shortstat = (try? await Process.output(GitShowShortstat(directory: folder.url, object: commit.hash))) ?? "" + } + }) + } +} diff --git a/GitClient/Views/Commit/CommitDetailContentView.swift b/GitClient/Views/Commit/CommitDetailContentView.swift index ebe5611f..d5b001e4 100644 --- a/GitClient/Views/Commit/CommitDetailContentView.swift +++ b/GitClient/Views/Commit/CommitDetailContentView.swift @@ -14,72 +14,13 @@ struct CommitDetailContentView: View { @State private var shortstat = "" @State private var fileDiffs: [ExpandableModel] = [] @State private var mergedIn: Commit? + @State private var mergeCommitViewTab = 0 + @State private var mergeCommitFilesChanged: [ExpandableModel] = [] @State private var error: Error? var body: some View { ScrollView { - ScrollView(.horizontal, showsIndicators: false) { - HStack { - Text(commit.hash.prefix(10)) - .textSelection(.disabled) - .help(commit.hash) - .contextMenu { - Button("Copy " + commit.hash) { - let pasteboard = NSPasteboard.general - pasteboard.declareTypes([.string], owner: nil) - pasteboard.setString(commit.hash, forType: .string) - } - } - Image(systemName: "arrow.left") - HStack(spacing: 0) { - ForEach(commit.parentHashes, id: \.self) { hash in - if hash == commit.parentHashes.first { - NavigationLink(commit.parentHashes[0].prefix(5), value: commit.parentHashes[0]) - .foregroundColor(.accentColor) - } else { - Text(",") - .padding(.trailing, 2) - NavigationLink(commit.parentHashes[1].prefix(5), value: commit.parentHashes[1]) - .foregroundColor(.accentColor) - } - } - } - .textSelection(.disabled) - if let mergedIn { - Divider() - .frame(height: 10) - HStack { - Image(systemName: "arrow.triangle.pull") - NavigationLink(mergedIn.hash.prefix(5), value: mergedIn.hash) - .foregroundColor(.accentColor) - } - .help("Merged in \(mergedIn.hash.prefix(5))") - } - if !commit.tags.isEmpty { - Divider() - .frame(height: 10) - HStack(spacing: 14) { - ForEach(commit.tags, id: \.self) { tag in - Label(tag, systemImage: "tag") - } - } - } - if !commit.branches.isEmpty { - Divider() - .frame(height: 10) - HStack(spacing: 14) { - ForEach(commit.branches, id: \.self) { branch in - Label(branch, systemImage: "arrow.triangle.branch") - .foregroundColor(.secondary) - } - } - } - } - .foregroundColor(.secondary) - .buttonStyle(.link) - } - .padding(.top, 14) - .padding(.horizontal) + CommitDetailHeaderView(commit: commit, mergedIn: $mergedIn) HStack { VStack (alignment: .leading, spacing: 0) { Text(commit.title.trimmingCharacters(in: .whitespacesAndNewlines)) @@ -107,7 +48,12 @@ struct CommitDetailContentView: View { Divider() .padding(.top) if commit.parentHashes.count == 2 { - MergeCommitContentView(mergeCommit: commit, directoryURL: folder.url) + MergeCommitContentView( + mergeCommit: commit, + directoryURL: folder.url, + tab: $mergeCommitViewTab, + filesChanged: $mergeCommitFilesChanged + ) } else { FileDiffsView(expandableFileDiffs: $fileDiffs) } @@ -119,20 +65,13 @@ struct CommitDetailContentView: View { .frame(maxWidth: .infinity, alignment: .leading) .background(Color(NSColor.textBackgroundColor)) .textSelection(.enabled) - .safeAreaInset(edge: .bottom, spacing: 0, content: { - VStack(spacing: 0) { - Divider() - Spacer() - HStack { - Text(shortstat) - .minimumScaleFactor(0.3) - .foregroundStyle(.primary) - } - .font(.callout) - Spacer() - } - .background(Color(nsColor: .textBackgroundColor)) - .frame(height: 40) + .scrollEdgeEffectStyle(.soft, for: .bottom) + .safeAreaBar(edge: .bottom, spacing: 0, content: { + CommitDetailBottomBar( + commit: commit, + folder: folder, + fileDiffs: bottomBarFileDiff() + ) }) .onChange(of: commit, initial: true, { _, commit in Task { @@ -143,12 +82,12 @@ struct CommitDetailContentView: View { merges: true, ancestryPath: true, reverse: true, - revisionRange: "\(commit.hash)..HEAD" + revisionRange: ["\(commit.hash)..HEAD"] )).first if let mergeCommit { let mergedInCommits = try await Process.output(GitLog( directory: folder.url, - revisionRange: "\(mergeCommit.parentHashes[0])..\(mergeCommit.hash)" + revisionRange: ["\(mergeCommit.parentHashes[0])..\(mergeCommit.hash)"] )) let contains = mergedInCommits.contains { $0.hash == commit.hash } if contains { @@ -173,11 +112,15 @@ struct CommitDetailContentView: View { fileDiffs = [] } }) - .onChange(of: commit, initial: true, { _, commit in - Task { - shortstat = (try? await Process.output(GitShowShortstat(directory: folder.url, object: commit.hash))) ?? "" - } - }) .errorSheet($error) } + + private func bottomBarFileDiff() -> Binding<[ExpandableModel]> { + if commit.parentHashes.count == 2 { + return mergeCommitViewTab == 0 ? .constant([]) : $mergeCommitFilesChanged + + } else { + return $fileDiffs + } + } } diff --git a/GitClient/Views/Commit/CommitDetailHeaderView.swift b/GitClient/Views/Commit/CommitDetailHeaderView.swift new file mode 100644 index 00000000..047a5025 --- /dev/null +++ b/GitClient/Views/Commit/CommitDetailHeaderView.swift @@ -0,0 +1,78 @@ +// +// CommitDetailHeaderView.swift +// GitClient +// +// Created by Makoto Aoyama on 2025/09/18. +// + +import SwiftUI + +struct CommitDetailHeaderView: View { + var commit: Commit + @Binding var mergedIn: Commit? + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + Text(commit.hash.prefix(5)) + .textSelection(.disabled) + .help(commit.hash) + .contextMenu { + Button("Copy " + commit.hash) { + let pasteboard = NSPasteboard.general + pasteboard.declareTypes([.string], owner: nil) + pasteboard.setString(commit.hash, forType: .string) + } + } + Image(systemName: "arrow.left") + HStack(spacing: 0) { + ForEach(commit.parentHashes, id: \.self) { hash in + if hash == commit.parentHashes.first { + NavigationLink(commit.parentHashes[0].prefix(5), value: commit.parentHashes[0]) + .foregroundColor(.accentColor) + } else { + Text(",") + .padding(.trailing, 2) + NavigationLink(commit.parentHashes[1].prefix(5), value: commit.parentHashes[1]) + .foregroundColor(.accentColor) + } + } + } + .textSelection(.disabled) + if let mergedIn { + Divider() + .frame(height: 10) + HStack { + Image(systemName: "arrow.triangle.pull") + NavigationLink(mergedIn.hash.prefix(5), value: mergedIn.hash) + .foregroundColor(.accentColor) + } + .help("Merged in \(mergedIn.hash.prefix(5))") + } + if !commit.tags.isEmpty { + Divider() + .frame(height: 10) + HStack(spacing: 14) { + ForEach(commit.tags, id: \.self) { tag in + Label(tag, systemImage: "tag") + } + } + } + if !commit.branches.isEmpty { + Divider() + .frame(height: 10) + HStack(spacing: 14) { + ForEach(commit.branches, id: \.self) { branch in + Label(branch, systemImage: "arrow.triangle.branch") + .foregroundColor(.secondary) + } + } + } + } + .foregroundColor(.secondary) + .buttonStyle(.link) + } + .padding(.top, 14) + .padding(.horizontal) + } +} diff --git a/GitClient/Views/Commit/CommitDiffView.swift b/GitClient/Views/Commit/CommitDiffView.swift index 98770f2a..0128b23c 100644 --- a/GitClient/Views/Commit/CommitDiffView.swift +++ b/GitClient/Views/Commit/CommitDiffView.swift @@ -35,56 +35,74 @@ struct CommitDiffView: View { .padding(.horizontal) } .background(Color(NSColor.textBackgroundColor)) - .safeAreaInset(edge: .bottom, spacing: 0, content: { - VStack(spacing: 0) { + .scrollEdgeEffectStyle(.soft, for: .bottom) + .safeAreaBar(edge: .bottom, spacing: 0, content: { + VStack(spacing: 0) { + DiffSummaryView(fileDiffs: filesChanges) + HStack(spacing: 0) { + HStack { + Text("Diff") + .foregroundStyle(.secondary) + Text(commitFirst == Log.notCommitted.id ? "Staged Changes" : commitFirst.prefix(5)) + Text(commitSecond == Log.notCommitted.id ? "Staged Changes" : commitSecond.prefix(5)) + Button { + let first = commitFirst + let second = commitSecond + commitFirst = second + commitSecond = first + } label: { + Image(systemName: "arrow.left.arrow.right") + } + .buttonStyle(.plain) + .help("Swap the Commits") + } + .padding(.horizontal) Divider() - Spacer() - HStack(spacing: 0) { - HStack { - Text("Diff") - .foregroundStyle(.secondary) - Text(commitFirst == Log.notCommitted.id ? "Staged Changes" : commitFirst.prefix(5)) - Text(commitSecond == Log.notCommitted.id ? "Staged Changes" : commitSecond.prefix(5)) - Button { - let first = commitFirst - let second = commitSecond - commitFirst = second - commitSecond = first - } label: { - Image(systemName: "arrow.left.arrow.right") + .frame(height: 16) + HStack { + Button { + filesChanges = filesChanges.map { + ExpandableModel(isExpanded: true, model: $0.model) + } + } label: { + Image(systemName: "arrow.up.and.line.horizontal.and.arrow.down") + } + .help("Expand All Files") + Button { + filesChanges = filesChanges.map { + ExpandableModel(isExpanded: false, model: $0.model) } - .buttonStyle(.accessoryBar) - .help("Swap the Commits") + } label: { + Image(systemName: "arrow.down.and.line.horizontal.and.arrow.up") } - .padding(.horizontal) - Divider() - Spacer() - Text(shortstat) - .minimumScaleFactor(0.3) - .foregroundStyle(.primary) - Spacer() + .help("Collapse All Files") } - .font(.callout) - + .padding(.leading) + .buttonStyle(.plain) + Spacer() + Text(shortstat) + .minimumScaleFactor(0.3) + .foregroundStyle(.primary) Spacer() } - .background(Color(nsColor: .textBackgroundColor)) + .font(.callout) .frame(height: 40) - }) - .onChange(of: selectionLogID + subSelectionLogID, initial: true) { oldValue, newValue in - commitFirst = selectionLogID - commitSecond = subSelectionLogID } - .onChange(of: commitFirst + commitSecond, initial: true) { _, _ in - if commitFirst == Log.notCommitted.id { - updateDiff(commitRange: commitSecond) - } else if commitSecond == Log.notCommitted.id { - updateDiff(commitRange: commitFirst) - } else { - updateDiff(commitRange: commitFirst + ".." + commitSecond) - } + }) + .onChange(of: selectionLogID + subSelectionLogID, initial: true) { oldValue, newValue in + commitFirst = selectionLogID + commitSecond = subSelectionLogID + } + .onChange(of: commitFirst + commitSecond, initial: true) { _, _ in + if commitFirst == Log.notCommitted.id { + updateDiff(commitRange: commitSecond) + } else if commitSecond == Log.notCommitted.id { + updateDiff(commitRange: commitFirst) + } else { + updateDiff(commitRange: commitFirst + ".." + commitSecond) } - .errorSheet($error) + } + .errorSheet($error) } private func updateDiff(commitRange: String) { diff --git a/GitClient/Views/Commit/CommitMessageEditor.swift b/GitClient/Views/Commit/CommitMessageEditor.swift new file mode 100644 index 00000000..c11d1a4b --- /dev/null +++ b/GitClient/Views/Commit/CommitMessageEditor.swift @@ -0,0 +1,102 @@ +// +// CommitMessageEditorView.swift +// GitClient +// +// Created by Makoto Aoyama on 2025/09/06. +// + +import SwiftUI + +struct CommitMessageEditor: View { + var folder: Folder + @Binding var commitMessage: String + @Binding var generatedCommitMessage: String + @Binding var generatedCommitMessageIsResponding: Bool + @Binding var cachedDiffStat: DiffStat? + @Binding var isAmend: Bool + @Binding var error: Error? + @Binding var cachedDiffRaw: String + @Binding var amendCommit: Commit? + @FocusState private var focused: Bool + var onCommit: () -> Void + + var body: some View { + VStack(spacing: 0) { + Divider() + HStack(alignment: .bottom, spacing: 0) { + VStack(spacing: 0) { + ZStack(alignment: .topLeading) { + TextEditor(text: $commitMessage) + .focused($focused) + .scrollContentBackground(.hidden) + .padding(.top, 16) + .padding(.horizontal, 12) + .font(.body) + if commitMessage.isEmpty && !focused { + Text("Commit Message") + .foregroundColor(.secondary) + .allowsHitTesting(false) + .padding(.top, 16) + .padding(.horizontal, 17) + } + } + .safeAreaBar(edge: .bottom) { + CommitMessageGenerationView( + cachedDiffRaw: $cachedDiffRaw, + commitMessage: $commitMessage, + commitMessageIsReponding: $generatedCommitMessageIsResponding, + generatedCommitMessage: $generatedCommitMessage, + ) + .font(.callout) + .padding(.horizontal) + } + CommitMessageSnippetSuggestionView() + .padding(.trailing) + .font(.callout) + } + Divider() + VStack(alignment: .center, spacing: 11) { + VStack(alignment: .trailing, spacing: 2) { + Label(cachedDiffStat?.files.count.formatted() ?? "-" , systemImage: "doc") + Label(cachedDiffStat?.insertionsTotal.formatted() ?? "-", systemImage: "plus") + Label(cachedDiffStat?.deletionsTotal.formatted() ?? "-", systemImage: "minus") + } + .font(.caption) + Button("Commit") { + Task { + do { + if isAmend { + try await Process.output(GitCommitAmend(directory: folder.url, message: commitMessage)) + } else { + try await Process.output(GitCommit(directory: folder.url, message: commitMessage)) + } + onCommit() + } catch { + self.error = error + } + } + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.init(.return)) + .disabled(cachedDiffRaw.isEmpty || commitMessage.isEmpty) + Toggle("Amend", isOn: $isAmend) + .font(.caption) + .padding(.trailing, 6) + } + .onChange(of: isAmend) { + if isAmend { + commitMessage = amendCommit?.rawBody ?? "" + } else { + commitMessage = "" + } + } + .padding() + } + } + .onReceive(NotificationCenter.default.publisher(for: .didSelectCommitMessageSnippetNotification), perform: { notification in + if let commitMessage = notification.object as? String { + self.commitMessage = commitMessage + } + }) + } +} diff --git a/GitClient/Views/Commit/CommitMessageGenerationContentView.swift b/GitClient/Views/Commit/CommitMessageGenerationContentView.swift new file mode 100644 index 00000000..d63be015 --- /dev/null +++ b/GitClient/Views/Commit/CommitMessageGenerationContentView.swift @@ -0,0 +1,108 @@ +// +// CommitMessageGenerationContentView.swift +// GitClient +// +// Created by Makoto Aoyama on 2025/09/13. +// + +import SwiftUI + +struct CommitMessageGenerationContentView: View { + @Binding var cachedDiffRaw: String + @Binding var commitMessage: String + @Binding var commitMessageIsReponding: Bool + @Binding var generatedCommitMessage: String + @State private var generateCommitMessageTask : Task<(), Never>? + @State private var error: Error? + + var body: some View { + HStack { + if commitMessageIsReponding || !generatedCommitMessage.isEmpty || error != nil { + HStack { + Button { + generateCommitMessageTask?.cancel() + generatedCommitMessage = "" + } label: { + Image(systemName: "xmark") + } + Button { + generateCommitMessageTask?.cancel() + generateCommitMessageTask = Task { + await generateCommitMessage() + } + } label: { + Image(systemName: "arrow.clockwise") + } + if commitMessageIsReponding && generatedCommitMessage.isEmpty { + ProgressView() + .scaleEffect(x: 0.4, y: 0.4, anchor: .leading) + } + ScrollView(.horizontal) { + HStack { + Text(generatedCommitMessage) + if error != nil { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.yellow) + Text(error?.localizedDescription ?? "") + .foregroundStyle(.secondary) + } + } + .frame(height: 38) + } + if !generatedCommitMessage.isEmpty { + Button { + commitMessage = generatedCommitMessage + generatedCommitMessage = "" + } label: { + Image(systemName: "arrow.up") + } + } + } + .padding(.horizontal) + } + } + .onChange(of: cachedDiffRaw, initial: true, { oldValue, newValue in + generateCommitMessageTask?.cancel() + generateCommitMessageTask = Task { + await generateCommitMessage() + } + }) + .glassEffect() + .buttonStyle(.plain) + } + + private func generateCommitMessage() async { + generatedCommitMessage = "" + commitMessageIsReponding = true + error = nil + do { + if !cachedDiffRaw.isEmpty { + let stream = SystemLanguageModelService().commitMessage(stagedDiff: cachedDiffRaw) + for try await message in stream { + if !Task.isCancelled { + generatedCommitMessage = message.content.commitMessage ?? "" + } + } + } + } catch { + if !Task.isCancelled { + self.error = error + } + } + commitMessageIsReponding = false + } +} + +#Preview { + @Previewable @State var cachedDiffRaw = "" + @Previewable @State var commitMessage = "Hello" + @Previewable @State var generatedCommitMessage = "Hello" + @Previewable @State var isRespofing = false + + CommitMessageGenerationView( + cachedDiffRaw: $cachedDiffRaw, + commitMessage: $commitMessage, + commitMessageIsReponding: $isRespofing, + generatedCommitMessage: $generatedCommitMessage + ) +} diff --git a/GitClient/Views/Commit/CommitMessageGenerationUnavailableView.swift b/GitClient/Views/Commit/CommitMessageGenerationUnavailableView.swift new file mode 100644 index 00000000..8e646e09 --- /dev/null +++ b/GitClient/Views/Commit/CommitMessageGenerationUnavailableView.swift @@ -0,0 +1,50 @@ +// +// CommitMessageGenerationUnavailableView.swift +// GitClient +// +// Created by Makoto Aoyama on 2025/09/13. +// + +import SwiftUI +import FoundationModels + +struct CommitMessageGenerationUnavailableView: View { + var reason: SystemLanguageModel.Availability.UnavailableReason + @State private var modelNotReadyPopOver = false + @State private var appleIntelligenceNotEnabled = false + + var body: some View { + switch reason { + case .modelNotReady: + Button { + modelNotReadyPopOver = true + } label: { + Image(systemName: "exclamationmark.circle") + } + .popover(isPresented: $modelNotReadyPopOver) { + Text("The model(s) aren’t available on this device.\nModels are downloaded automatically based on factors like network status, battery level, and system load.") + .padding() + } + case .appleIntelligenceNotEnabled: + Button { + appleIntelligenceNotEnabled = true + } label: { + Image(systemName: "exclamationmark.circle") + } + .popover(isPresented: $appleIntelligenceNotEnabled) { + Text("Apple Intelligence is not enabled on this system.") + .padding() + } + case .deviceNotEligible: + // This device does not support Apple Intelligence. + EmptyView() + @unknown default: + // The model is unavailable for unknown reasons. + EmptyView() + } + } +} + +#Preview { + CommitMessageGenerationUnavailableView(reason: .modelNotReady) +} diff --git a/GitClient/Views/Commit/CommitMessageGenerationView.swift b/GitClient/Views/Commit/CommitMessageGenerationView.swift new file mode 100644 index 00000000..bac148a6 --- /dev/null +++ b/GitClient/Views/Commit/CommitMessageGenerationView.swift @@ -0,0 +1,36 @@ +// +// CommitMessageGenerationView.swift +// GitClient +// +// Created by Makoto Aoyama on 2025/08/16. +// + +import SwiftUI +import FoundationModels + +struct CommitMessageGenerationView: View { + @Environment(\.systemLanguageModelAvailability) private var systemLanguageModelAvailability + @Binding var cachedDiffRaw: String + @Binding var commitMessage: String + @Binding var commitMessageIsReponding: Bool + @Binding var generatedCommitMessage: String + + var body: some View { + HStack { + switch systemLanguageModelAvailability { + case .available: + CommitMessageGenerationContentView( + cachedDiffRaw: $cachedDiffRaw, + commitMessage: $commitMessage, + commitMessageIsReponding: $commitMessageIsReponding, + generatedCommitMessage: $generatedCommitMessage + ) + case .unavailable(let reason): + HStack { + Spacer() + CommitMessageGenerationUnavailableView(reason: reason) + }.buttonStyle(.plain) + } + } + } +} diff --git a/GitClient/Views/Commit/CommitMessageSuggestionView.swift b/GitClient/Views/Commit/CommitMessageSnippetSuggestionView.swift similarity index 88% rename from GitClient/Views/Commit/CommitMessageSuggestionView.swift rename to GitClient/Views/Commit/CommitMessageSnippetSuggestionView.swift index b2fdc999..e283eda6 100644 --- a/GitClient/Views/Commit/CommitMessageSuggestionView.swift +++ b/GitClient/Views/Commit/CommitMessageSnippetSuggestionView.swift @@ -7,7 +7,7 @@ import SwiftUI -struct CommitMessageSuggestionView: View { +struct CommitMessageSnippetSuggestionView: View { @State private var error: Error? @State private var isPresenting = false @Environment(\.openWindow) private var openWindow @@ -30,15 +30,14 @@ struct CommitMessageSuggestionView: View { Button(snippet) { NotificationCenter.default.post(name: .didSelectCommitMessageSnippetNotification, object: snippet) } - .buttonStyle(.accessoryBar) + .buttonStyle(.plain) if snippet != decodedCommitMessageSnippet.last { Text("|") .foregroundStyle(.separator) } } } - .font(.callout) - .padding(.leading, 12) + .padding(.leading) } .frame(height: 40) Button(action: { @@ -52,5 +51,5 @@ struct CommitMessageSuggestionView: View { } #Preview { - CommitMessageSuggestionView() + CommitMessageSnippetSuggestionView() } diff --git a/GitClient/Views/Commit/DiffView.swift b/GitClient/Views/Commit/DiffView.swift index 64541c33..c8014032 100644 --- a/GitClient/Views/Commit/DiffView.swift +++ b/GitClient/Views/Commit/DiffView.swift @@ -10,7 +10,7 @@ import SwiftUI struct DiffView: View { @Binding var commits: [Commit] @Binding var filesChanged: [ExpandableModel] - @State private var tab = 0 + @Binding var tab: Int var body: some View { VStack(alignment: .leading, spacing: 0) { diff --git a/GitClient/Views/Commit/FileNameView.swift b/GitClient/Views/Commit/FileNameView.swift index 6cb31f42..a67dfa42 100644 --- a/GitClient/Views/Commit/FileNameView.swift +++ b/GitClient/Views/Commit/FileNameView.swift @@ -38,7 +38,7 @@ struct FileNameView: View { .foregroundStyle(.secondary) .help("Open " + (fileURL?.absoluteString ?? "")) } - .buttonStyle(.accessoryBar) + .buttonStyle(.plain) Spacer() } } diff --git a/GitClient/Views/Commit/MergeCommitContentView.swift b/GitClient/Views/Commit/MergeCommitContentView.swift index aca63f84..226d3e0b 100644 --- a/GitClient/Views/Commit/MergeCommitContentView.swift +++ b/GitClient/Views/Commit/MergeCommitContentView.swift @@ -10,16 +10,21 @@ import SwiftUI struct MergeCommitContentView: View { var mergeCommit: Commit var directoryURL: URL + @Binding var tab: Int + @Binding var filesChanged: [ExpandableModel] @State private var commits: [Commit] = [] - @State private var filesChanged: [ExpandableModel] = [] @State private var error: Error? var body: some View { - DiffView(commits: $commits, filesChanged: $filesChanged) + DiffView( + commits: $commits, + filesChanged: $filesChanged, + tab: $tab + ) .onChange(of: mergeCommit, initial: true) { Task { do { - commits = try await Array(Process.output(GitLog(directory: directoryURL, revisionRange: "\(mergeCommit.parentHashes[0])..\(mergeCommit.hash)")).dropFirst()) + commits = try await Array(Process.output(GitLog(directory: directoryURL, revisionRange: ["\(mergeCommit.parentHashes[0])..\(mergeCommit.hash)"])).dropFirst()) let diffRaw = try await Process.output( GitDiff(directory: directoryURL, noRenames: false, commitRange: mergeCommit.parentHashes[0] + ".." + mergeCommit.hash) ) @@ -47,7 +52,9 @@ struct MergeCommitContentView: View { branches: [], tags: [] ), - directoryURL: URL(string: "file:///maoyama/Projects/")! + directoryURL: URL(string: "file:///maoyama/Projects/")!, + tab: .constant(0), + filesChanged: .constant([]) ) } } diff --git a/GitClient/Views/Commit/SectionHeader.swift b/GitClient/Views/Commit/SectionHeader.swift index 5f0b5720..0c52dfb4 100644 --- a/GitClient/Views/Commit/SectionHeader.swift +++ b/GitClient/Views/Commit/SectionHeader.swift @@ -9,17 +9,18 @@ import SwiftUI struct SectionHeader: View { var title: String - + var callout: String + var body: some View { - Text(title) - .font(.title) - .fontWeight(.bold) - .textSelection(.disabled) + HStack { + Text(title) + .font(.title) + .fontWeight(.bold) + .textSelection(.disabled) + Spacer() + Text(callout) + .font(.callout) + .foregroundStyle(.tertiary) + } } } - -#Preview { - return SectionHeader( - title: "Staged" - ) -} diff --git a/GitClient/Views/Commit/StageFileDiffView.swift b/GitClient/Views/Commit/StageFileDiffView.swift index 309c7836..c79be4c1 100644 --- a/GitClient/Views/Commit/StageFileDiffView.swift +++ b/GitClient/Views/Commit/StageFileDiffView.swift @@ -30,7 +30,7 @@ struct StageFileDiffView: View { Image(systemName: selectButtonImageSystemName) .frame(width: 20, height: 20) } - .buttonStyle(.accessoryBar) + .buttonStyle(.plain) .help(selectButtonHelp) .padding(.vertical) .disabled(onSelectChunk == nil) @@ -53,7 +53,7 @@ struct StageFileDiffView: View { Image(systemName: selectButtonImageSystemName) .frame(width: 20, height: 20) } - .buttonStyle(.accessoryBar) + .buttonStyle(.plain) .help(selectButtonHelp) .padding() } diff --git a/GitClient/Views/Commit/StagedView.swift b/GitClient/Views/Commit/StagedView.swift index b1374288..294bf684 100644 --- a/GitClient/Views/Commit/StagedView.swift +++ b/GitClient/Views/Commit/StagedView.swift @@ -9,6 +9,7 @@ import SwiftUI struct StagedView: View { @Binding var fileDiffs: [ExpandableModel] + var status: String var onSelectFileDiff: ((FileDiff) -> Void)? var onSelectChunk: ((FileDiff, Chunk) -> Void)? @State private var isExpanded = true @@ -35,7 +36,7 @@ struct StagedView: View { .padding(.top) } } label: { - SectionHeader(title: "Staged Changes") + SectionHeader(title: "Staged Changes", callout: status) .padding(.leading, 3) } .padding(.horizontal) diff --git a/GitClient/Views/Commit/UnstagedView.swift b/GitClient/Views/Commit/UnstagedView.swift index b3b499d2..6b1f2f55 100644 --- a/GitClient/Views/Commit/UnstagedView.swift +++ b/GitClient/Views/Commit/UnstagedView.swift @@ -9,6 +9,7 @@ import SwiftUI struct UnstagedView: View { @Binding var fileDiffs: [ExpandableModel] + var status: String var untrackedFiles: [String] var onSelectFileDiff: ((FileDiff) -> Void)? var onSelectChunk: ((FileDiff, Chunk) -> Void)? @@ -53,7 +54,7 @@ struct UnstagedView: View { } label: { Image(systemName: "plus.circle") } - .buttonStyle(.accessoryBar) + .buttonStyle(.plain) .help("Stage This File") .padding(.horizontal) } @@ -63,7 +64,7 @@ struct UnstagedView: View { .padding(.bottom) } } label: { - SectionHeader(title: "Unstaged Changes") + SectionHeader(title: "Unstaged Changes", callout: status) .padding(.leading, 3) } .padding(.horizontal) @@ -119,6 +120,7 @@ struct UnstagedView: View { return ScrollView { UnstagedView( fileDiffs: $fileDiffs, + status: "1 file changes", untrackedFiles: ["Projects/Files/Path.swift", "Projects/Files/Path1.swift"], onSelectFileDiff: { f in print(f) diff --git a/GitClient/Views/ContentView.swift b/GitClient/Views/ContentView.swift index 3ed94bb9..c3c033d4 100644 --- a/GitClient/Views/ContentView.swift +++ b/GitClient/Views/ContentView.swift @@ -9,6 +9,7 @@ import SwiftUI struct ContentView: View { @AppStorage(AppStorageKey.folder.rawValue) var folders: Data? + @Environment(\.appearsActive) private var appearsActive private var decodedFolders: [Folder] { guard let folders else { return [] } do { @@ -25,6 +26,7 @@ struct ContentView: View { @State private var selectionLog: Log? @State private var subSelectionLogID: String? @State private var folderIsRefresh = false + @State private var systemLanguageModelAvailability = SystemLanguageModelService().availability @State private var error: Error? var body: some View { @@ -32,14 +34,18 @@ struct ContentView: View { VStack { if decodedFolders.isEmpty { VStack { + Spacer() + Spacer() + .frame(height: 60) Text("No Project Folder Added") Text("Please add a folder that contains a Git repository.") .font(.caption) .padding(.top, 2) + Spacer() } .multilineTextAlignment(.center) - .foregroundColor(.secondary) - .padding(.horizontal) + .foregroundStyle(.secondary) + .padding() } else { List(decodedFolders, id: \.url, selection: $selectionFolderURL) { folder in Label(folder.displayName, systemImage: "folder") @@ -58,8 +64,8 @@ struct ContentView: View { } } } - .toolbar { - ToolbarItemGroup { + .safeAreaBar(edge: .bottom, content: { + HStack { Button { let panel = NSOpenPanel() panel.canChooseFiles = false @@ -83,12 +89,14 @@ struct ContentView: View { } } } label: { - Image(systemName: "plus.rectangle.on.folder") + Image(systemName: "plus") } + .buttonStyle(.plain) .help("Add Project Folder") + .padding() + Spacer() } - } - + }) } content: { if let folder = selectionFolder { FolderView( @@ -132,8 +140,12 @@ struct ContentView: View { .onChange(of: selectionFolder, { selectionLog = nil }) + .onChange(of: appearsActive) { + systemLanguageModelAvailability = SystemLanguageModelService().availability + } .errorSheet($error) .environment(\.folder, selectionFolderURL) + .environment(\.systemLanguageModelAvailability, systemLanguageModelAvailability) } } diff --git a/GitClient/Views/DiffSummaryView.swift b/GitClient/Views/DiffSummaryView.swift new file mode 100644 index 00000000..ea20325b --- /dev/null +++ b/GitClient/Views/DiffSummaryView.swift @@ -0,0 +1,106 @@ +// +// DiffSummaryView.swift +// GitClient +// +// Created by Makoto Aoyama on 2025/09/22. +// + +import SwiftUI + +struct DiffSummaryView: View { + var fileDiffs: [ExpandableModel] + @State private var summary = "" + @State private var summaryIsResponding = false + @State private var summaryGenerationError: Error? + @State private var generateSummaryTask: Task<(), Never>? + @Environment(\.systemLanguageModelAvailability) private var systemLanguageModelAvailability + + var body: some View { + VStack { + if systemLanguageModelAvailability == .available && (!summary.isEmpty || summaryIsResponding) { + VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 8) { + HStack { + HStack(spacing: 2) { + Image(systemName: "apple.intelligence") + Text("Summary") + } + .foregroundStyle(.tertiary) + Spacer() + Button { + generateSummaryTask?.cancel() + generateSummaryTask = Task { + await generateSummary() + } + } label: { + Image(systemName: "arrow.clockwise") + } + Button { + generateSummaryTask?.cancel() + summary = "" + } label: { + Image(systemName: "xmark") + } + } + .buttonStyle(.plain) + .font(.callout) + if summaryGenerationError != nil { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.yellow) + Text(summaryGenerationError?.localizedDescription ?? "") + .foregroundStyle(.secondary) + .textSelection(.enabled) + Spacer() + } + } + if summaryIsResponding && summary.isEmpty { + ProgressView() + .scaleEffect(x: 0.4, y: 0.4, anchor: .leading) + .frame(height: 16) + .padding(.leading, 1) + } + if !summary.isEmpty { + Text(summary) + .textSelection(.enabled) + } + } + Divider() + .padding(.top) + } + .padding(.horizontal) + } + } + .onChange(of: fileDiffs.map { $0.model }, initial: true) { + generateSummaryTask?.cancel() + generateSummaryTask = Task { + await generateSummary() + } + } + } + + private func generateSummary() async { + summary = "" + summaryIsResponding = true + summaryGenerationError = nil + do { + let diffRaw = fileDiffs.map { fileDiff in + fileDiff.model.raw + }.joined(separator: "\n") + if !diffRaw.isEmpty { + let stream = SystemLanguageModelService().diffSummary(diffRaw) + for try await text in stream { + if !Task.isCancelled { + summary = text.content.summary ?? "" + } + } + } + } catch { + if !Task.isCancelled { + summaryGenerationError = error + } + } + summaryIsResponding = false + } + +} diff --git a/GitClient/Views/Extensions/EnviromentValues+.swift b/GitClient/Views/Extensions/EnviromentValues+.swift index b5d563aa..eaad3e60 100644 --- a/GitClient/Views/Extensions/EnviromentValues+.swift +++ b/GitClient/Views/Extensions/EnviromentValues+.swift @@ -8,8 +8,8 @@ import SwiftUI extension EnvironmentValues { - @Entry var openAIAPISecretKey: String = "" @Entry var folder: URL? @Entry var expandAllFiles: UUID? @Entry var collapseAllFiles: UUID? + @Entry var systemLanguageModelAvailability = SystemLanguageModelService().availability } diff --git a/GitClient/Views/Folder/CommitRowView.swift b/GitClient/Views/Folder/CommitRowView.swift index 81fd00e3..409e42fe 100644 --- a/GitClient/Views/Folder/CommitRowView.swift +++ b/GitClient/Views/Folder/CommitRowView.swift @@ -15,13 +15,10 @@ struct CommitRowView: View { HStack(alignment: .firstTextBaseline) { Text(commit.title) Spacer() - VStack(alignment: .trailing, spacing: 2) { - Text(commit.hash.prefix(5)) + if !commit.tags.isEmpty { + Image(systemName: "tag") .foregroundStyle(.tertiary) - if commit.parentHashes.count == 2 { - Image(systemName: "arrow.triangle.merge") - .foregroundStyle(.tertiary) - } + .font(.caption) } } HStack { diff --git a/GitClient/Views/Folder/FolderView.swift b/GitClient/Views/Folder/FolderView.swift index c9c822ae..4fd93ffe 100644 --- a/GitClient/Views/Folder/FolderView.swift +++ b/GitClient/Views/Folder/FolderView.swift @@ -59,29 +59,28 @@ struct FolderView: View { ) } } - .safeAreaInset(edge: .bottom, spacing: 0) { - VStack(spacing: 0) { - Divider() + .scrollEdgeEffectStyle(.soft, for: .bottom) + .safeAreaBar(edge: .bottom, spacing: 0) { + HStack(spacing: 0) { Spacer() countText() .font(.callout) Spacer() } - .frame(height: 40) - .background(Color(nsColor: .textBackgroundColor)) - .overlay(alignment: .trailing) { + .overlay(alignment: .trailing, content: { Button(action: { showGraph.toggle() }) { Image(systemName: showGraph ? "point.3.filled.connected.trianglepath.dotted" : "point.3.connected.trianglepath.dotted") .font(.title3) .rotationEffect(.init(degrees: 270)) - .foregroundStyle( showGraph ? Color.accentColor : Color.secondary) + .foregroundStyle( showGraph ? Color.accentColor : Color.primary) } - .buttonStyle(.accessoryBar) - .padding(.horizontal, 8) + .buttonStyle(.plain) + .padding(.horizontal) .help("Commit Graph") - } + }) + .frame(height: 40) } .overlay(content: { if logStore.commits.isEmpty && !searchTokens.isEmpty { @@ -105,7 +104,7 @@ struct FolderView: View { if !suggestSearchToken.isEmpty { Section("History") { ForEach(suggestSearchToken) { token in - HStack { + VStack(alignment: .leading) { Text(token.kind.label) .foregroundStyle(.secondary) Text(token.text) @@ -134,7 +133,7 @@ struct FolderView: View { } } else { ForEach(SearchKind.allCases, id: \.self) { kind in - HStack { + VStack(alignment: .leading) { Text(kind.label) .foregroundStyle(.secondary) Text(searchText) @@ -215,21 +214,21 @@ struct FolderView: View { ProgressView() .scaleEffect(x: 0.5, y: 0.5, anchor: .center) } + .sharedBackgroundVisibility(.hidden) } else { ToolbarItem(placement: .principal) { branchesButton() } ToolbarItem(placement: .principal) { addBranchButton() - .padding(.trailing) } + ToolbarSpacer(.fixed, placement: .principal) ToolbarItem(placement: .principal) { tagButton() - .padding(.trailing) } + ToolbarSpacer(.fixed, placement: .principal) ToolbarItem(placement: .principal) { stashButton() - .padding(.trailing) } ToolbarItem(placement: .primaryAction) { pullButton() diff --git a/GitClient/Views/Folder/Sheets/AmendCommitSheet.swift b/GitClient/Views/Folder/Sheets/AmendCommitSheet.swift index 4a61ce0d..1a6e35fc 100644 --- a/GitClient/Views/Folder/Sheets/AmendCommitSheet.swift +++ b/GitClient/Views/Folder/Sheets/AmendCommitSheet.swift @@ -21,9 +21,10 @@ struct AmendCommitSheet: View { VStack(alignment: .leading) { HStack { TextEditor(text: $message) + .scrollContentBackground(.hidden) .frame(height: 100) // .contentMargins(8, for: .scrollContent) - // `contentMargins` not work on macOS. Xcode16.2 and macOS14 + // `contentMargins` not work on macOS. Xcode26.0 and macOS26 } HStack { Button("Cancel") { diff --git a/GitClient/Views/SettingsView.swift b/GitClient/Views/SettingsView.swift deleted file mode 100644 index 142330d7..00000000 --- a/GitClient/Views/SettingsView.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// SettingsView.swift -// GitClient -// -// Created by Makoto Aoyama on 2024/09/27. -// - -import SwiftUI - -struct SettingsView: View { - @Binding var openAIAPISecretKey: String - - var body: some View { - VStack(alignment: .leading) { - Text("OpenAI API") - .font(.title2) - .fontWeight(.bold) - Divider() - Form { - Section { - HStack { - SecureField(text: $openAIAPISecretKey) { - Text("SECRET KEY") - } - .focusable(false) - } - } footer: { - Text(""" -Please enter the Secret Key, which can be created from the link https://platform.openai.com/api-keys. -Grant “Write” permission to the Secret Key for the “/v1/chat/completions” endpoint. -""" - ) - } - } - .padding(.vertical) - HStack { - Image(systemName: "sparkle") - .frame(width: 20) - Text("You can use the OpenAI API to stage changes and generate commit messages.") - } - .padding(.vertical) - HStack { - Image(systemName: "dollarsign") - .frame(width: 20) - Text(""" -Each time the Generate button is clicked, a request for changes will be sent to the API(using GPT-4o mini). Using GPT-4o-mini via the API costs 15 cents per 1M input tokens and 60 cents per 1M output tokens (roughly the equivalent of 2500 pages in a standard book). You can check the costs associated with using the API here. https://platform.openai.com/usage -""") - } - .padding(.bottom) - HStack { - Image(systemName: "shield") - .frame(width: 20) - Text(""" -This Git client app and OpenAI API also do not use the inputs and outputs for model training. https://openai.com/enterprise-privacy -""") - } - .padding(.bottom) - } - .padding() - .padding(.horizontal) - .frame(minWidth: 300, maxWidth: 700, minHeight: 200, maxHeight: 600) - } -} - -#Preview { - @Previewable @State var openAIAPISecretKey = "" - SettingsView(openAIAPISecretKey: $openAIAPISecretKey) -} diff --git a/GitClient/Views/StashChanged/StashChangedContentView.swift b/GitClient/Views/StashChanged/StashChangedContentView.swift index cb66fd88..536a6b25 100644 --- a/GitClient/Views/StashChanged/StashChangedContentView.swift +++ b/GitClient/Views/StashChanged/StashChangedContentView.swift @@ -8,14 +8,20 @@ import SwiftUI struct StashChangedContentView: View { + var folder: Folder @Binding var showingStashChanged: Bool var stashList: [Stash]? + var onTapDropButton: ((Stash) -> Void)? @State private var selectionStashID: Int? @State private var fileDiffs: [ExpandableModel] = [] + @State private var summary = "" + @State private var summaryIsResponding = false @State private var error: Error? - var onTapDropButton: ((Stash) -> Void)? - + @State private var summaryGenerationError: Error? + @State private var generateSummaryTask: Task<(), Never>? + @Environment(\.systemLanguageModelAvailability) private var systemLanguageModelAvailability + var body: some View { NavigationSplitView { List(selection: $selectionStashID) { @@ -42,6 +48,7 @@ struct StashChangedContentView: View { } } } + .navigationSplitViewColumnWidth(ideal: 200) } detail: { ScrollView { VStack(spacing: 0) { @@ -56,10 +63,29 @@ struct StashChangedContentView: View { Spacer(minLength: 0) } } - .safeAreaInset(edge: .bottom, content: { + .scrollEdgeEffectStyle(.soft, for: .bottom) + .safeAreaBar(edge: .bottom, content: { VStack (spacing: 0) { - Divider() + DiffSummaryView(fileDiffs: fileDiffs) HStack { + Button { + fileDiffs = fileDiffs.map { + ExpandableModel(isExpanded: true, model: $0.model) + } + } label: { + Image(systemName: "arrow.up.and.line.horizontal.and.arrow.down") + } + .help("Expand All Files") + .buttonStyle(.plain) + Button { + fileDiffs = fileDiffs.map { + ExpandableModel(isExpanded: false, model: $0.model) + } + } label: { + Image(systemName: "arrow.down.and.line.horizontal.and.arrow.up") + } + .help("Collapse All Files") + .buttonStyle(.plain) Spacer() Button("Cancel") { showingStashChanged.toggle() @@ -79,16 +105,14 @@ struct StashChangedContentView: View { .disabled(selectionStashID == nil) } .padding() - .background(.bar) } }) } - .onChange(of: selectionStashID, { - Task { - await updateDiff() - } + .background(Color(NSColor.textBackgroundColor)) + .task(id: selectionStashID, { + await updateDiff() }) - .frame(minWidth: 800, minHeight: 700) + .frame(width: 800, height: 700) .errorSheet($error) } @@ -104,6 +128,29 @@ struct StashChangedContentView: View { self.error = error } } + + private func generateSummary() async { + summary = "" + summaryIsResponding = true + summaryGenerationError = nil + do { + let diffRaw = fileDiffs.map { fileDiff in + fileDiff.model.raw + }.joined(separator: "\n") + if !diffRaw.isEmpty { + let stream = SystemLanguageModelService().diffSummary(diffRaw) + for try await text in stream { + if !Task.isCancelled { + summary = text.content.summary ?? "" + } + } + } + } catch { + summaryGenerationError = error + } + summaryIsResponding = false + } + } #Preview { diff --git a/GitClientTests/BranchTests.swift b/GitClientTests/BranchTests.swift index af68ac29..4b30bc49 100644 --- a/GitClientTests/BranchTests.swift +++ b/GitClientTests/BranchTests.swift @@ -6,7 +6,7 @@ // import XCTest -@testable import Tempo +@testable import Changes final class BranchTests: XCTestCase { func testPoint() throws { diff --git a/GitClientTests/DefaultMergeCommitMessageTests.swift b/GitClientTests/DefaultMergeCommitMessageTests.swift index 6e5dfc3c..b29d5514 100644 --- a/GitClientTests/DefaultMergeCommitMessageTests.swift +++ b/GitClientTests/DefaultMergeCommitMessageTests.swift @@ -6,7 +6,7 @@ // import Testing -@testable import Tempo +@testable import Changes import Foundation struct DefaultMergeCommitMessageTests { diff --git a/GitClientTests/DiffTests.swift b/GitClientTests/DiffTests.swift index 98c28294..9f1c32d0 100644 --- a/GitClientTests/DiffTests.swift +++ b/GitClientTests/DiffTests.swift @@ -6,7 +6,7 @@ // import XCTest -@testable import Tempo +@testable import Changes final class DiffTests: XCTestCase { let raw = """ diff --git a/GitClientTests/ExpandableModelTests.swift b/GitClientTests/ExpandableModelTests.swift index 4b359af1..5684b1e9 100644 --- a/GitClientTests/ExpandableModelTests.swift +++ b/GitClientTests/ExpandableModelTests.swift @@ -6,7 +6,7 @@ // import Testing -@testable import Tempo +@testable import Changes struct ExpandableModelTests { diff --git a/GitClientTests/GitDiffNumStatTests.swift b/GitClientTests/GitDiffNumStatTests.swift index a3ae75eb..3fbb95dc 100644 --- a/GitClientTests/GitDiffNumStatTests.swift +++ b/GitClientTests/GitDiffNumStatTests.swift @@ -6,7 +6,7 @@ // import XCTest -@testable import Tempo +@testable import Changes final class GitDiffNumStatTests: XCTestCase { func testParse() throws { diff --git a/GitClientTests/GitLogTests.swift b/GitClientTests/GitLogTests.swift index cd18e3e9..8a90d351 100644 --- a/GitClientTests/GitLogTests.swift +++ b/GitClientTests/GitLogTests.swift @@ -7,7 +7,7 @@ import Testing import Foundation -@testable import Tempo +@testable import Changes struct GitLogTests { @Test func parse() async throws { diff --git a/GitClientTests/GitRevParseTests.swift b/GitClientTests/GitRevParseTests.swift index ff1c319e..87d4396c 100644 --- a/GitClientTests/GitRevParseTests.swift +++ b/GitClientTests/GitRevParseTests.swift @@ -7,7 +7,7 @@ import Testing import Foundation -@testable import Tempo +@testable import Changes struct GitRevParseTests { diff --git a/GitClientTests/GitShowShortstatTests.swift b/GitClientTests/GitShowShortstatTests.swift index 42be442f..ebf0a3d8 100644 --- a/GitClientTests/GitShowShortstatTests.swift +++ b/GitClientTests/GitShowShortstatTests.swift @@ -7,7 +7,7 @@ import Testing import Foundation -@testable import Tempo +@testable import Changes struct GitShowShortstatTests { @Test func output() async throws { diff --git a/GitClientTests/GitStatusTests.swift b/GitClientTests/GitStatusTests.swift index 8eace7ea..6a07e842 100644 --- a/GitClientTests/GitStatusTests.swift +++ b/GitClientTests/GitStatusTests.swift @@ -6,7 +6,7 @@ // import Testing -@testable import Tempo +@testable import Changes import Foundation struct GitStatusTests { diff --git a/GitClientTests/LogStoreTests.swift b/GitClientTests/LogStoreTests.swift index 08b2210b..dc2b1a83 100644 --- a/GitClientTests/LogStoreTests.swift +++ b/GitClientTests/LogStoreTests.swift @@ -7,7 +7,7 @@ import Testing import Foundation -@testable import Tempo +@testable import Changes @MainActor struct LogStoreTests { diff --git a/GitClientTests/SearchTokensHandlerTests.swift b/GitClientTests/SearchTokensHandlerTests.swift index 7121c325..2c391c3e 100644 --- a/GitClientTests/SearchTokensHandlerTests.swift +++ b/GitClientTests/SearchTokensHandlerTests.swift @@ -6,7 +6,7 @@ // import Testing -@testable import Tempo +@testable import Changes struct SearchTokensHandlerTests { @Test func handleGrep() async throws { diff --git a/GitClientTests/SyncStateTests.swift b/GitClientTests/SyncStateTests.swift index adc2ee10..6f080cf7 100644 --- a/GitClientTests/SyncStateTests.swift +++ b/GitClientTests/SyncStateTests.swift @@ -6,7 +6,7 @@ // import Testing -@testable import Tempo +@testable import Changes import Foundation @MainActor diff --git a/GitClientTests/SystemLanguageModelServiceTests.swift b/GitClientTests/SystemLanguageModelServiceTests.swift new file mode 100644 index 00000000..be63bcbf --- /dev/null +++ b/GitClientTests/SystemLanguageModelServiceTests.swift @@ -0,0 +1,348 @@ +// +// SystemLanguageModelServiceTests.swift +// GitClientTests +// +// Created by Makoto Aoyama on 2025/06/17. +// + +import Testing +@testable import Changes +import FoundationModels +import Foundation + +struct SystemLanguageModelServiceTests { + + @Test func commitMessage() async throws { + var generatedCommitMessage = "" + let messages = SystemLanguageModelService().commitMessage(stagedDiff: """ + diff --git a/GitClient/Views/Folder/CommitGraphView.swift b/GitClient/Views/Folder/CommitGraphView.swift + index 5f79207..4660cf4 100644 + --- a/GitClient/Views/Folder/CommitGraphView.swift + +++ b/GitClient/Views/Folder/CommitGraphView.swift + @@ -51,7 +51,6 @@ struct CommitGraphView: View { + .padding(.bottom, 22) + } + } + - .background(Color(NSColor.textBackgroundColor)) + .focusable() + .focusEffectDisabled() + .onMoveCommand { direction in + """) + for try await message in messages { + generatedCommitMessage = message.content.commitMessage ?? "" + } + print(generatedCommitMessage) + #expect(!generatedCommitMessage.isEmpty) + + let messages2 = SystemLanguageModelService().commitMessage(stagedDiff: """ + diff --git a/GitClient/Models/Observables/LogStore.swift b/GitClient/Models/Observables/LogStore.swift + index 8a43562..226c84e 100644 + --- a/GitClient/Models/Observables/LogStore.swift + +++ b/GitClient/Models/Observables/LogStore.swift + @@ -147,6 +147,24 @@ import Observation + } + } + + + func nextLogID(logID: String) -> String? { + + if logID == Log.notCommitted.id { + + return commits.first?.id + + } + + let commit = commits.first { $0.id == logID } + + guard let commit else { return nil } + + return commit.parentHashes.last + + } + + + + func previousLogID(logID: String) -> String? { + + if logID == Log.notCommitted.id { + + return nil + + } + + let index = commits.firstIndex { $0.id == logID } + + guard let index, index != 0 else { return nil } + + return commits[index - 1].id + + } + + + private func notCommited(directory: URL) async throws -> NotCommitted { + let gitDiff = try await Process.output(GitDiff(directory: directory)) + let gitDiffCached = try await Process.output(GitDiffCached(directory: directory)) + """) + var generatedCommitMessage2 = "" + + for try await message2 in messages2 { + generatedCommitMessage2 = message2.content.commitMessage ?? "" + } + print(generatedCommitMessage2) + #expect(!generatedCommitMessage2.isEmpty) + } + + @Test func diffSummary() async throws { + var generated = "" + let summary = SystemLanguageModelService().diffSummary(""" + diff --git a/GitClient/Views/Folder/CommitGraphView.swift b/GitClient/Views/Folder/CommitGraphView.swift + index 5f79207..4660cf4 100644 + --- a/GitClient/Views/Folder/CommitGraphView.swift + +++ b/GitClient/Views/Folder/CommitGraphView.swift + @@ -51,7 +51,6 @@ struct CommitGraphView: View { + .padding(.bottom, 22) + } + } + - .background(Color(NSColor.textBackgroundColor)) + .focusable() + .focusEffectDisabled() + .onMoveCommand { direction in + """) + for try await text in summary { + generated = text.content.summary ?? "" + } + print(generated) + #expect(!generated.isEmpty) + + let summary2 = SystemLanguageModelService().diffSummary(""" + diff --git a/GitClient/Models/Observables/LogStore.swift b/GitClient/Models/Observables/LogStore.swift + index 8a43562..226c84e 100644 + --- a/GitClient/Models/Observables/LogStore.swift + +++ b/GitClient/Models/Observables/LogStore.swift + @@ -147,6 +147,24 @@ import Observation + } + } + + + func nextLogID(logID: String) -> String? { + + if logID == Log.notCommitted.id { + + return commits.first?.id + + } + + let commit = commits.first { $0.id == logID } + + guard let commit else { return nil } + + return commit.parentHashes.last + + } + + + + func previousLogID(logID: String) -> String? { + + if logID == Log.notCommitted.id { + + return nil + + } + + let index = commits.firstIndex { $0.id == logID } + + guard let index, index != 0 else { return nil } + + return commits[index - 1].id + + } + + + private func notCommited(directory: URL) async throws -> NotCommitted { + let gitDiff = try await Process.output(GitDiff(directory: directory)) + let gitDiffCached = try await Process.output(GitDiffCached(directory: directory)) + """) + var generated2 = "" + + for try await text in summary2 { + generated2 = text.content.summary ?? "" + } + print(generated2) + #expect(!generated2.isEmpty) + } + + @Test func commitMessageWithTool() async throws { + let stagedDiffRaw = """ + diff --git a/GitClient/Views/Folder/CommitGraphView.swift b/GitClient/Views/Folder/CommitGraphView.swift + index 5f79207..4660cf4 100644 + --- a/GitClient/Views/Folder/CommitGraphView.swift + +++ b/GitClient/Views/Folder/CommitGraphView.swift + @@ -51,7 +51,6 @@ struct CommitGraphView: View { + .padding(.bottom, 22) + } + } + - .background(Color(NSColor.textBackgroundColor)) + .focusable() + .focusEffectDisabled() + .onMoveCommand { direction in + """ + let message = try await SystemLanguageModelService().commitMessage(tools: [UncommitedChangesStubTool(cachedDiffRaw: stagedDiffRaw, diffRaw: "")]) + print(message) + #expect(!message.isEmpty) + + let stagedDiffRaw2 = """ + diff --git a/GitClient/Models/Observables/LogStore.swift b/GitClient/Models/Observables/LogStore.swift + index 8a43562..226c84e 100644 + --- a/GitClient/Models/Observables/LogStore.swift + +++ b/GitClient/Models/Observables/LogStore.swift + @@ -147,6 +147,24 @@ import Observation + } + } + + + func nextLogID(logID: String) -> String? { + + if logID == Log.notCommitted.id { + + return commits.first?.id + + } + + let commit = commits.first { $0.id == logID } + + guard let commit else { return nil } + + return commit.parentHashes.last + + } + + + + func previousLogID(logID: String) -> String? { + + if logID == Log.notCommitted.id { + + return nil + + } + + let index = commits.firstIndex { $0.id == logID } + + guard let index, index != 0 else { return nil } + + return commits[index - 1].id + + } + + + private func notCommited(directory: URL) async throws -> NotCommitted { + let gitDiff = try await Process.output(GitDiff(directory: directory)) + let gitDiffCached = try await Process.output(GitDiffCached(directory: directory)) + """ + let message2 = try await SystemLanguageModelService().commitMessage(tools: [UncommitedChangesStubTool(cachedDiffRaw: stagedDiffRaw2, diffRaw: "")]) + print(message2) + #expect(!message2.isEmpty) + } + + @Test func commitMessageWithStagedChangesTool() async throws { + let stagedDiffRaw = """ + diff --git a/GitClient/Views/Folder/CommitGraphView.swift b/GitClient/Views/Folder/CommitGraphView.swift + index 5f79207..4660cf4 100644 + --- a/GitClient/Views/Folder/CommitGraphView.swift + +++ b/GitClient/Views/Folder/CommitGraphView.swift + @@ -51,7 +51,6 @@ struct CommitGraphView: View { + .padding(.bottom, 22) + } + } + - .background(Color(NSColor.textBackgroundColor)) + .focusable() + .focusEffectDisabled() + .onMoveCommand { direction in + """ + let message = try await SystemLanguageModelService().commitMessage(tools: [StagedChangesToolStub(cachedDiffRaw: stagedDiffRaw)]) + print(message) + #expect(!message.isEmpty) + + let stagedDiffRaw2 = """ + diff --git a/GitClient/Models/Observables/LogStore.swift b/GitClient/Models/Observables/LogStore.swift + index 8a43562..226c84e 100644 + --- a/GitClient/Models/Observables/LogStore.swift + +++ b/GitClient/Models/Observables/LogStore.swift + @@ -147,6 +147,24 @@ import Observation + } + } + + + func nextLogID(logID: String) -> String? { + + if logID == Log.notCommitted.id { + + return commits.first?.id + + } + + let commit = commits.first { $0.id == logID } + + guard let commit else { return nil } + + return commit.parentHashes.last + + } + + + + func previousLogID(logID: String) -> String? { + + if logID == Log.notCommitted.id { + + return nil + + } + + let index = commits.firstIndex { $0.id == logID } + + guard let index, index != 0 else { return nil } + + return commits[index - 1].id + + } + + + private func notCommited(directory: URL) async throws -> NotCommitted { + let gitDiff = try await Process.output(GitDiff(directory: directory)) + let gitDiffCached = try await Process.output(GitDiffCached(directory: directory)) + """ + let message2 = try await SystemLanguageModelService().commitMessage(tools: [UncommitedChangesStubTool(cachedDiffRaw: stagedDiffRaw2, diffRaw: "")]) + print(message2) + #expect(!message2.isEmpty) + } + + @Test func stagingChanges() async throws { + let hunksToStages = try await SystemLanguageModelService().stagingChanges(unstagedDiff: """ + diff --git a/GitClient/Views/Folder/CommitGraphView.swift b/GitClient/Views/Folder/CommitGraphView.swift + index 5f79207..4660cf4 100644 + --- a/GitClient/Views/Folder/CommitGraphView.swift + +++ b/GitClient/Views/Folder/CommitGraphView.swift + @@ -51,7 +51,6 @@ struct CommitGraphView: View { + .padding(.bottom, 22) + } + } + - .background(Color(NSColor.textBackgroundColor)) + .focusable() + .focusEffectDisabled() + .onMoveCommand { direction in + """) + print(hunksToStages) + #expect(!hunksToStages.isEmpty) + } + + @Test func stagingChangesWithTool() async throws { + let hunksToStages = try await SystemLanguageModelService().stagingChanges(tools: [UnstagedChangesToolStub(diffRaw: """ + diff --git a/GitClient/Views/Folder/CommitGraphView.swift b/GitClient/Views/Folder/CommitGraphView.swift + index 5f79207..4660cf4 100644 + --- a/GitClient/Views/Folder/CommitGraphView.swift + +++ b/GitClient/Views/Folder/CommitGraphView.swift + @@ -51,7 +51,6 @@ struct CommitGraphView: View { + .padding(.bottom, 22) + } + } + - .background(Color(NSColor.textBackgroundColor)) + .focusable() + .focusEffectDisabled() + .onMoveCommand { direction in + """)]) + print(hunksToStages) + } + + @Test func commitHashes() async throws { + let commitHashes = try await SystemLanguageModelService().commitHashes( + SearchArguments(), + prompt: ["Commits which updated README.md"], + directory: .testFixture! + ) + print(commitHashes) + } + + @Test func commitHashes2() async throws { + let commitHashes = try await SystemLanguageModelService().commitHashes( + SearchArguments(), + prompt: ["最近行ったテストコードの変更のコミット"], + directory: .testFixture! + ) + print(commitHashes) + } +} + + +struct UncommitedChangesStubTool: Tool { + @Generable + struct Arguments {} + + let name = UncommitedChangesTool(directory: .testFixture!).name + let description: String = UncommitedChangesTool(directory: .testFixture!).description + let cachedDiffRaw: String + let diffRaw: String + + func call(arguments: Arguments) async throws -> some PromptRepresentable { + let diff = try Diff(raw: diffRaw).fileDiffs.map { $0.raw } + let cachedDiff = try Diff(raw: cachedDiffRaw).fileDiffs.map { $0.raw } + return UncommitedChanges(stagedChanges: cachedDiff, unstagedChanges: diff) + } +} + +struct StagedChangesToolStub: Tool { + @Generable + struct Arguments {} + + let name = StagedChangesTool(directory: .testFixture!).name + let description: String = StagedChangesTool(directory: .testFixture!).description + let cachedDiffRaw: String + + func call(arguments: Arguments) async throws -> some PromptRepresentable { + let cachedDiff = try Diff(raw: cachedDiffRaw).fileDiffs.map { $0.raw } + return cachedDiff + } +} + +struct UnstagedChangesToolStub: Tool { + @Generable + struct Arguments {} + + let name = UnstagedChangesTool(directory: .testFixture!).name + let description: String = UnstagedChangesTool(directory: .testFixture!).description + let diffRaw: String + + func call(arguments: Arguments) async throws -> some PromptRepresentable { + let diff = try Diff(raw: diffRaw).fileDiffs.map { $0.raw } + return diff + } +} diff --git a/README.md b/README.md index f0b01756..ff3779c6 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,18 @@ -# Tempo - GUI Git Client +# Changes - An Open Source GUI Git Client for macOS -A Mac app that is a GUI Git client, written in SwiftUI and AppKit. -Tempo.app replaces CLI-based Git operations with a Mac-specific, clear GUI. -Instead of typing numerous commands, you can focus on coding while effortlessly managing Git tasks with simple button clicks. +Changes.app is a modern Git client for Mac, built with SwiftUI and AppKit and styled with Apple’s new Liquid Glass design. +It replaces complex CLI commands with a clear, Mac-native interface that keeps cognitive load low, so you can focus on what really matters—coding. +With commit message generation powered by Apple Intelligence, creating clear and meaningful commits becomes effortless. -By setting the OpenAI API secret key in the app, you can generate commit messages and perform staging by hunk using the OpenAI API. Since this app utilizes GPT-4o-mini, it remains relatively inexpensive, allowing you to use it without worrying too much about costs (the API is priced at 15 cents per 1M input tokens and 60 cents per 1M output tokens). - -![Screenshot](Screenshots/Screenshot5.png) -![Screenshot](Screenshots/Screenshot6.png) -![Screenshot](Screenshots/Screenshot7.png) -![Screenshot](Screenshots/Screenshot9.png) +![Screenshot](Screenshots/Screenshot2-1.png) +![Screenshot](Screenshots/Screenshot2-2.png) ## Features -This app provides the following Git features: +This app provides the following features: - Commit - Amend + - Commit Message Generation using Apple Intelligence - Commit Message Snippets - Revert - Checkout @@ -41,5 +38,7 @@ This app provides the following Git features: ## Installation -Download the latest [release](https://github.com/maoyama/Tempo/releases), unzip, and run Tempo.app. +Download the latest [release](https://github.com/maoyama/Tempo/releases), unzip, and run the app. +- v2.0+ (macOS 26.0 or later) +- [v1.18](https://github.com/maoyama/Tempo/releases/tag/v1.18) and below (macOS 14.6 or later) - Tempo.app (old name for v1.x) diff --git a/Screenshots/Screenshot2-1.png b/Screenshots/Screenshot2-1.png new file mode 100644 index 00000000..bdb6bd32 Binary files /dev/null and b/Screenshots/Screenshot2-1.png differ diff --git a/Screenshots/Screenshot2-2.png b/Screenshots/Screenshot2-2.png new file mode 100644 index 00000000..b2461511 Binary files /dev/null and b/Screenshots/Screenshot2-2.png differ diff --git a/Screenshots/Screenshot5.png b/Screenshots/Screenshot5.png deleted file mode 100644 index 784aed59..00000000 Binary files a/Screenshots/Screenshot5.png and /dev/null differ diff --git a/Screenshots/Screenshot6.png b/Screenshots/Screenshot6.png deleted file mode 100644 index 3d5e7f4f..00000000 Binary files a/Screenshots/Screenshot6.png and /dev/null differ diff --git a/Screenshots/Screenshot7.png b/Screenshots/Screenshot7.png deleted file mode 100644 index f44d5e07..00000000 Binary files a/Screenshots/Screenshot7.png and /dev/null differ diff --git a/Screenshots/Screenshot9.png b/Screenshots/Screenshot9.png deleted file mode 100644 index b31558a9..00000000 Binary files a/Screenshots/Screenshot9.png and /dev/null differ