commit 5adc0102ebe4bbeeb718045da7d76a107706b9c5 Author: Stefan Hausotte Date: Sat Feb 28 21:08:13 2026 +0100 feat: inital commit Forji is an iOS app to interact with a Forgejo instance diff --git a/Forji/Forji.xcodeproj/project.pbxproj b/Forji/Forji.xcodeproj/project.pbxproj new file mode 100644 index 0000000..efef86d --- /dev/null +++ b/Forji/Forji.xcodeproj/project.pbxproj @@ -0,0 +1,683 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + DE00000000000001000000AA /* ForgejoKit in Frameworks */ = {isa = PBXBuildFile; productRef = DE00000000000002000000AA /* ForgejoKit */; }; + DE00000000000003000000AA /* ForgejoKit in Frameworks */ = {isa = PBXBuildFile; productRef = DE00000000000004000000AA /* ForgejoKit */; }; + DE00000000000006000000BB /* AppIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE00000000000007000000BB /* AppIntents.framework */; }; + DE00000000000008000000BB /* AppIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE00000000000007000000BB /* AppIntents.framework */; }; + DE00000000000009000000BB /* AppIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE00000000000007000000BB /* AppIntents.framework */; }; + DEC49F6E2F3D023400E7DD54 /* Textual in Frameworks */ = {isa = PBXBuildFile; productRef = DEC49F6D2F3D023400E7DD54 /* Textual */; }; + DEC49F832F3D173F00E7DD54 /* HighlightSwift in Frameworks */ = {isa = PBXBuildFile; productRef = DEC49F822F3D173F00E7DD54 /* HighlightSwift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + DEC49F312F3CE05400E7DD54 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DEC49F192F3CE05200E7DD54 /* Project object */; + proxyType = 1; + remoteGlobalIDString = DEC49F202F3CE05200E7DD54; + remoteInfo = Forji; + }; + DEC49F3B2F3CE05400E7DD54 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DEC49F192F3CE05200E7DD54 /* Project object */; + proxyType = 1; + remoteGlobalIDString = DEC49F202F3CE05200E7DD54; + remoteInfo = Forji; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + DE00000000000007000000BB /* AppIntents.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppIntents.framework; path = System/Library/Frameworks/AppIntents.framework; sourceTree = SDKROOT; }; + DEC49F212F3CE05200E7DD54 /* Forji.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Forji.app; sourceTree = BUILT_PRODUCTS_DIR; }; + DEC49F302F3CE05400E7DD54 /* ForjiTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ForjiTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + DEC49F3A2F3CE05400E7DD54 /* ForjiUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ForjiUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + DEC49F232F3CE05200E7DD54 /* Forji */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Forji; + sourceTree = ""; + }; + DEC49F332F3CE05400E7DD54 /* ForjiTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = ForjiTests; + sourceTree = ""; + }; + DEC49F3D2F3CE05400E7DD54 /* ForjiUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = ForjiUITests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + DEC49F1E2F3CE05200E7DD54 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DE00000000000001000000AA /* ForgejoKit in Frameworks */, + DE00000000000006000000BB /* AppIntents.framework in Frameworks */, + DEC49F6E2F3D023400E7DD54 /* Textual in Frameworks */, + DEC49F832F3D173F00E7DD54 /* HighlightSwift in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DEC49F2D2F3CE05400E7DD54 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DE00000000000003000000AA /* ForgejoKit in Frameworks */, + DE00000000000008000000BB /* AppIntents.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DEC49F372F3CE05400E7DD54 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DE00000000000009000000BB /* AppIntents.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + DEC49F182F3CE05200E7DD54 = { + isa = PBXGroup; + children = ( + DEC49F232F3CE05200E7DD54 /* Forji */, + DEC49F332F3CE05400E7DD54 /* ForjiTests */, + DEC49F3D2F3CE05400E7DD54 /* ForjiUITests */, + DEC49F6C2F3D023400E7DD54 /* Frameworks */, + DEC49F222F3CE05200E7DD54 /* Products */, + ); + sourceTree = ""; + }; + DEC49F222F3CE05200E7DD54 /* Products */ = { + isa = PBXGroup; + children = ( + DEC49F212F3CE05200E7DD54 /* Forji.app */, + DEC49F302F3CE05400E7DD54 /* ForjiTests.xctest */, + DEC49F3A2F3CE05400E7DD54 /* ForjiUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + DEC49F6C2F3D023400E7DD54 /* Frameworks */ = { + isa = PBXGroup; + children = ( + DE00000000000007000000BB /* AppIntents.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + DEC49F202F3CE05200E7DD54 /* Forji */ = { + isa = PBXNativeTarget; + buildConfigurationList = DEC49F442F3CE05400E7DD54 /* Build configuration list for PBXNativeTarget "Forji" */; + buildPhases = ( + DEC49F1D2F3CE05200E7DD54 /* Sources */, + DEC49F1E2F3CE05200E7DD54 /* Frameworks */, + DEC49F1F2F3CE05200E7DD54 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + DEC49F232F3CE05200E7DD54 /* Forji */, + ); + name = Forji; + packageProductDependencies = ( + DE00000000000002000000AA /* ForgejoKit */, + DEC49F6D2F3D023400E7DD54 /* Textual */, + DEC49F822F3D173F00E7DD54 /* HighlightSwift */, + ); + productName = Forji; + productReference = DEC49F212F3CE05200E7DD54 /* Forji.app */; + productType = "com.apple.product-type.application"; + }; + DEC49F2F2F3CE05400E7DD54 /* ForjiTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = DEC49F472F3CE05400E7DD54 /* Build configuration list for PBXNativeTarget "ForjiTests" */; + buildPhases = ( + DEC49F2C2F3CE05400E7DD54 /* Sources */, + DEC49F2D2F3CE05400E7DD54 /* Frameworks */, + DEC49F2E2F3CE05400E7DD54 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + DEC49F322F3CE05400E7DD54 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + DEC49F332F3CE05400E7DD54 /* ForjiTests */, + ); + name = ForjiTests; + packageProductDependencies = ( + DE00000000000004000000AA /* ForgejoKit */, + ); + productName = ForjiTests; + productReference = DEC49F302F3CE05400E7DD54 /* ForjiTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + DEC49F392F3CE05400E7DD54 /* ForjiUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = DEC49F4A2F3CE05400E7DD54 /* Build configuration list for PBXNativeTarget "ForjiUITests" */; + buildPhases = ( + DEC49F362F3CE05400E7DD54 /* Sources */, + DEC49F372F3CE05400E7DD54 /* Frameworks */, + DEC49F382F3CE05400E7DD54 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + DEC49F3C2F3CE05400E7DD54 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + DEC49F3D2F3CE05400E7DD54 /* ForjiUITests */, + ); + name = ForjiUITests; + packageProductDependencies = ( + ); + productName = ForjiUITests; + productReference = DEC49F3A2F3CE05400E7DD54 /* ForjiUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + DEC49F192F3CE05200E7DD54 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2620; + LastUpgradeCheck = 2620; + TargetAttributes = { + DEC49F202F3CE05200E7DD54 = { + CreatedOnToolsVersion = 26.2; + }; + DEC49F2F2F3CE05400E7DD54 = { + CreatedOnToolsVersion = 26.2; + TestTargetID = DEC49F202F3CE05200E7DD54; + }; + DEC49F392F3CE05400E7DD54 = { + CreatedOnToolsVersion = 26.2; + TestTargetID = DEC49F202F3CE05200E7DD54; + }; + }; + }; + buildConfigurationList = DEC49F1C2F3CE05200E7DD54 /* Build configuration list for PBXProject "Forji" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = DEC49F182F3CE05200E7DD54; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + DE00000000000005000000AA /* XCRemoteSwiftPackageReference "ForgejoKit" */, + DEC49F6B2F3D00C700E7DD54 /* XCRemoteSwiftPackageReference "textual" */, + DEC49F812F3D173F00E7DD54 /* XCRemoteSwiftPackageReference "HighlightSwift" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = DEC49F222F3CE05200E7DD54 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + DEC49F202F3CE05200E7DD54 /* Forji */, + DEC49F2F2F3CE05400E7DD54 /* ForjiTests */, + DEC49F392F3CE05400E7DD54 /* ForjiUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + DEC49F1F2F3CE05200E7DD54 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DEC49F2E2F3CE05400E7DD54 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DEC49F382F3CE05400E7DD54 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + DEC49F1D2F3CE05200E7DD54 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DEC49F2C2F3CE05400E7DD54 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DEC49F362F3CE05400E7DD54 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + DEC49F322F3CE05400E7DD54 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DEC49F202F3CE05200E7DD54 /* Forji */; + targetProxy = DEC49F312F3CE05400E7DD54 /* PBXContainerItemProxy */; + }; + DEC49F3C2F3CE05400E7DD54 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DEC49F202F3CE05200E7DD54 /* Forji */; + targetProxy = DEC49F3B2F3CE05400E7DD54 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + DEC49F422F3CE05400E7DD54 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = RVT2M7QTD4; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + DEC49F432F3CE05400E7DD54 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = RVT2M7QTD4; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + DEC49F452F3CE05400E7DD54 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = RVT2M7QTD4; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Forji; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = de.hausotte.Forji; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_CONST_VALUE_PROTOCOLS = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + DEC49F462F3CE05400E7DD54 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = RVT2M7QTD4; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Forji; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = de.hausotte.Forji; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_CONST_VALUE_PROTOCOLS = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + DEC49F482F3CE05400E7DD54 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = RVT2M7QTD4; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = de.hausotte.ForjiTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_CONST_VALUE_PROTOCOLS = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Forji.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Forji"; + }; + name = Debug; + }; + DEC49F492F3CE05400E7DD54 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = RVT2M7QTD4; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = de.hausotte.ForjiTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_CONST_VALUE_PROTOCOLS = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Forji.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Forji"; + }; + name = Release; + }; + DEC49F4B2F3CE05400E7DD54 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = RVT2M7QTD4; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = de.hausotte.ForjiUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_CONST_VALUE_PROTOCOLS = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Forji; + }; + name = Debug; + }; + DEC49F4C2F3CE05400E7DD54 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = RVT2M7QTD4; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = de.hausotte.ForjiUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_CONST_VALUE_PROTOCOLS = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Forji; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + DEC49F1C2F3CE05200E7DD54 /* Build configuration list for PBXProject "Forji" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DEC49F422F3CE05400E7DD54 /* Debug */, + DEC49F432F3CE05400E7DD54 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + DEC49F442F3CE05400E7DD54 /* Build configuration list for PBXNativeTarget "Forji" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DEC49F452F3CE05400E7DD54 /* Debug */, + DEC49F462F3CE05400E7DD54 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + DEC49F472F3CE05400E7DD54 /* Build configuration list for PBXNativeTarget "ForjiTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DEC49F482F3CE05400E7DD54 /* Debug */, + DEC49F492F3CE05400E7DD54 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + DEC49F4A2F3CE05400E7DD54 /* Build configuration list for PBXNativeTarget "ForjiUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DEC49F4B2F3CE05400E7DD54 /* Debug */, + DEC49F4C2F3CE05400E7DD54 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + DE00000000000005000000AA /* XCRemoteSwiftPackageReference "ForgejoKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://codeberg.org/secana/ForgejoKit.git"; + requirement = { + kind = exactVersion; + version = 0.1.0; + }; + }; + DEC49F6B2F3D00C700E7DD54 /* XCRemoteSwiftPackageReference "textual" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/gonzalezreal/textual"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.3.1; + }; + }; + DEC49F812F3D173F00E7DD54 /* XCRemoteSwiftPackageReference "HighlightSwift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/appstefan/HighlightSwift.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.9; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + DE00000000000002000000AA /* ForgejoKit */ = { + isa = XCSwiftPackageProductDependency; + package = DE00000000000005000000AA /* XCRemoteSwiftPackageReference "ForgejoKit" */; + productName = ForgejoKit; + }; + DE00000000000004000000AA /* ForgejoKit */ = { + isa = XCSwiftPackageProductDependency; + package = DE00000000000005000000AA /* XCRemoteSwiftPackageReference "ForgejoKit" */; + productName = ForgejoKit; + }; + DEC49F6D2F3D023400E7DD54 /* Textual */ = { + isa = XCSwiftPackageProductDependency; + package = DEC49F6B2F3D00C700E7DD54 /* XCRemoteSwiftPackageReference "textual" */; + productName = Textual; + }; + DEC49F822F3D173F00E7DD54 /* HighlightSwift */ = { + isa = XCSwiftPackageProductDependency; + package = DEC49F812F3D173F00E7DD54 /* XCRemoteSwiftPackageReference "HighlightSwift" */; + productName = HighlightSwift; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = DEC49F192F3CE05200E7DD54 /* Project object */; +} diff --git a/Forji/Forji.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Forji/Forji.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Forji/Forji.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Forji/Forji.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Forji/Forji.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..297298f --- /dev/null +++ b/Forji/Forji.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,51 @@ +{ + "originHash" : "931ec0beeaf4e6a5eaa0afab6f815f97bc126bda7a2c9f001c10de58585e766f", + "pins" : [ + { + "identity" : "forgejokit", + "kind" : "remoteSourceControl", + "location" : "https://codeberg.org/secana/ForgejoKit.git", + "state" : { + "revision" : "897a8ebeddfc97444c27752df8e99eda589eeaba", + "version" : "0.1.0" + } + }, + { + "identity" : "highlightswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/appstefan/HighlightSwift.git", + "state" : { + "revision" : "784ca3ccfc8a2cf724fbb2f06cb6ec3329424da3", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", + "version" : "1.3.2" + } + }, + { + "identity" : "swiftui-math", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/swiftui-math", + "state" : { + "revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71", + "version" : "0.1.0" + } + }, + { + "identity" : "textual", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/textual", + "state" : { + "revision" : "5b06b811c0f5313b6b84bbef98c635a630638c38", + "version" : "0.3.1" + } + } + ], + "version" : 3 +} diff --git a/Forji/Forji.xcodeproj/project.xcworkspace/xcuserdata/hausi.xcuserdatad/UserInterfaceState.xcuserstate b/Forji/Forji.xcodeproj/project.xcworkspace/xcuserdata/hausi.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..abe45ef Binary files /dev/null and b/Forji/Forji.xcodeproj/project.xcworkspace/xcuserdata/hausi.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Forji/Forji.xcodeproj/xcshareddata/xcschemes/Forji.xcscheme b/Forji/Forji.xcodeproj/xcshareddata/xcschemes/Forji.xcscheme new file mode 100644 index 0000000..f9a1808 --- /dev/null +++ b/Forji/Forji.xcodeproj/xcshareddata/xcschemes/Forji.xcscheme @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Forji/Forji.xcodeproj/xcuserdata/hausi.xcuserdatad/xcschemes/xcschememanagement.plist b/Forji/Forji.xcodeproj/xcuserdata/hausi.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..b00ac61 --- /dev/null +++ b/Forji/Forji.xcodeproj/xcuserdata/hausi.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,22 @@ + + + + + SchemeUserState + + Forji.xcscheme_^#shared#^_ + + orderHint + 0 + + + SuppressBuildableAutocreation + + DEC49F202F3CE05200E7DD54 + + primary + + + + + diff --git a/Forji/Forji/App/ContentView.swift b/Forji/Forji/App/ContentView.swift new file mode 100644 index 0000000..2155f17 --- /dev/null +++ b/Forji/Forji/App/ContentView.swift @@ -0,0 +1,100 @@ +import ForgejoKit +import SwiftData +import SwiftUI + +enum AppAppearance: Int, CaseIterable { + case system = 0 + case light = 1 + case dark = 2 + + var colorScheme: ColorScheme? { + switch self { + case .system: nil + case .light: .light + case .dark: .dark + } + } +} + +struct ContentView: View { + @AppStorage("appearance") private var appearance: AppAppearance = .system + @State private var authService = AuthenticationService() + @Query(filter: #Predicate { $0.isDefault }) private var defaultInstances: [ForgejoInstance] + @State private var hasAttemptedAutoLogin = false + @Environment(\.modelContext) private var modelContext + + // Dev auto-login credentials, set via launch arguments for development and UI integration tests + #if DEBUG + @AppStorage("dev_serverURL") private var devServerURL = "" + @AppStorage("dev_username") private var devUsername = "" + @AppStorage("dev_password") private var devPassword = "" + @AppStorage("dev_skipAutoLogin") private var devSkipAutoLogin = false + #endif + + var body: some View { + Group { + if authService.isAuthenticated, authService.client != nil { + HomeView(authService: authService) + } else if !hasAttemptedAutoLogin { + ProgressView("Connecting...") + .task { await attemptAutoLogin() } + } else { + InstanceListView(authService: authService) + } + } + .preferredColorScheme(appearance.colorScheme) + } + + private func attemptAutoLogin() async { + #if DEBUG + if devSkipAutoLogin { + hasAttemptedAutoLogin = true + return + } + // Dev auto-login: launch arguments set dev_ UserDefaults + if !devServerURL.isEmpty, !devUsername.isEmpty, !devPassword.isEmpty { + do { + try await authService.login(serverURL: devServerURL, username: devUsername, password: devPassword) + let descriptor = FetchDescriptor(predicate: #Predicate { + $0.serverURL == devServerURL && $0.username == devUsername + }) + let existing = (try? modelContext.fetch(descriptor)) ?? [] + let instance: ForgejoInstance + if let found = existing.first { + instance = found + } else { + instance = ForgejoInstance(serverURL: devServerURL, username: devUsername, name: "Dev") + modelContext.insert(instance) + } + authService.currentInstance = instance + hasAttemptedAutoLogin = true + return + } catch { + // Fall through to normal flow + } + } + #endif + + guard let defaultInstance = defaultInstances.first else { + hasAttemptedAutoLogin = true + return + } + do { + try await authService.restoreSession(instance: defaultInstance) + defaultInstance.lastUsed = Date() + do { + try modelContext.save() + } catch { + assertionFailure("SwiftData save failed: \(error)") + } + } catch { + // Auto-login failed — fall through to instance list + } + hasAttemptedAutoLogin = true + } +} + +#Preview { + ContentView() + .modelContainer(for: ForgejoInstance.self, inMemory: true) +} diff --git a/Forji/Forji/App/ForjiApp.swift b/Forji/Forji/App/ForjiApp.swift new file mode 100644 index 0000000..10820b0 --- /dev/null +++ b/Forji/Forji/App/ForjiApp.swift @@ -0,0 +1,25 @@ +import SwiftData +import SwiftUI + +@main +struct ForjiApp: App { + var sharedModelContainer: ModelContainer = { + let schema = Schema([ + ForgejoInstance.self, + ]) + let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) + + do { + return try ModelContainer(for: schema, configurations: [modelConfiguration]) + } catch { + fatalError("Could not create ModelContainer: \(error)") + } + }() + + var body: some Scene { + WindowGroup { + ContentView() + } + .modelContainer(sharedModelContainer) + } +} diff --git a/Forji/Forji/Assets.xcassets/AccentColor.colorset/Contents.json b/Forji/Forji/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Forji/Forji/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Forji/Forji/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/Forji/Forji/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000..296ef37 Binary files /dev/null and b/Forji/Forji/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/Forji/Forji/Assets.xcassets/AppIcon.appiconset/Contents.json b/Forji/Forji/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..ce8e776 --- /dev/null +++ b/Forji/Forji/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,36 @@ +{ + "images" : [ + { + "filename" : "AppIcon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Forji/Forji/Assets.xcassets/Contents.json b/Forji/Forji/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Forji/Forji/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Forji/Forji/Helpers/DebouncedSearch.swift b/Forji/Forji/Helpers/DebouncedSearch.swift new file mode 100644 index 0000000..f898455 --- /dev/null +++ b/Forji/Forji/Helpers/DebouncedSearch.swift @@ -0,0 +1,32 @@ +import SwiftUI + +struct DebouncedSearchModifier: ViewModifier { + @Binding var searchText: String + @Binding var searchTask: Task? + let action: () async -> Void + + func body(content: Content) -> some View { + content + .onChange(of: searchText) { + searchTask?.cancel() + searchTask = Task { + do { + try await Task.sleep(for: .milliseconds(300)) + } catch { + return + } + await action() + } + } + } +} + +extension View { + func debouncedSearch( + text: Binding, + task: Binding?>, + action: @escaping () async -> Void, + ) -> some View { + modifier(DebouncedSearchModifier(searchText: text, searchTask: task, action: action)) + } +} diff --git a/Forji/Forji/Helpers/DiffParserSwiftUI.swift b/Forji/Forji/Helpers/DiffParserSwiftUI.swift new file mode 100644 index 0000000..da18064 --- /dev/null +++ b/Forji/Forji/Helpers/DiffParserSwiftUI.swift @@ -0,0 +1,22 @@ +import ForgejoKit +import SwiftUI + +extension DiffLineType { + var color: Color { + switch self { + case .header: .blue + case .addition: .green + case .deletion: .red + case .context: .primary + } + } + + var backgroundColor: Color { + switch self { + case .header: .blue.opacity(0.1) + case .addition: .green.opacity(0.1) + case .deletion: .red.opacity(0.1) + case .context: .clear + } + } +} diff --git a/Forji/Forji/Helpers/LanguageColor.swift b/Forji/Forji/Helpers/LanguageColor.swift new file mode 100644 index 0000000..13b7251 --- /dev/null +++ b/Forji/Forji/Helpers/LanguageColor.swift @@ -0,0 +1,10 @@ +import SwiftUI + +func colorForLanguage(_ language: String) -> Color { + var hash: UInt64 = 5381 + for byte in language.utf8 { + hash = ((hash &<< 5) &+ hash) &+ UInt64(byte) + } + let hue = Double(hash % 360) / 360.0 + return Color(hue: hue, saturation: 0.6, brightness: 0.75) +} diff --git a/Forji/Forji/Helpers/MermaidParser.swift b/Forji/Forji/Helpers/MermaidParser.swift new file mode 100644 index 0000000..ef799b7 --- /dev/null +++ b/Forji/Forji/Helpers/MermaidParser.swift @@ -0,0 +1,60 @@ +import Foundation + +enum MarkdownSegment: Equatable { + case text(String) + case mermaid(String) +} + +enum MermaidParser { + private static let regex: NSRegularExpression? = { + let pattern = #"(?m)^```mermaid[ \t]*\n([\s\S]*?)^```[ \t]*$"# + return try? NSRegularExpression(pattern: pattern, options: .anchorsMatchLines) + }() + + static func parse(_ markdown: String) -> [MarkdownSegment] { + guard let regex else { + return [.text(markdown)] + } + + let nsString = markdown as NSString + let fullRange = NSRange(location: 0, length: nsString.length) + let matches = regex.matches(in: markdown, range: fullRange) + + if matches.isEmpty { + return [.text(markdown)] + } + + var segments: [MarkdownSegment] = [] + var lastEnd = 0 + + for match in matches { + let matchRange = match.range + let codeRange = match.range(at: 1) + + if matchRange.location > lastEnd { + let textRange = NSRange(location: lastEnd, length: matchRange.location - lastEnd) + let text = nsString.substring(with: textRange) + if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + segments.append(.text(text)) + } + } + + let code = nsString.substring(with: codeRange) + .trimmingCharacters(in: .whitespacesAndNewlines) + if !code.isEmpty { + segments.append(.mermaid(code)) + } + + lastEnd = matchRange.location + matchRange.length + } + + if lastEnd < nsString.length { + let text = nsString.substring(from: lastEnd) + if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + segments.append(.text(text)) + } + } + + return segments + } +} diff --git a/Forji/Forji/Helpers/MetadataLoader.swift b/Forji/Forji/Helpers/MetadataLoader.swift new file mode 100644 index 0000000..e4ebff5 --- /dev/null +++ b/Forji/Forji/Helpers/MetadataLoader.swift @@ -0,0 +1,18 @@ +import ForgejoKit + +struct RepositoryMetadata { + var labels: [IssueLabel] = [] + var milestones: [IssueMilestone] = [] + var assignees: [User] = [] +} + +func loadRepositoryMetadata(service: RepositoryService, owner: String, repo: String) async -> RepositoryMetadata { + async let labels = try? service.fetchLabels(owner: owner, repo: repo) + async let milestones = try? service.fetchMilestones(owner: owner, repo: repo) + async let assignees = try? service.fetchAssignees(owner: owner, repo: repo) + return await RepositoryMetadata( + labels: labels ?? [], + milestones: milestones ?? [], + assignees: assignees ?? [], + ) +} diff --git a/Forji/Forji/Helpers/PRStatusStyle.swift b/Forji/Forji/Helpers/PRStatusStyle.swift new file mode 100644 index 0000000..40a14b1 --- /dev/null +++ b/Forji/Forji/Helpers/PRStatusStyle.swift @@ -0,0 +1,33 @@ +import SwiftUI + +struct PRStatusStyle { + let icon: String + let color: Color + let text: String + + init(state: PullRequestState, merged: Bool?, draft: Bool?) { + let isMerged = merged == true + let isDraft = draft == true + + if isMerged { + icon = "arrow.triangle.merge" + color = .purple + text = "Merged" + } else if isDraft, state == .open { + icon = "circle.dashed" + color = .green + text = "Draft" + } else { + switch state { + case .open: + icon = "arrow.triangle.pull" + color = .green + text = "Open" + default: + icon = "xmark.circle.fill" + color = .red + text = "Closed" + } + } + } +} diff --git a/Forji/Forji/Helpers/PaginationState.swift b/Forji/Forji/Helpers/PaginationState.swift new file mode 100644 index 0000000..4832abb --- /dev/null +++ b/Forji/Forji/Helpers/PaginationState.swift @@ -0,0 +1,92 @@ +import SwiftUI + +@MainActor +@Observable +final class PaginationState { + var items: [Item] = [] + private(set) var isLoading = false + private(set) var hasMore = true + var errorMessage: String? + var showError = false + + private var currentPage = 1 + private var loadTask: Task? + let pageSize: Int + + init(pageSize: Int = 20) { + self.pageSize = pageSize + } + + init(items: [Item], hasMore: Bool = false, pageSize: Int = 20) { + self.items = items + self.hasMore = hasMore + self.pageSize = pageSize + } + + /// Cancels any in-flight load, resets pagination, and starts a new fetch. + /// Returns the internal Task so callers can await completion (e.g. refreshable). + @discardableResult + func reload( + clearItems: Bool = false, + using fetch: @escaping (_ page: Int, _ limit: Int) async throws -> [Item], + ) -> Task { + loadTask?.cancel() + if clearItems { items = [] } + currentPage = 1 + hasMore = true + isLoading = true + showError = false + let pageSize = pageSize + let task = Task { + do { + let fetched = try await fetch(1, pageSize) + guard !Task.isCancelled else { return } + items = fetched + hasMore = fetched.count >= pageSize + currentPage = 2 + } catch is CancellationError { + // Ignore cancellation + } catch let urlError as URLError where urlError.code == .cancelled { + // Ignore URLSession cancellation from cancelled tasks + } catch { + guard !Task.isCancelled else { return } + errorMessage = error.localizedDescription + showError = true + } + if !Task.isCancelled { + isLoading = false + } + } + loadTask = task + return task + } + + func loadMore(using fetch: @escaping (_ page: Int, _ limit: Int) async throws -> [Item]) async { + guard hasMore, !isLoading else { return } + isLoading = true + let currentPage = currentPage + let pageSize = pageSize + let task = Task { + do { + let fetched = try await fetch(currentPage, pageSize) + guard !Task.isCancelled else { return } + items.append(contentsOf: fetched) + hasMore = fetched.count >= pageSize + self.currentPage = currentPage + 1 + } catch is CancellationError { + // Ignore cancellation + } catch let urlError as URLError where urlError.code == .cancelled { + // Ignore URLSession cancellation from cancelled tasks + } catch { + guard !Task.isCancelled else { return } + errorMessage = error.localizedDescription + showError = true + } + if !Task.isCancelled { + isLoading = false + } + } + loadTask = task + await task.value + } +} diff --git a/Forji/Forji/Helpers/PreviewData.swift b/Forji/Forji/Helpers/PreviewData.swift new file mode 100644 index 0000000..3415ad2 --- /dev/null +++ b/Forji/Forji/Helpers/PreviewData.swift @@ -0,0 +1,350 @@ +import ForgejoKit +import Foundation + +// Fake model instances for SwiftUI previews +#if DEBUG + extension Repository { + static let preview = Repository( + id: 1, + name: "test-repo", + fullName: "testuser/test-repo", + description: "A test repository", + empty: false, + private: false, + fork: false, + parent: nil, + mirror: false, + size: 1024, + language: "Swift", + languagesUrl: nil, + htmlUrl: "", + sshUrl: "", + cloneUrl: "", + website: nil, + starsCount: 42, + forksCount: 7, + watchersCount: 10, + openIssuesCount: 3, + openPrCounter: 1, + releaseCounter: 2, + defaultBranch: "main", + archived: false, + createdAt: Date(), + updatedAt: Date(), + permissions: nil, + hasIssues: true, + internalTracker: nil, + hasWiki: true, + hasPullRequests: true, + hasProjects: true, + hasReleases: true, + hasPackages: false, + hasActions: true, + template: false, + avatarUrl: nil, + ) + } + + extension User { + static let preview = User( + id: 1, + login: "testuser", + fullName: "Test User", + email: "test@example.com", + avatarUrl: nil, + isAdmin: false, + created: Date(), + ) + + static let previewBot = User( + id: 2, + login: "botuser", + fullName: "Bot User", + email: "bot@example.com", + avatarUrl: nil, + isAdmin: false, + created: Date(), + ) + } + + extension Commit { + static let preview = Commit( + sha: "abc123def456789", + htmlUrl: "https://forgejo.example.com/testuser/test-repo/commit/abc123def456789", + commit: CommitDetail( + message: "Add new feature", + author: CommitSignature(name: "Test User", email: "test@example.com", date: Date()), + committer: CommitSignature(name: "Test User", email: "test@example.com", date: Date()), + ), + author: .preview, + ) + } + + extension ForgejoInstance { + static let preview = ForgejoInstance( + serverURL: "https://forgejo.example.com", + username: "testuser", + ) + } + + extension IssueLabel { + static let preview = IssueLabel( + id: 1, + name: "bug", + color: "ee0701", + description: "Something isn't working", + ) + + static let previewList: [IssueLabel] = [ + .preview, + IssueLabel( + id: 2, + name: "enhancement", + color: "0075ca", + description: "New feature or request", + ), + ] + } + + extension IssueMilestone { + static let preview = IssueMilestone( + id: 1, + title: "v1.0", + description: "First stable release", + state: "open", + ) + } + + extension IssueComment { + static let preview = IssueComment( + id: 1, + body: "This looks good! I think we should also consider **edge cases** for empty input.", + user: .preview, + createdAt: Date(), + updatedAt: Date(), + ) + } + + extension Issue { + static let preview = Issue( + id: 1, + number: 1, + title: "Fix login crash on empty password", + body: """ + The app crashes when the user submits the login form with an empty password. + + ## Steps to reproduce + 1. Open the app + 2. Enter a username + 3. Leave password empty + 4. Tap **Login** + """, + state: "open", + user: .preview, + labels: IssueLabel.previewList, + milestone: .preview, + assignees: [.preview], + createdAt: Date().addingTimeInterval(-86400), + updatedAt: Date(), + comments: 2, + ) + + static let previewWithRepo = Issue( + id: 1, + number: 1, + title: "Fix login crash on empty password", + body: "The app crashes when the user submits the login form with an empty password.", + state: "open", + user: .preview, + labels: IssueLabel.previewList, + milestone: .preview, + assignees: [.preview], + createdAt: Date().addingTimeInterval(-86400), + updatedAt: Date(), + comments: 2, + repository: .preview, + ) + + static let previewPullRequest = Issue( + id: 10, + number: 4, + title: "Add dark mode support", + body: "This PR adds dark mode support.", + state: "open", + user: .preview, + labels: IssueLabel.previewList, + createdAt: Date().addingTimeInterval(-86400), + updatedAt: Date(), + pullRequest: IssuePullRequest(merged: false), + comments: 1, + repository: .preview, + ) + } + + extension PRBranchRef { + static let previewHead = PRBranchRef( + label: "testuser:feature-branch", + ref: "feature-branch", + sha: "abc123def456789", + repo: PRRepo(id: 1, name: "test-repo", fullName: "testuser/test-repo"), + ) + + static let previewBase = PRBranchRef( + label: "testuser:main", + ref: "main", + sha: "def456789abc123", + repo: PRRepo(id: 1, name: "test-repo", fullName: "testuser/test-repo"), + ) + } + + extension PullRequest { + static let preview = PullRequest( + id: 1, + number: 4, + title: "Add dark mode support", + body: "This PR adds dark mode support to the app.\n\n- Updated color scheme\n- Added toggle in settings", + state: "open", + user: .preview, + labels: IssueLabel.previewList, + assignees: [.preview, .previewBot], + head: .previewHead, + base: .previewBase, + mergeable: true, + merged: false, + requestedReviewers: [.previewBot], + draft: false, + comments: 1, + createdAt: Date().addingTimeInterval(-86400), + updatedAt: Date(), + ) + } + + extension NotificationSubject { + static let preview = NotificationSubject( + type: "Issue", + title: "Fix login crash on empty password", + state: "open", + ) + } + + extension NotificationThread { + static let preview = NotificationThread( + id: 1, + unread: true, + pinned: false, + updatedAt: Date(), + subject: .preview, + repository: .preview, + ) + + static let previewList: [NotificationThread] = [ + .preview, + NotificationThread( + id: 2, + unread: false, + pinned: false, + updatedAt: Date().addingTimeInterval(-3600), + subject: NotificationSubject( + type: "Pull", + title: "Add dark mode support", + state: "open", + ), + repository: .preview, + ), + ] + } + + extension PullRequestReview { + static let preview = PullRequestReview( + id: 1, + body: "Looks good overall, just a few minor suggestions.", + user: .previewBot, + state: "APPROVED", + commentsCount: 1, + submittedAt: Date(), + ) + } + + extension ReviewComment { + static let preview = ReviewComment( + id: 1, + body: "Consider using a guard statement here instead.", + user: .previewBot, + path: "README.md", + diffHunk: "@@ -1,3 +1,4 @@\n # Test Repo\n+Some new line", + createdAt: Date(), + updatedAt: Date(), + ) + } + + extension ParsedDiff { + static let preview = ParsedDiff( + files: [ + DiffFile( + oldName: "README.md", + newName: "README.md", + hunks: [ + DiffHunk( + header: "@@ -1,5 +1,6 @@", + lines: [ + DiffLine( + type: .context, content: " # Test Repo", + oldLineNumber: 1, newLineNumber: 1, diffPosition: 1, + ), + DiffLine( + type: .context, content: " ", + oldLineNumber: 2, newLineNumber: 2, diffPosition: 2, + ), + DiffLine( + type: .deletion, content: "-Old description", + oldLineNumber: 3, newLineNumber: nil, diffPosition: 3, + ), + DiffLine( + type: .addition, content: "+New and improved description", + oldLineNumber: nil, newLineNumber: 3, diffPosition: 4, + ), + DiffLine( + type: .context, content: " ", + oldLineNumber: 4, newLineNumber: 4, diffPosition: 5, + ), + DiffLine( + type: .addition, content: "+Added a new line", + oldLineNumber: nil, newLineNumber: 5, diffPosition: 6, + ), + ], + ), + ], + ), + ], + ) + } + + extension Branch { + static let preview = Branch( + name: "main", + commit: BranchCommit(id: "abc123", message: "Initial commit", url: ""), + protected: true, + ) + + static let previewList: [Branch] = [ + .preview, + Branch( + name: "feature-branch", + commit: BranchCommit(id: "def456", message: "Add feature", url: ""), + protected: false, + ), + Branch( + name: "develop", + commit: BranchCommit(id: "789abc", message: "Merge develop", url: ""), + protected: false, + ), + ] + } + + extension AuthenticationService { + static let previewDefault: AuthenticationService = .preview( + user: .preview, + instance: .preview, + ) + } +#endif diff --git a/Forji/Forji/Models/ForgejoInstance.swift b/Forji/Forji/Models/ForgejoInstance.swift new file mode 100644 index 0000000..d1e9d6a --- /dev/null +++ b/Forji/Forji/Models/ForgejoInstance.swift @@ -0,0 +1,24 @@ +import Foundation +import SwiftData + +@Model +final class ForgejoInstance { + var serverURL: String + var username: String + var name: String + var isDefault: Bool + var lastUsed: Date + var allowSelfSignedCertificates: Bool + + init( + serverURL: String, username: String, name: String = "", + isDefault: Bool = false, allowSelfSignedCertificates: Bool = false, + ) { + self.serverURL = serverURL + self.username = username + self.name = name + self.isDefault = isDefault + lastUsed = Date() + self.allowSelfSignedCertificates = allowSelfSignedCertificates + } +} diff --git a/Forji/Forji/Models/ReviewState.swift b/Forji/Forji/Models/ReviewState.swift new file mode 100644 index 0000000..4f7a15d --- /dev/null +++ b/Forji/Forji/Models/ReviewState.swift @@ -0,0 +1,53 @@ +import Foundation + +enum ReviewEvent: String, CaseIterable, Identifiable { + case comment = "COMMENT" + case approved = "APPROVED" + case requestChanges = "REQUEST_CHANGES" + + var id: String { + rawValue + } + + var title: String { + switch self { + case .comment: + "Comment" + case .approved: + "Approve" + case .requestChanges: + "Reject" + } + } + + var systemImage: String { + switch self { + case .comment: + "message" + case .approved: + "checkmark.circle" + case .requestChanges: + "xmark.circle" + } + } +} + +enum ReviewState: String { + case approved = "APPROVED" + case requestChanges = "REQUEST_CHANGES" + case comment = "COMMENT" + case pending = "PENDING" + + var label: String { + switch self { + case .approved: + "approved" + case .requestChanges: + "rejected" + case .comment: + "commented" + case .pending: + "pending" + } + } +} diff --git a/Forji/Forji/Models/State.swift b/Forji/Forji/Models/State.swift new file mode 100644 index 0000000..9542cca --- /dev/null +++ b/Forji/Forji/Models/State.swift @@ -0,0 +1,62 @@ +import ForgejoKit + +enum IssueState: String { + case open + case closed +} + +enum IssueFilterState: String { + case open + case closed + case all +} + +enum PullRequestState: String { + case open + case closed +} + +enum PullRequestFilterState: String { + case open + case closed + case all +} + +enum NotificationSubjectState: String { + case open + case closed + case merged +} + +extension Issue { + var stateValue: IssueState { + IssueState(rawValue: state) ?? .closed + } +} + +extension Issue { + var pullRequestStateValue: PullRequestState { + PullRequestState(rawValue: state) ?? .closed + } +} + +extension PullRequest { + var stateValue: PullRequestState { + PullRequestState(rawValue: state) ?? .closed + } +} + +enum InvolvementScope: String, CaseIterable { + case involved + case created + case assigned + case mentioned + case reviewRequested = "review_requested" +} + +extension NotificationSubject { + var stateValue: NotificationSubjectState? { + guard let state else { return nil } + return NotificationSubjectState(rawValue: state) + } +} diff --git a/Forji/Forji/Services/AuthenticationService.swift b/Forji/Forji/Services/AuthenticationService.swift new file mode 100644 index 0000000..505c01f --- /dev/null +++ b/Forji/Forji/Services/AuthenticationService.swift @@ -0,0 +1,131 @@ +import ForgejoKit +import Foundation +import SwiftData + +@Observable +class AuthenticationService { + var isAuthenticated = false + var currentUser: User? + var currentInstance: ForgejoInstance? + private(set) var client: ForgejoClient? + + func login(serverURL: String, username: String, password: String, allowSelfSigned: Bool = false) async throws { + let result = try await ForgejoClient.login( + serverURL: serverURL, + username: username, + password: password, + allowSelfSignedCertificates: allowSelfSigned, + ) + try await storeCredentials(result: result, password: password) + } + + func loginWithOTP( + serverURL: String, username: String, password: String, otp: String, + allowSelfSigned: Bool = false, + ) async throws { + let result = try await ForgejoClient.loginWithOTP( + serverURL: serverURL, + username: username, + password: password, + otp: otp, + allowSelfSignedCertificates: allowSelfSigned, + ) + try await storeCredentials(result: result, password: password) + } + + private func storeCredentials(result: LoginResult, password: String) async throws { + try await KeychainManager.shared.savePassword( + password, for: result.client.serverURL, username: result.client.username, + ) + try await KeychainManager.shared.saveToken( + result.token, for: result.client.serverURL, username: result.client.username, + ) + client = result.client + currentUser = result.user + isAuthenticated = true + } + + func disconnect() { + isAuthenticated = false + currentUser = nil + currentInstance = nil + client = nil + } + + func logout(modelContext: ModelContext? = nil) async { + if let instance = currentInstance { + let normalizedURL = ForgejoClient.normalizeServerURL(instance.serverURL) + do { + try await KeychainManager.shared.deletePassword( + for: normalizedURL, + username: instance.username, + ) + try await KeychainManager.shared.deleteToken( + for: normalizedURL, + username: instance.username, + ) + } catch { + // Keychain delete failed — log in debug builds + #if DEBUG + print("Keychain delete failed during logout: \(error)") + #endif + } + // Remove the instance from SwiftData and unset default + if let modelContext { + instance.isDefault = false + modelContext.delete(instance) + try? modelContext.save() + } + } + isAuthenticated = false + currentUser = nil + currentInstance = nil + client = nil + } + + func restoreSession(instance: ForgejoInstance) async throws { + let normalizedURL = ForgejoClient.normalizeServerURL(instance.serverURL) + + // Try restoring from stored API token first (avoids 2FA prompt) + if let token = try? await KeychainManager.shared.getToken(for: normalizedURL, username: instance.username) { + let tokenClient = ForgejoClient( + serverURL: normalizedURL, + username: instance.username, + token: token, + allowSelfSignedCertificates: instance.allowSelfSignedCertificates, + ) + // Validate the token still works + let user = try await tokenClient.fetchCurrentUser() + client = tokenClient + currentUser = user + isAuthenticated = true + currentInstance = instance + return + } + + // Fall back to password-based login (creates a new token) + let password = try await KeychainManager.shared.getPassword(for: normalizedURL, username: instance.username) + try await login( + serverURL: normalizedURL, username: instance.username, + password: password, + allowSelfSigned: instance.allowSelfSignedCertificates, + ) + currentInstance = instance + } + + // Stub factories for SwiftUI previews + #if DEBUG + static func preview(user: User, instance: ForgejoInstance? = nil) -> AuthenticationService { + let service = AuthenticationService() + service.isAuthenticated = true + service.currentUser = user + service.currentInstance = instance + service.client = ForgejoClient( + serverURL: instance?.serverURL ?? "https://forgejo.example.com", + username: user.login, + token: "preview-token", + ) + return service + } + #endif +} diff --git a/Forji/Forji/Services/KeychainManager.swift b/Forji/Forji/Services/KeychainManager.swift new file mode 100644 index 0000000..7d9dac1 --- /dev/null +++ b/Forji/Forji/Services/KeychainManager.swift @@ -0,0 +1,114 @@ +import Foundation +import Security + +actor KeychainManager { + static let shared = KeychainManager() + + private static let serviceName = "de.hausotte.Forji" + + private init() {} + + private func key(for server: String, username: String, suffix: String = "") -> String { + "\(server)_\(username)\(suffix)" + } + + // MARK: - Password + + func savePassword(_ password: String, for server: String, username: String) throws { + try saveItem(password, forKey: key(for: server, username: username)) + } + + func getPassword(for server: String, username: String) throws -> String { + try getItem(forKey: key(for: server, username: username)) + } + + func deletePassword(for server: String, username: String) throws { + try deleteItem(forKey: key(for: server, username: username)) + } + + // MARK: - Token + + func saveToken(_ token: String, for server: String, username: String) throws { + try saveItem(token, forKey: key(for: server, username: username, suffix: "_token")) + } + + func getToken(for server: String, username: String) throws -> String { + try getItem(forKey: key(for: server, username: username, suffix: "_token")) + } + + func deleteToken(for server: String, username: String) throws { + try deleteItem(forKey: key(for: server, username: username, suffix: "_token")) + } + + // MARK: - Private helpers + + private func saveItem(_ value: String, forKey key: String) throws { + guard let data = value.data(using: .utf8) else { + throw KeychainError.unableToSave + } + + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.serviceName, + kSecAttrAccount as String: key, + ] + + SecItemDelete(deleteQuery as CFDictionary) + + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.serviceName, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + ] + + let status = SecItemAdd(addQuery as CFDictionary, nil) + + guard status == errSecSuccess else { + throw KeychainError.unableToSave + } + } + + private func getItem(forKey key: String) throws -> String { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.serviceName, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess, + let data = result as? Data, + let value = String(data: data, encoding: .utf8) + else { + throw KeychainError.notFound + } + + return value + } + + private func deleteItem(forKey key: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.serviceName, + kSecAttrAccount as String: key, + ] + + let status = SecItemDelete(query as CFDictionary) + + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainError.unableToDelete + } + } +} + +enum KeychainError: Error, Sendable { + case unableToSave + case notFound + case unableToDelete +} diff --git a/Forji/Forji/Views/CommentSheet.swift b/Forji/Forji/Views/CommentSheet.swift new file mode 100644 index 0000000..d1f7ba3 --- /dev/null +++ b/Forji/Forji/Views/CommentSheet.swift @@ -0,0 +1,71 @@ +import ForgejoKit +import SwiftUI + +struct CommentSheet: View { + let users: [User] + var onSubmit: (String) async throws -> Void + + @State private var text = "" + @State private var selectedTab: EditPreviewTab = .edit + @State private var isSubmitting = false + @State private var errorMessage: String? + @State private var showError = false + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + Group { + if users.isEmpty { + MarkdownEditorField( + text: $text, + selectedTab: $selectedTab, + showToolbar: true, + ) + } else { + MentionableEditorField( + text: $text, + selectedTab: $selectedTab, + users: users, + showToolbar: true, + ) + } + } + .padding() + .navigationTitle("New Comment") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + if isSubmitting { + ProgressView() + .controlSize(.small) + } else { + Button("Submit") { + Task { await submit() } + } + .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSubmitting) + } + } + } + .errorAlert(message: $errorMessage, isPresented: $showError) + } + } + + private func submit() async { + isSubmitting = true + do { + try await onSubmit(text.trimmingCharacters(in: .whitespacesAndNewlines)) + dismiss() + } catch { + errorMessage = error.localizedDescription + showError = true + } + isSubmitting = false + } +} + +#Preview { + CommentSheet(users: [.preview, .previewBot]) { _ in } +} diff --git a/Forji/Forji/Views/CommentView.swift b/Forji/Forji/Views/CommentView.swift new file mode 100644 index 0000000..ee0a96a --- /dev/null +++ b/Forji/Forji/Views/CommentView.swift @@ -0,0 +1,118 @@ +import ForgejoKit +import SwiftUI + +struct CommentView: View { + let comment: IssueComment + let currentUsername: String? + let onSave: ((String) async -> Bool)? + + @State private var isEditing = false + @State private var editedBody: String = "" + @State private var displayBody: String = "" + + private var isOwnComment: Bool { + currentUsername != nil && comment.user.login == currentUsername + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Text(comment.user.login) + .font(.subheadline) + .fontWeight(.medium) + Text(formatRelativeDate(comment.createdAt)) + .font(.caption) + .foregroundStyle(.secondary) + + Spacer() + + if isOwnComment { + Button { + editedBody = displayBody + isEditing = true + } label: { + Image(systemName: "pencil") + .font(.caption) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + + MarkdownPreview(text: displayBody) + } + .padding(.vertical, 4) + .onAppear { + displayBody = comment.body + } + .onChange(of: comment.body) { _, newValue in + displayBody = newValue + } + .sheet(isPresented: $isEditing) { + CommentEditSheet(text: $editedBody) { saved in + displayBody = saved + } onSave: { body in + await onSave?(body) ?? false + } + } + } +} + +private struct CommentEditSheet: View { + @Binding var text: String + let onDismissWithSave: (String) -> Void + let onSave: (String) async -> Bool + @State private var selectedTab: EditPreviewTab = .edit + @State private var isSaving = false + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + MarkdownEditorField( + text: $text, + selectedTab: $selectedTab, + showToolbar: true, + ) + .frame(maxHeight: .infinity) + .padding() + .navigationTitle("Edit Comment") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + if isSaving { + ProgressView() + .controlSize(.small) + } else { + Button("Save") { + Task { await save() } + } + .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + } + } + + private func save() async { + isSaving = true + let success = await onSave(text) + if success { + onDismissWithSave(text) + dismiss() + } + isSaving = false + } +} + +#Preview { + List { + CommentView( + comment: .preview, + currentUsername: "testuser", + onSave: nil, + ) + } +} diff --git a/Forji/Forji/Views/CommitDetailView.swift b/Forji/Forji/Views/CommitDetailView.swift new file mode 100644 index 0000000..ae23b27 --- /dev/null +++ b/Forji/Forji/Views/CommitDetailView.swift @@ -0,0 +1,104 @@ +import ForgejoKit +import SwiftUI + +struct CommitDetailView: View { + let commit: Commit + let repository: Repository + @State private var authService: AuthenticationService + @State private var parsedDiff: ParsedDiff? + @State private var isLoadingDiff = false + @State private var errorMessage: String? + @State private var showError = false + + private let repositoryService: RepositoryService? + + init(commit: Commit, repository: Repository, authService: AuthenticationService) { + self.commit = commit + self.repository = repository + self.authService = authService + repositoryService = authService.client.map { RepositoryService(client: $0) } + } + + var body: some View { + List { + // Commit info + Section { + VStack(alignment: .leading, spacing: 8) { + Text(commit.commit.message) + .font(.body) + + HStack(spacing: 8) { + if let authorName = commit.author?.login ?? commit.commit.author?.name { + Text(authorName) + .fontWeight(.medium) + } + + Text(String(commit.sha.prefix(7))) + .monospaced() + + if let date = commit.commit.committer?.date ?? commit.commit.author?.date { + Text(formatRelativeDate(date)) + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + + // Diff + Section("Changes") { + if isLoadingDiff { + HStack { + Spacer() + ProgressView() + Spacer() + } + .listRowBackground(Color.clear) + } else if let parsedDiff { + if parsedDiff.files.isEmpty { + Text("No file changes") + .foregroundStyle(.secondary) + } else { + DiffView(diff: parsedDiff) + .listRowInsets(EdgeInsets()) + } + } + } + } + .listStyle(.insetGrouped) + .navigationTitle(String(commit.sha.prefix(7))) + .navigationBarTitleDisplayMode(.inline) + .task { + await loadDiff() + } + .errorAlert(message: $errorMessage, isPresented: $showError) + } + + private func loadDiff() async { + guard let repositoryService else { return } + isLoadingDiff = true + do { + let rawDiff = try await repositoryService.fetchCommitDiff( + owner: repository.owner, + repo: repository.repoName, + sha: commit.sha, + ) + parsedDiff = DiffParser.parse(rawDiff) + } catch { + errorMessage = error.localizedDescription + showError = true + } + isLoadingDiff = false + } +} + +#Preview { + NavigationStack { + CommitDetailView( + commit: .preview, + repository: .preview, + authService: .previewDefault, + ) + } +} diff --git a/Forji/Forji/Views/CommitHistoryView.swift b/Forji/Forji/Views/CommitHistoryView.swift new file mode 100644 index 0000000..0feb3a9 --- /dev/null +++ b/Forji/Forji/Views/CommitHistoryView.swift @@ -0,0 +1,220 @@ +import ForgejoKit +import SwiftUI + +struct CommitHistoryView: View { + let repository: Repository + @Binding var branch: String + @State private var authService: AuthenticationService + @State private var pagination = PaginationState() + @State private var branches: [Branch] = [] + @State private var showBranchPicker = false + + private let repositoryService: RepositoryService? + + init(repository: Repository, branch: Binding, authService: AuthenticationService) { + self.repository = repository + _branch = branch + self.authService = authService + repositoryService = authService.client.map { RepositoryService(client: $0) } + } + + private var owner: String { + repository.owner + } + + private var repo: String { + repository.repoName + } + + var body: some View { + @Bindable var pagination = pagination + List { + if pagination.isLoading, pagination.items.isEmpty { + LoadingListSection() + } else if pagination.items.isEmpty { + ContentUnavailableView { + Label("No Commits", systemImage: "clock.arrow.circlepath") + .foregroundStyle(.secondary) + } description: { + Text("No commits found on \(branch).") + } + } else { + Section { + ForEach(pagination.items) { commit in + NavigationLink { + CommitDetailView( + commit: commit, + repository: repository, + authService: authService, + ) + } label: { + CommitRow(commit: commit) + } + } + + if pagination.hasMore { + ProgressView() + .frame(maxWidth: .infinity) + .listRowBackground(Color.clear) + .task { + await loadMoreCommits() + } + } + } + } + } + .listStyle(.insetGrouped) + .refreshable { + await reloadCommits(clearItems: true).value + } + .task { + reloadCommits() + await loadBranches() + } + .onChange(of: branch) { + reloadCommits(clearItems: true) + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showBranchPicker = true + } label: { + HStack(spacing: 4) { + Image(systemName: "arrow.triangle.branch") + Text(branch) + .lineLimit(1) + .frame(maxWidth: 140) + } + .font(.subheadline.weight(.medium)) + } + .disabled(branches.isEmpty) + .accessibilityIdentifier("branch-selector") + } + } + .sheet(isPresented: $showBranchPicker) { + NavigationStack { + List(branches) { branchItem in + Button { + branch = branchItem.name + showBranchPicker = false + } label: { + HStack { + Text(branchItem.name) + Spacer() + if branchItem.name == branch { + Image(systemName: "checkmark") + .foregroundStyle(.blue) + } + } + } + .accessibilityIdentifier("branch-option-\(branchItem.name)") + } + .navigationTitle("Switch Branch") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + showBranchPicker = false + } + } + } + } + .presentationDetents([.medium]) + } + .errorAlert(message: $pagination.errorMessage, isPresented: $pagination.showError) + } + + @discardableResult + private func reloadCommits(clearItems: Bool = false) -> Task { + guard let repositoryService else { return Task {} } + return pagination.reload(clearItems: clearItems) { [self] page, limit in + try await repositoryService.fetchCommits( + owner: owner, + repo: repo, + sha: branch, + page: page, + limit: limit, + ) + } + } + + private func loadBranches() async { + guard let repositoryService else { return } + do { + branches = try await repositoryService.fetchBranches( + owner: owner, + repo: repo, + ) + } catch { + // Non-critical — branch selector stays disabled + } + } + + private func loadMoreCommits() async { + guard let repositoryService else { return } + await pagination.loadMore { page, limit in + let fetched = try await repositoryService.fetchCommits( + owner: owner, + repo: repo, + sha: branch, + page: page, + limit: limit, + ) + try Task.checkCancellation() + return fetched + } + } +} + +struct CommitRow: View { + let commit: Commit + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(commitTitle) + .font(.body) + .lineLimit(2) + + HStack(spacing: 8) { + if let authorName = displayName { + Text(authorName) + .fontWeight(.medium) + } + + Text(shortSHA) + .monospaced() + + if let date = commit.commit.committer?.date ?? commit.commit.author?.date { + Text(formatRelativeDate(date)) + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + .accessibilityIdentifier("commit-row-\(commit.sha.prefix(7))") + } + + private var commitTitle: String { + let message = commit.commit.message + return message.components(separatedBy: "\n").first ?? message + } + + private var shortSHA: String { + String(commit.sha.prefix(7)) + } + + private var displayName: String? { + commit.author?.login ?? commit.commit.author?.name + } +} + +#Preview { + NavigationStack { + CommitHistoryView( + repository: .preview, + branch: .constant("main"), + authService: .previewDefault, + ) + } +} diff --git a/Forji/Forji/Views/DiffView.swift b/Forji/Forji/Views/DiffView.swift new file mode 100644 index 0000000..eee1f76 --- /dev/null +++ b/Forji/Forji/Views/DiffView.swift @@ -0,0 +1,151 @@ +import ForgejoKit +import SwiftUI + +struct DiffView: View { + let diff: ParsedDiff + var reviewComments: [ReviewComment] = [] + var onLineTap: ((DiffLine, String) -> Void)? + + private var commentIndex: [String: [Int: [ReviewComment]]] { + Self.buildCommentIndex(reviewComments) + } + + static func buildCommentIndex(_ comments: [ReviewComment]) -> [String: [Int: [ReviewComment]]] { + var index: [String: [Int: [ReviewComment]]] = [:] + for comment in comments { + let path = comment.path + let pos = comment.position ?? comment.originalPosition ?? 0 + guard pos > 0 else { continue } + index[path, default: [:]][pos, default: []].append(comment) + } + return index + } + + var body: some View { + ScrollView(.horizontal) { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(Array(diff.files.enumerated()), id: \.offset) { fileIndex, file in + let filePath = file.newName == "/dev/null" ? file.oldName : file.newName + + HStack(spacing: 4) { + Image(systemName: "doc.text") + .font(.caption2) + Text(filePath) + .font(.system(.caption, design: .monospaced)) + .fontWeight(.semibold) + .fixedSize(horizontal: true, vertical: false) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .glassEffect(.regular) + + ForEach(Array(file.hunks.enumerated()), id: \.offset) { _, hunk in + ForEach(hunk.lines) { line in + diffLineRow(line: line, filePath: filePath) + + if !reviewComments.isEmpty { + ForEach(commentsForLine(line, path: filePath)) { comment in + InlineCommentBubble(comment: comment) + } + } + } + } + + if fileIndex < diff.files.count - 1 { + Spacer().frame(height: 8) + } + } + } + } + } + + private func diffLineRow(line: DiffLine, filePath: String) -> some View { + HStack(spacing: 0) { + HStack(spacing: 2) { + Text(line.oldLineNumber.map { "\($0)" } ?? "") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.tertiary) + .frame(minWidth: 20, alignment: .trailing) + + Text(line.newLineNumber.map { "\($0)" } ?? "") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.tertiary) + .frame(minWidth: 20, alignment: .trailing) + } + .padding(.trailing, 2) + + Rectangle() + .fill(.quaternary) + .frame(width: 1) + .padding(.vertical, 1) + + Text(line.content) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(line.type.color) + .fixedSize(horizontal: true, vertical: false) + .padding(.leading, 4) + + Spacer(minLength: 0) + + if onLineTap != nil, line.type != .header, line.diffPosition != nil { + Image(systemName: "plus.bubble") + .font(.caption2) + .foregroundStyle(.tertiary) + .padding(.trailing, 4) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 2) + .padding(.vertical, 1) + .background(line.type.backgroundColor) + .contentShape(Rectangle()) + .onTapGesture { + if line.type != .header, line.diffPosition != nil { + onLineTap?(line, filePath) + } + } + } + + private func commentsForLine(_ line: DiffLine, path: String) -> [ReviewComment] { + guard let linePos = line.diffPosition else { return [] } + return commentIndex[path]?[linePos] ?? [] + } +} + +private struct InlineCommentBubble: View { + let comment: ReviewComment + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Text(comment.user?.login ?? "Unknown") + .font(.caption2) + .fontWeight(.medium) + if let date = comment.createdAt { + Text(formatRelativeDate(date)) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + MarkdownPreview(text: comment.body) + } + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .containerRelativeFrame(.horizontal, alignment: .leading) { width, _ in + max(0, width - 52) + } + .background(Color.blue.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .padding(.leading, 48) + .padding(.trailing, 4) + .padding(.vertical, 2) + } +} + +#Preview { + List { + DiffView(diff: .preview) + .listRowInsets(EdgeInsets()) + } +} diff --git a/Forji/Forji/Views/DisplaySections.swift b/Forji/Forji/Views/DisplaySections.swift new file mode 100644 index 0000000..02970eb --- /dev/null +++ b/Forji/Forji/Views/DisplaySections.swift @@ -0,0 +1,58 @@ +import ForgejoKit +import SwiftUI + +struct MilestoneDisplaySection: View { + let milestone: IssueMilestone + + var body: some View { + Section("Milestone") { + HStack(spacing: 8) { + Image(systemName: "flag") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(milestone.title) + .font(.subheadline) + if let due = milestone.dueOn { + Text("Due \(due, style: .date)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + } +} + +struct AssigneesDisplaySection: View { + let assignees: [User] + + var body: some View { + Section("Assignees") { + ForEach(assignees) { user in + HStack(spacing: 8) { + Image(systemName: "person") + .foregroundStyle(.secondary) + Text(user.fullName ?? user.login) + .font(.subheadline) + if user.fullName != nil { + Text("@\(user.login)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + } +} + +#Preview("Milestone") { + List { + MilestoneDisplaySection(milestone: .preview) + } +} + +#Preview("Assignees") { + List { + AssigneesDisplaySection(assignees: [.preview, .previewBot]) + } +} diff --git a/Forji/Forji/Views/ErrorAlert.swift b/Forji/Forji/Views/ErrorAlert.swift new file mode 100644 index 0000000..d6f2381 --- /dev/null +++ b/Forji/Forji/Views/ErrorAlert.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct ErrorAlertModifier: ViewModifier { + @Binding var errorMessage: String? + @Binding var isPresented: Bool + var title: String = "Error" + + func body(content: Content) -> some View { + content + .alert(title, isPresented: $isPresented) { + Button("OK", role: .cancel) {} + } message: { + Text(errorMessage ?? "An unknown error occurred") + } + } +} + +extension View { + func errorAlert(_ title: String = "Error", message: Binding, isPresented: Binding) -> some View { + modifier(ErrorAlertModifier(errorMessage: message, isPresented: isPresented, title: title)) + } +} diff --git a/Forji/Forji/Views/FileViewerView.swift b/Forji/Forji/Views/FileViewerView.swift new file mode 100644 index 0000000..a0dae80 --- /dev/null +++ b/Forji/Forji/Views/FileViewerView.swift @@ -0,0 +1,337 @@ +import ForgejoKit +import HighlightSwift +import SwiftUI + +struct FileViewerView: View { + let repository: Repository + let filePath: String + let fileName: String + let ref: String? + @State private var authService: AuthenticationService + @State private var fileContent: String? + @State private var fileSha: String? + @State private var isLoading = false + @State private var errorMessage: String? + @State private var showError = false + @State private var isEditing = false + @State private var editedContent: String = "" + @State private var showCommitSheet = false + @State private var navigateToFilePath: String? + + private let repositoryService: RepositoryService? + + init( + repository: Repository, filePath: String, fileName: String, + authService: AuthenticationService, ref: String? = nil, + ) { + self.repository = repository + self.filePath = filePath + self.fileName = fileName + self.authService = authService + self.ref = ref + repositoryService = authService.client.map { RepositoryService(client: $0) } + } + + var body: some View { + Group { + if isLoading { + VStack { + ProgressView("Loading file...") + .padding() + } + } else if let content = fileContent { + if isEditing { + TextEditor(text: $editedContent) + .font(.system(.body, design: .monospaced)) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } else { + if isMarkdownFile { + ScrollView { + MarkdownPreview( + text: content, + baseURL: markdownBaseURL, + onNavigateToFile: { path in + navigateToFilePath = path + }, + ) + .padding() + } + } else { + let lines = content.components(separatedBy: "\n") + let lineNumberText = (1 ... lines.count).map(String.init).joined(separator: "\n") + + ScrollView(.vertical) { + HStack(alignment: .top, spacing: 0) { + Text(lineNumberText) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.quaternary) + .multilineTextAlignment(.trailing) + .padding(.leading, 8) + .padding(.trailing, 8) + .padding(.vertical, 12) + .glassEffect(.regular) + + ScrollView(.horizontal, showsIndicators: false) { + CodeText(content) + .fixedSize(horizontal: true, vertical: false) + .textSelection(.enabled) + .padding(12) + } + } + } + } + } + } else { + ContentUnavailableView { + Label("Could not load file", systemImage: "doc.text.fill.badge.questionmark") + .foregroundStyle(.orange) + } description: { + Text("The file could not be loaded.") + } + } + } + .navigationTitle(fileName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + if isEditing { + Button("Cancel") { + isEditing = false + editedContent = fileContent ?? "" + } + .accessibilityIdentifier("file-edit-cancel") + Button("Commit") { + showCommitSheet = true + } + .disabled(editedContent == fileContent) + .accessibilityIdentifier("file-edit-commit") + } else if fileContent != nil, fileSha != nil { + Button { + editedContent = fileContent ?? "" + isEditing = true + } label: { + Image(systemName: "pencil") + } + .accessibilityIdentifier("file-edit-button") + } + } + } + .sheet(isPresented: $showCommitSheet) { + if let repositoryService, let currentSha = fileSha { + CommitSheetView( + fileName: fileName, + repositoryService: repositoryService, + repository: repository, + filePath: filePath, + editedContent: editedContent, + fileSha: currentSha, + branch: ref, + onCommit: { _ in + isEditing = false + await loadFile() + }, + ) + } + } + .task { + await loadFile() + } + .errorAlert(message: $errorMessage, isPresented: $showError) + .navigationDestination(item: $navigateToFilePath) { path in + let fileName = path.components(separatedBy: "/").last ?? path + FileViewerView( + repository: repository, + filePath: path, + fileName: fileName, + authService: authService, + ref: ref, + ) + } + } + + private var isMarkdownFile: Bool { + let lowercased = fileName.lowercased() + return lowercased.hasSuffix(".md") || lowercased.hasSuffix(".markdown") + } + + private var markdownBaseURL: URL? { + guard let serverURL = authService.client?.serverURL else { return nil } + let branch = ref ?? repository.defaultBranch ?? "main" + let encodedBranch = branch.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? branch + let directory = filePath.contains("/") + ? filePath.components(separatedBy: "/").dropLast() + .map { $0.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? $0 } + .joined(separator: "/") + "/" + : "" + return URL(string: "\(serverURL)/\(repository.fullName)/src/branch/\(encodedBranch)/\(directory)") + } + + #if DEBUG + init(preview _: Void, repository: Repository, filePath: String, fileName: String, + authService: AuthenticationService, ref: String? = nil, + fileContent: String? = nil, fileSha: String? = nil) + { + self.repository = repository + self.filePath = filePath + self.fileName = fileName + self.authService = authService + self.ref = ref + repositoryService = nil + _fileContent = State(initialValue: fileContent) + _fileSha = State(initialValue: fileSha) + } + #endif + + private func loadFile() async { + guard let repositoryService else { return } + isLoading = true + errorMessage = nil + + do { + let fileContentData = try await repositoryService.fetchFileContent( + owner: repository.owner, + repo: repository.repoName, + path: filePath, + ref: ref, + ) + + fileContent = fileContentData.decodedContent + fileSha = fileContentData.sha + + } catch { + errorMessage = error.localizedDescription + showError = true + } + + isLoading = false + } +} + +struct CommitSheetView: View { + let fileName: String + let repositoryService: RepositoryService + let repository: Repository + let filePath: String + let editedContent: String + let fileSha: String + let branch: String? + let onCommit: (FileContent) async -> Void + + @Environment(\.dismiss) private var dismiss + @State private var commitMessage: String = "" + @State private var isCommitting = false + @State private var errorMessage: String? + @State private var showError = false + + var body: some View { + NavigationStack { + Form { + Section("Commit Message") { + TextField("Update \(fileName)", text: $commitMessage) + .accessibilityIdentifier("commit-message-field") + } + } + .navigationTitle("Commit Changes") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Commit") { + Task { + await commit() + } + } + .disabled(isCommitting) + .accessibilityIdentifier("commit-submit") + } + } + .errorAlert(message: $errorMessage, isPresented: $showError) + } + } + + private func commit() async { + isCommitting = true + + do { + let message = commitMessage.isEmpty ? "Update \(fileName)" : commitMessage + + let updatedFile = try await repositoryService.updateFile( + owner: repository.owner, + repo: repository.repoName, + path: filePath, + content: editedContent, + sha: fileSha, + message: message, + branch: branch, + ) + + await onCommit(updatedFile) + dismiss() + return + } catch { + errorMessage = error.localizedDescription + showError = true + } + + isCommitting = false + } +} + +#Preview("Markdown") { + NavigationStack { + FileViewerView( + preview: (), + repository: .preview, + filePath: "README.md", + fileName: "README.md", + authService: .previewDefault, + fileContent: """ + # test-repo + + A sample repository for testing. + + ## Getting Started + + ```bash + git clone https://example.com/testadmin/test-repo.git + cd test-repo + ``` + + ## Features + + - Written in **Python** + - Includes `hello.py` and `src/main.py` + - Licensed under MIT + """, + fileSha: "abc123", + ) + } +} + +#Preview("Code") { + NavigationStack { + FileViewerView( + preview: (), + repository: .preview, + filePath: "hello.py", + fileName: "hello.py", + authService: .previewDefault, + fileContent: """ + #!/usr/bin/env python3 + + def greet(name: str) -> str: + \"\"\"Return a greeting message.\"\"\" + return f"Hello, {name}!" + + if __name__ == "__main__": + print(greet("World")) + """, + fileSha: "def456", + ) + } +} diff --git a/Forji/Forji/Views/FloatingButtons.swift b/Forji/Forji/Views/FloatingButtons.swift new file mode 100644 index 0000000..af60d98 --- /dev/null +++ b/Forji/Forji/Views/FloatingButtons.swift @@ -0,0 +1,45 @@ +import SwiftUI + +struct FloatingCreateButton: View { + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: "plus") + .font(.title2.bold()) + .foregroundStyle(.white) + .frame(width: 56, height: 56) + } + .glassEffect(.regular.tint(.blue).interactive()) + .padding(.trailing, 20) + .padding(.bottom, 20) + } +} + +struct ExpandableActionMenu: View { + @Binding var isExpanded: Bool + @ViewBuilder let content: () -> Content + + var body: some View { + VStack(alignment: .trailing, spacing: 10) { + if isExpanded { + content() + } + + Button { + withAnimation(.snappy) { + isExpanded.toggle() + } + } label: { + Image(systemName: isExpanded ? "xmark" : "wrench.and.screwdriver") + .font(.body) + .frame(width: 40, height: 40) + } + .buttonStyle(.glass) + .accessibilityIdentifier("action-menu-toggle") + .accessibilityValue(isExpanded ? "expanded" : "collapsed") + } + .padding(.trailing, 20) + .padding(.bottom, 20) + } +} diff --git a/Forji/Forji/Views/FlowLayout.swift b/Forji/Forji/Views/FlowLayout.swift new file mode 100644 index 0000000..78861f4 --- /dev/null +++ b/Forji/Forji/Views/FlowLayout.swift @@ -0,0 +1,46 @@ +import SwiftUI + +struct FlowLayout: Layout { + var spacing: CGFloat = 4 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache _: inout ()) -> CGSize { + let result = arrange(proposal: proposal, subviews: subviews) + return result.size + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache _: inout ()) { + let result = arrange(proposal: proposal, subviews: subviews) + for (index, position) in result.positions.enumerated() { + subviews[index].place( + at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y), + proposal: .unspecified, + ) + } + } + + private func arrange(proposal: ProposedViewSize, subviews: Subviews) -> (positions: [CGPoint], size: CGSize) { + let maxWidth = proposal.width ?? .infinity + var positions: [CGPoint] = [] + var xPos: CGFloat = 0 + var yPos: CGFloat = 0 + var rowHeight: CGFloat = 0 + var totalHeight: CGFloat = 0 + var totalWidth: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if xPos + size.width > maxWidth, xPos > 0 { + xPos = 0 + yPos += rowHeight + spacing + rowHeight = 0 + } + positions.append(CGPoint(x: xPos, y: yPos)) + rowHeight = max(rowHeight, size.height) + xPos += size.width + spacing + totalWidth = max(totalWidth, xPos - spacing) + totalHeight = yPos + rowHeight + } + + return (positions, CGSize(width: totalWidth, height: totalHeight)) + } +} diff --git a/Forji/Forji/Views/HomeView.swift b/Forji/Forji/Views/HomeView.swift new file mode 100644 index 0000000..2a69621 --- /dev/null +++ b/Forji/Forji/Views/HomeView.swift @@ -0,0 +1,180 @@ +import ForgejoKit +import SwiftUI + +struct HomeView: View { + @State private var authService: AuthenticationService + @State private var unreadCount = 0 + @State private var selectedTab = 0 + + private let notificationService: NotificationService? + + init(authService: AuthenticationService) { + self.authService = authService + notificationService = authService.client.map { NotificationService(client: $0) } + } + + var body: some View { + TabView(selection: $selectedTab) { + Tab("Repositories", systemImage: "folder.fill", value: 0) { + NavigationStack { + RepositoryListView(authService: authService) + } + } + + Tab("Issues", systemImage: "exclamationmark.circle.fill", value: 1) { + NavigationStack { + IssuesOverviewView(authService: authService) + } + } + + Tab("Pull Requests", systemImage: "arrow.triangle.pull", value: 2) { + NavigationStack { + PullRequestsOverviewView(authService: authService) + } + } + + Tab("Notifications", systemImage: "bell.fill", value: 3) { + NavigationStack { + NotificationsOverviewView(authService: authService) + } + } + .badge(unreadCount) + + Tab("Settings", systemImage: "gearshape", value: 4) { + NavigationStack { + SettingsTabView(authService: authService) + } + } + } + .tabBarMinimizeBehavior(.onScrollDown) + .task { + await loadUnreadCount() + } + .onChange(of: selectedTab) { + Task { await loadUnreadCount() } + } + } + + private func loadUnreadCount() async { + guard let notificationService else { return } + do { + unreadCount = try await notificationService.fetchUnreadCount() + } catch { + // Silently ignore — badge is non-critical + } + } +} + +// MARK: - Settings Tab + +struct SettingsTabView: View { + @AppStorage("appearance") private var appearance: AppAppearance = .system + @State private var authService: AuthenticationService + @Environment(\.modelContext) private var modelContext + + init(authService: AuthenticationService) { + self.authService = authService + } + + var body: some View { + List { + Section("Appearance") { + Picker("Theme", selection: $appearance) { + Text("System").tag(AppAppearance.system) + Text("Light").tag(AppAppearance.light) + Text("Dark").tag(AppAppearance.dark) + } + .pickerStyle(.inline) + .labelsHidden() + } + + if let user = authService.currentUser { + Section { + VStack(alignment: .leading, spacing: 8) { + Text(user.fullName ?? user.login) + .font(.headline) + Text("@\(user.login)") + .font(.subheadline) + .foregroundStyle(.secondary) + if let email = user.email { + Text(email) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 8) + } + } + + if let instance = authService.currentInstance { + Section("Instance") { + LabeledContent("Server", value: instance.serverURL) + LabeledContent("User", value: instance.username) + } + } + + Section { + Button { + authService.disconnect() + } label: { + Label("Switch Instance", systemImage: "arrow.triangle.swap") + } + .accessibilityIdentifier("home-switch-instance-button") + + Button(role: .destructive) { + Task { await authService.logout(modelContext: modelContext) } + } label: { + Label("Logout", systemImage: "rectangle.portrait.and.arrow.right") + } + .accessibilityIdentifier("home-logout-button") + } + + Section("About") { + VStack(alignment: .leading, spacing: 8) { + Text("License") + .font(.subheadline) + .foregroundStyle(.secondary) + // swiftlint:disable:next line_length + Text("Forji is free software licensed under the GNU General Public License v3.0 or later (GPLv3+). You are free to use, modify, and redistribute it under the terms of that license.") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + + LabeledContent("Copyright", value: "Stefan Hausotte") + + VStack(alignment: .leading, spacing: 8) { + Text("Logo") + .font(.subheadline) + .foregroundStyle(.secondary) + Text("The Forji logo is based on the Forgejo logo by Caesar Schinas, licensed under CC BY-SA 4.0.") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + + VStack(alignment: .leading, spacing: 8) { + Text("Libraries") + .font(.subheadline) + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 4) { + Text("ForgejoKit (MIT)") + Link("Textual (MIT)", destination: URL(string: "https://github.com/gonzalezreal/textual")!) + Link( + "HighlightSwift (MIT)", + destination: URL(string: "https://github.com/appstefan/HighlightSwift")!, + ) + } + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + } + .navigationTitle("Settings") + } +} + +#Preview { + HomeView(authService: .previewDefault) +} diff --git a/Forji/Forji/Views/InlineCommentSheet.swift b/Forji/Forji/Views/InlineCommentSheet.swift new file mode 100644 index 0000000..4092a23 --- /dev/null +++ b/Forji/Forji/Views/InlineCommentSheet.swift @@ -0,0 +1,139 @@ +import ForgejoKit +import SwiftUI + +struct DiffLineHeader: View { + let path: String + let line: DiffLine + + var body: some View { + VStack(spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "doc.text") + .font(.caption) + .foregroundStyle(.secondary) + Text(path) + .font(.system(.caption, design: .monospaced)) + Spacer() + if let oldLine = line.oldLineNumber { + Text("L\(oldLine)") + .font(.caption) + .foregroundStyle(.secondary) + } + if let newLine = line.newLineNumber { + Text("L\(newLine)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Text(line.content) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(line.type.color) + .padding(6) + .frame(maxWidth: .infinity, alignment: .leading) + .background(line.type.backgroundColor) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + .padding() + } +} + +struct InlineCommentSheet: View { + let context: InlineCommentContext + let prService: PullRequestService + let owner: String + let repo: String + let prNumber: Int + let isOwnPR: Bool + var onSubmitted: (() -> Void)? + + @State private var commentBody = "" + @State private var reviewEvent: ReviewEvent = .comment + @State private var isSubmitting = false + @State private var errorMessage: String? + @State private var showError = false + @Environment(\.dismiss) private var dismiss + + private var line: DiffLine { + context.line + } + + private var path: String { + context.path + } + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + DiffLineHeader(path: path, line: line) + + if !isOwnPR { + Picker("Type", selection: $reviewEvent) { + Label(ReviewEvent.comment.title, systemImage: ReviewEvent.comment.systemImage) + .tag(ReviewEvent.comment) + Label(ReviewEvent.requestChanges.title, systemImage: ReviewEvent.requestChanges.systemImage) + .tag(ReviewEvent.requestChanges) + } + .pickerStyle(.segmented) + .padding(.horizontal) + } + + TextEditor(text: $commentBody) + .frame(maxHeight: .infinity) + .scrollContentBackground(.hidden) + .padding(8) + .background(Color(.tertiarySystemFill)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .padding(.horizontal) + .padding(.bottom) + } + .navigationTitle("Inline Comment") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Submit") { + Task { await submit() } + } + .disabled(commentBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSubmitting) + } + } + .errorAlert(message: $errorMessage, isPresented: $showError) + } + } + + private func submit() async { + guard line.diffPosition != nil else { + errorMessage = "Cannot comment on this line (no diff position)" + showError = true + return + } + isSubmitting = true + do { + let oldPosition = line.type == .deletion ? line.diffPosition : nil + let newPosition = line.type == .deletion ? nil : line.diffPosition + let inlineComment = CreateReviewComment( + body: commentBody, + path: path, + oldPosition: oldPosition, + newPosition: newPosition, + ) + _ = try await prService.createReview( + owner: owner, + repo: repo, + index: prNumber, + body: "", + event: reviewEvent.rawValue, + comments: [inlineComment], + ) + onSubmitted?() + dismiss() + } catch { + errorMessage = error.localizedDescription + showError = true + } + isSubmitting = false + } +} diff --git a/Forji/Forji/Views/InstanceFormView.swift b/Forji/Forji/Views/InstanceFormView.swift new file mode 100644 index 0000000..ff2a4fe --- /dev/null +++ b/Forji/Forji/Views/InstanceFormView.swift @@ -0,0 +1,267 @@ +import ForgejoKit +import SwiftData +import SwiftUI + +struct InstanceFormView: View { + enum Mode: Identifiable { + case add + case edit(ForgejoInstance) + + var id: String { + switch self { + case .add: "add" + case let .edit(instance): instance.serverURL + instance.username + } + } + } + + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + @Query(sort: \ForgejoInstance.lastUsed, order: .reverse) private var instances: [ForgejoInstance] + @State private var authService: AuthenticationService + + let mode: Mode + + #if DEBUG + @AppStorage("dev_serverURL") private var devServerURL = "" + @AppStorage("dev_username") private var devUsername = "" + @AppStorage("dev_password") private var devPassword = "" + @AppStorage("dev_allowSelfSigned") private var devAllowSelfSigned = false + #endif + + @State private var name = "" + @State private var serverURL = "" + @State private var username = "" + @State private var password = "" + @State private var allowSelfSignedCertificates = false + @State private var isDefault = false + @State private var otpCode = "" + @State private var needsOTP = false + @State private var isLoading = false + @State private var errorMessage: String? + @State private var showError = false + + init(authService: AuthenticationService, mode: Mode) { + self.authService = authService + self.mode = mode + } + + var body: some View { + NavigationStack { + Form { + Section { + TextField("Name (optional)", text: $name) + .autocorrectionDisabled() + .accessibilityIdentifier("instance-name-field") + + TextField("Server URL", text: $serverURL) + .textContentType(.URL) + .autocapitalization(.none) + .autocorrectionDisabled() + .keyboardType(.URL) + .accessibilityIdentifier("login-serverURL-field") + + TextField("Username", text: $username) + .textContentType(.username) + .autocapitalization(.none) + .autocorrectionDisabled() + .accessibilityIdentifier("login-username-field") + + SecureField("Password", text: $password) + .textContentType(.password) + .accessibilityIdentifier("login-password-field") + + if needsOTP { + TextField("One-Time Code", text: $otpCode) + .keyboardType(.numberPad) + .textContentType(.oneTimeCode) + .accessibilityIdentifier("login-otp-field") + } + } header: { + Text("Forgejo Instance") + } footer: { + if needsOTP { + Text("Enter the 6-digit code from your authenticator app.") + } else { + switch mode { + case .add: + Text("Enter your Forgejo server URL (e.g., forgejo.example.com)") + case .edit: + Text("Leave password blank to keep the existing one.") + } + } + } + + Section { + Toggle("Accept Self-Signed Certificates", isOn: $allowSelfSignedCertificates) + } footer: { + Text("Enable this if your Forgejo instance uses a self-signed SSL certificate.") + .font(.caption) + } + + Section { + Toggle("Default Instance", isOn: $isDefault) + } footer: { + Text("The default instance will be connected automatically on launch.") + .font(.caption) + } + + Section { + Button(action: handleSave) { + HStack { + Spacer() + if isLoading { + ProgressView() + .progressViewStyle(.circular) + } else { + Text(isAddMode ? "Login" : "Save") + .fontWeight(.semibold) + } + Spacer() + } + } + .buttonStyle(.borderedProminent) + .disabled( + isLoading || serverURL.isEmpty || username.isEmpty + || (isAddMode && password.isEmpty) || (needsOTP && otpCode.isEmpty), + ) + .listRowBackground(Color.clear) + .accessibilityIdentifier("login-button") + } + } + .navigationTitle(isAddMode ? "Add Instance" : "Edit Instance") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + .errorAlert("Login Failed", message: $errorMessage, isPresented: $showError) + .onAppear { + populateFields() + } + } + } + + private var isAddMode: Bool { + if case .add = mode { return true } + return false + } + + private func populateFields() { + switch mode { + case .add: + #if DEBUG + if !devServerURL.isEmpty { serverURL = devServerURL } + if !devUsername.isEmpty { username = devUsername } + if !devPassword.isEmpty { password = devPassword } + allowSelfSignedCertificates = devAllowSelfSigned + #endif + case let .edit(instance): + name = instance.name + serverURL = instance.serverURL + username = instance.username + allowSelfSignedCertificates = instance.allowSelfSignedCertificates + isDefault = instance.isDefault + } + } + + private func performLogin() async throws { + if needsOTP, !otpCode.isEmpty { + try await authService.loginWithOTP( + serverURL: serverURL, + username: username, + password: password, + otp: otpCode, + allowSelfSigned: allowSelfSignedCertificates, + ) + } else { + try await authService.login( + serverURL: serverURL, + username: username, + password: password, + allowSelfSigned: allowSelfSignedCertificates, + ) + } + } + + // swiftlint:disable:next function_body_length cyclomatic_complexity + private func handleSave() { + isLoading = true + errorMessage = nil + + Task { + do { + switch mode { + case .add: + try await performLogin() + + if isDefault { + for inst in instances where inst.isDefault { + inst.isDefault = false + } + } + + let normalizedURL = ForgejoClient.normalizeServerURL(serverURL) + let instance = ForgejoInstance( + serverURL: normalizedURL, + username: username, + name: name, + isDefault: isDefault, + allowSelfSignedCertificates: allowSelfSignedCertificates, + ) + modelContext.insert(instance) + do { + try modelContext.save() + } catch { + assertionFailure("SwiftData save failed: \(error)") + } + authService.currentInstance = instance + + case let .edit(instance): + if !password.isEmpty { + try await performLogin() + } + + instance.name = name + instance.serverURL = ForgejoClient.normalizeServerURL(serverURL) + instance.username = username + instance.allowSelfSignedCertificates = allowSelfSignedCertificates + + if isDefault, !instance.isDefault { + for inst in instances where inst.isDefault { + inst.isDefault = false + } + } + instance.isDefault = isDefault + + do { + try modelContext.save() + } catch { + assertionFailure("SwiftData save failed: \(error)") + } + authService.currentInstance = instance + } + + dismiss() + } catch AuthenticationError.otpRequired { + needsOTP = true + } catch { + errorMessage = error.localizedDescription + showError = true + } + + isLoading = false + } + } +} + +#Preview("Add") { + InstanceFormView(authService: .previewDefault, mode: .add) + .modelContainer(for: ForgejoInstance.self, inMemory: true) +} + +#Preview("Edit") { + InstanceFormView(authService: .previewDefault, mode: .edit(.preview)) + .modelContainer(for: ForgejoInstance.self, inMemory: true) +} diff --git a/Forji/Forji/Views/InstanceListView.swift b/Forji/Forji/Views/InstanceListView.swift new file mode 100644 index 0000000..ed40d4d --- /dev/null +++ b/Forji/Forji/Views/InstanceListView.swift @@ -0,0 +1,181 @@ +import ForgejoKit +import SwiftData +import SwiftUI + +struct InstanceListView: View { + @Environment(\.modelContext) private var modelContext + @Query(sort: \ForgejoInstance.lastUsed, order: .reverse) private var instances: [ForgejoInstance] + @State private var authService: AuthenticationService + @State private var showAddSheet = false + @State private var editingInstance: ForgejoInstance? + @State private var connectingInstance: ForgejoInstance? + @State private var errorMessage: String? + @State private var showError = false + + init(authService: AuthenticationService) { + self.authService = authService + } + + var body: some View { + NavigationStack { + Group { + if instances.isEmpty { + ContentUnavailableView { + Label("No Instances", systemImage: "server.rack") + .foregroundStyle(.blue) + } description: { + Text("Add your first Forgejo instance to get started.") + } + } else { + List { + ForEach(instances) { instance in + instanceRow(instance) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + deleteInstance(instance) + } label: { + Label("Delete", systemImage: "trash") + } + } + .swipeActions(edge: .leading, allowsFullSwipe: true) { + Button { + toggleDefault(instance) + } label: { + Label( + instance.isDefault ? "Unset Default" : "Set Default", + systemImage: instance.isDefault ? "star.slash" : "star.fill", + ) + } + .tint(.yellow) + } + } + } + .accessibilityIdentifier("instance-list") + } + } + .navigationTitle("Instances") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + showAddSheet = true + } label: { + Image(systemName: "plus") + } + .accessibilityIdentifier("instance-add-button") + } + } + .sheet(isPresented: $showAddSheet) { + InstanceFormView(authService: authService, mode: .add) + } + .sheet(item: $editingInstance) { instance in + InstanceFormView(authService: authService, mode: .edit(instance)) + } + .errorAlert("Connection Failed", message: $errorMessage, isPresented: $showError) + } + } + + private func instanceRow(_ instance: ForgejoInstance) -> some View { + Button { + connect(to: instance) + } label: { + HStack { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(instance.name.isEmpty ? instance.serverURL : instance.name) + .font(.headline) + if instance.isDefault { + Text("Default") + .font(.caption2) + .fontWeight(.medium) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .glassEffect(.regular.tint(.yellow)) + } + } + Text(instance.serverURL) + .font(.subheadline) + .foregroundStyle(.secondary) + Text("@\(instance.username)") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if connectingInstance?.id == instance.id { + ProgressView() + } else { + Button { + editingInstance = instance + } label: { + Image(systemName: "pencil.circle.fill") + .font(.title3) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityIdentifier("instance-edit-button") + } + } + } + .disabled(connectingInstance != nil) + .accessibilityIdentifier("instance-row") + } + + private func connect(to instance: ForgejoInstance) { + connectingInstance = instance + Task { + do { + try await authService.restoreSession(instance: instance) + instance.lastUsed = Date() + do { + try modelContext.save() + } catch { + assertionFailure("SwiftData save failed: \(error)") + } + } catch is KeychainError { + editingInstance = instance + } catch { + errorMessage = error.localizedDescription + showError = true + } + connectingInstance = nil + } + } + + private func deleteInstance(_ instance: ForgejoInstance) { + // Disconnect if deleting the currently active instance + if authService.currentInstance?.id == instance.id { + authService.disconnect() + } + let normalizedURL = ForgejoClient.normalizeServerURL(instance.serverURL) + let username = instance.username + modelContext.delete(instance) + do { + try modelContext.save() + } catch { + assertionFailure("SwiftData save failed: \(error)") + } + Task { + try? await KeychainManager.shared.deletePassword(for: normalizedURL, username: username) + } + } + + private func toggleDefault(_ instance: ForgejoInstance) { + if instance.isDefault { + instance.isDefault = false + } else { + for inst in instances where inst.isDefault { + inst.isDefault = false + } + instance.isDefault = true + } + do { + try modelContext.save() + } catch { + assertionFailure("SwiftData save failed: \(error)") + } + } +} + +#Preview { + InstanceListView(authService: .previewDefault) + .modelContainer(for: ForgejoInstance.self, inMemory: true) +} diff --git a/Forji/Forji/Views/IssueCreateView.swift b/Forji/Forji/Views/IssueCreateView.swift new file mode 100644 index 0000000..7f238f7 --- /dev/null +++ b/Forji/Forji/Views/IssueCreateView.swift @@ -0,0 +1,160 @@ +import ForgejoKit +import SwiftUI + +struct IssueCreateView: View { + let repository: Repository + @State private var authService: AuthenticationService + @State private var title = "" + @State private var bodyText = "" + @State private var isSubmitting = false + @State private var errorMessage: String? + @State private var showError = false + @State private var availableLabels: [IssueLabel] = [] + @State private var availableMilestones: [IssueMilestone] = [] + @State private var availableAssignees: [User] = [] + @State private var selectedLabelIDs: Set = [] + @State private var selectedMilestoneID: Int? + @State private var selectedAssigneeLogins: Set = [] + @Environment(\.dismiss) private var dismiss + + private let issueService: IssueService? + private let repositoryService: RepositoryService? + private let onCreated: () -> Void + private let embeddedInNavigation: Bool + + init( + repository: Repository, + authService: AuthenticationService, + embeddedInNavigation: Bool = false, + onCreated: @escaping () -> Void, + ) { + self.repository = repository + self.authService = authService + issueService = authService.client.map { IssueService(client: $0) } + repositoryService = authService.client.map { RepositoryService(client: $0) } + self.onCreated = onCreated + self.embeddedInNavigation = embeddedInNavigation + } + + private var owner: String { + repository.owner + } + + private var repo: String { + repository.repoName + } + + var body: some View { + if embeddedInNavigation { + formContent + } else { + NavigationStack { + formContent + } + } + } + + private var formContent: some View { + Form { + Section { + TextField("Title", text: $title) + .accessibilityIdentifier("issue-create-title-field") + } + + DescriptionEditorSection(text: $bodyText) + + LabelPickerSection( + availableLabels: availableLabels, + selectedLabelIDs: $selectedLabelIDs, + ) + MilestonePickerSection( + availableMilestones: availableMilestones, + selectedMilestoneID: $selectedMilestoneID, + ) + UserPickerSection( + title: "Assignees", + availableUsers: availableAssignees, + selectedLogins: $selectedAssigneeLogins, + ) + } + .navigationTitle("New Issue") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Create") { + Task { await createIssue() } + } + .disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSubmitting) + .accessibilityIdentifier("issue-create-submit") + } + } + .errorAlert(message: $errorMessage, isPresented: $showError) + .task { + await loadMetadata() + } + } + + private func loadMetadata() async { + guard let repositoryService else { return } + let metadata = await loadRepositoryMetadata(service: repositoryService, owner: owner, repo: repo) + availableLabels = metadata.labels + availableMilestones = metadata.milestones + availableAssignees = metadata.assignees + } + + private func createIssue() async { + guard let issueService else { return } + isSubmitting = true + do { + _ = try await issueService.createIssue( + owner: owner, + repo: repo, + title: title.trimmingCharacters(in: .whitespacesAndNewlines), + body: bodyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? nil : bodyText.trimmingCharacters(in: .whitespacesAndNewlines), + labels: selectedLabelIDs.isEmpty ? nil : Array(selectedLabelIDs), + milestone: selectedMilestoneID, + assignees: selectedAssigneeLogins.isEmpty ? nil : Array(selectedAssigneeLogins), + ) + onCreated() + dismiss() + } catch { + errorMessage = error.localizedDescription + showError = true + } + isSubmitting = false + } + + #if DEBUG + init(preview _: Void, repository: Repository, authService: AuthenticationService, + availableLabels: [IssueLabel] = [], availableMilestones: [IssueMilestone] = [], + availableAssignees: [User] = []) + { + self.repository = repository + self.authService = authService + issueService = nil + repositoryService = nil + onCreated = {} + embeddedInNavigation = false + _availableLabels = State(initialValue: availableLabels) + _availableMilestones = State(initialValue: availableMilestones) + _availableAssignees = State(initialValue: availableAssignees) + } + #endif +} + +#Preview { + NavigationStack { + IssueCreateView( + preview: (), + repository: .preview, + authService: .previewDefault, + availableLabels: IssueLabel.previewList, + availableMilestones: [.preview], + availableAssignees: [.preview, .previewBot], + ) + } +} diff --git a/Forji/Forji/Views/IssueDetailView.swift b/Forji/Forji/Views/IssueDetailView.swift new file mode 100644 index 0000000..5d5ef38 --- /dev/null +++ b/Forji/Forji/Views/IssueDetailView.swift @@ -0,0 +1,300 @@ +import ForgejoKit +import SwiftUI + +struct IssueDetailView: View { + let repository: Repository + let issueNumber: Int + @State private var authService: AuthenticationService + @State private var issue: Issue? + @State private var comments: [IssueComment] = [] + @State private var isLoading = true + @State private var errorMessage: String? + @State private var showError = false + @State private var showEditSheet = false + @State private var isTogglingState = false + @State private var showCommentSheet = false + @State private var showActionsExpanded = false + + private let issueService: IssueService? + + init(repository: Repository, issueNumber: Int, authService: AuthenticationService) { + self.repository = repository + self.issueNumber = issueNumber + self.authService = authService + issueService = authService.client.map { IssueService(client: $0) } + } + + private var owner: String { + repository.owner + } + + private var repo: String { + repository.repoName + } + + private var hasPushPermission: Bool { + repository.permissions?.push == true || repository.permissions?.admin == true + } + + private var isAuthor: Bool { + guard let currentUser = authService.currentUser?.login else { return false } + return issue?.user.login == currentUser + } + + private var canEditOrClose: Bool { + hasPushPermission || isAuthor + } + + var body: some View { + ZStack(alignment: .bottomTrailing) { + Group { + if isLoading, issue == nil { + ProgressView() + } else if let issue { + List { + // Header + Section { + VStack(alignment: .leading, spacing: 10) { + Text(issue.title) + .font(.title3) + .fontWeight(.bold) + .accessibilityIdentifier("issue-detail-title") + + HStack(spacing: 8) { + HStack(spacing: 4) { + Image( + systemName: issue.stateValue == .open + ? "circle.circle" : "checkmark.circle.fill", + ) + Text(issue.stateValue == .open ? "Open" : "Closed") + } + .font(.caption) + .fontWeight(.medium) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .foregroundStyle(.white) + .glassEffect(.regular.tint(issue.stateValue == .open ? .green : .purple)) + + Text("#\(issue.number)") + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Text("\(issue.user.login) opened \(formatRelativeDate(issue.createdAt))") + .font(.subheadline) + .foregroundStyle(.secondary) + + if !issue.labels.isEmpty { + FlowLayout(spacing: 4) { + ForEach(issue.labels) { label in + IssueLabelView(label: label) + } + } + } + } + .padding(.vertical, 4) + } + .listRowBackground( + (issue.stateValue == .open ? Color.green : Color.purple).opacity(0.08), + ) + + if let milestone = issue.milestone { + MilestoneDisplaySection(milestone: milestone) + } + + if let assignees = issue.assignees, !assignees.isEmpty { + AssigneesDisplaySection(assignees: assignees) + } + + // Body + if let body = issue.body, !body.isEmpty { + Section("Description") { + MarkdownPreview(text: body) + .padding(.vertical, 4) + } + } + + // Comments + if !comments.isEmpty { + Section("Comments (\(comments.count))") { + ForEach(comments) { comment in + CommentView( + comment: comment, + currentUsername: authService.currentUser?.login, + ) { newBody in + await editComment(commentId: comment.id, body: newBody) + } + } + } + } + + // Spacer so content isn't hidden behind floating bar + Section {} footer: { + Spacer().frame(height: 60) + } + } + .listStyle(.insetGrouped) + } + } + + // Floating glass action cluster + if let currentIssue = issue { + ExpandableActionMenu(isExpanded: $showActionsExpanded) { + Button { showCommentSheet = true } label: { + Label("Comment", systemImage: "text.bubble") + } + .buttonStyle(.glassProminent) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .accessibilityIdentifier("issue-comment-button") + + if canEditOrClose { + Button { showEditSheet = true } label: { + Label("Edit", systemImage: "pencil") + } + .buttonStyle(.glassProminent) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .accessibilityIdentifier("issue-edit-button") + + Button { Task { await toggleIssueState() } } label: { + if isTogglingState { + ProgressView() + .controlSize(.small) + } else { + Label( + currentIssue.stateValue == .open ? "Close" : "Reopen", + systemImage: currentIssue.stateValue == .open + ? "xmark.circle" : "arrow.uturn.left.circle", + ) + } + } + .buttonStyle(.glassProminent) + .tint(currentIssue.stateValue == .open ? .purple : .green) + .disabled(isTogglingState) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .accessibilityIdentifier("issue-toggle-state") + } + } + } + } + .navigationTitle("#\(issueNumber)") + .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $showEditSheet) { + if let issue { + IssueEditView( + repository: repository, + issue: issue, + authService: authService, + ) { updatedIssue in + self.issue = updatedIssue + } + } + } + .sheet(isPresented: $showCommentSheet) { + CommentSheet(users: []) { body in + guard let issueService else { throw URLError(.userAuthenticationRequired) } + let comment = try await issueService.createComment( + owner: owner, + repo: repo, + index: issueNumber, + body: body, + ) + comments.append(comment) + } + } + .task { + await loadData() + } + .errorAlert(message: $errorMessage, isPresented: $showError) + } + + private func loadData() async { + guard let issueService else { return } + isLoading = true + do { + async let fetchedIssue = issueService.fetchIssue(owner: owner, repo: repo, index: issueNumber) + async let fetchedComments = issueService.fetchComments(owner: owner, repo: repo, index: issueNumber) + let loadedIssue = try await fetchedIssue + let loadedComments = try await fetchedComments + try Task.checkCancellation() + issue = loadedIssue + comments = loadedComments + } catch is CancellationError { + // Ignore cancellation + } catch { + errorMessage = error.localizedDescription + showError = true + } + isLoading = false + } + + private func toggleIssueState() async { + guard let issueService else { return } + guard let issue else { return } + isTogglingState = true + + do { + let newState = issue.stateValue == .open ? IssueState.closed.rawValue : IssueState.open.rawValue + let updated = try await issueService.editIssue( + owner: owner, + repo: repo, + index: issueNumber, + title: nil, + body: nil, + state: newState, + ) + self.issue = updated + } catch { + errorMessage = error.localizedDescription + showError = true + } + + isTogglingState = false + } + + private func editComment(commentId: Int, body: String) async -> Bool { + guard let issueService else { return false } + do { + let updated = try await issueService.editComment( + owner: owner, + repo: repo, + commentId: commentId, + body: body, + ) + if let index = comments.firstIndex(where: { $0.id == commentId }) { + comments[index] = updated + } + return true + } catch { + errorMessage = error.localizedDescription + showError = true + return false + } + } + + #if DEBUG + init(preview _: Void, repository: Repository, issueNumber: Int, authService: AuthenticationService, + issue: Issue, comments: [IssueComment] = []) + { + self.repository = repository + self.issueNumber = issueNumber + self.authService = authService + issueService = nil + _issue = State(initialValue: issue) + _comments = State(initialValue: comments) + _isLoading = State(initialValue: false) + } + #endif +} + +#Preview { + NavigationStack { + IssueDetailView( + preview: (), + repository: .preview, + issueNumber: 1, + authService: .previewDefault, + issue: .preview, + comments: [.preview], + ) + } +} diff --git a/Forji/Forji/Views/IssueEditView.swift b/Forji/Forji/Views/IssueEditView.swift new file mode 100644 index 0000000..e072202 --- /dev/null +++ b/Forji/Forji/Views/IssueEditView.swift @@ -0,0 +1,169 @@ +import ForgejoKit +import SwiftUI + +struct IssueEditView: View { + let repository: Repository + let issue: Issue + @State private var authService: AuthenticationService + @State private var title: String + @State private var bodyText: String + @State private var isOpen: Bool + @State private var isSubmitting = false + @State private var errorMessage: String? + @State private var showError = false + @State private var availableLabels: [IssueLabel] = [] + @State private var availableMilestones: [IssueMilestone] = [] + @State private var availableAssignees: [User] = [] + @State private var selectedLabelIDs: Set + @State private var selectedMilestoneID: Int? + @State private var selectedAssigneeLogins: Set + @Environment(\.dismiss) private var dismiss + + private let issueService: IssueService? + private let repositoryService: RepositoryService? + private let onSaved: (Issue) -> Void + + init(repository: Repository, issue: Issue, authService: AuthenticationService, onSaved: @escaping (Issue) -> Void) { + self.repository = repository + self.issue = issue + self.authService = authService + issueService = authService.client.map { IssueService(client: $0) } + repositoryService = authService.client.map { RepositoryService(client: $0) } + self.onSaved = onSaved + _title = State(initialValue: issue.title) + _bodyText = State(initialValue: issue.body ?? "") + _isOpen = State(initialValue: issue.stateValue == .open) + _selectedLabelIDs = State(initialValue: Set(issue.labels.map(\.id))) + _selectedMilestoneID = State(initialValue: issue.milestone?.id) + _selectedAssigneeLogins = State(initialValue: Set((issue.assignees ?? []).map(\.login))) + } + + private var owner: String { + repository.owner + } + + private var repo: String { + repository.repoName + } + + var body: some View { + NavigationStack { + Form { + Section { + TextField("Title", text: $title) + .accessibilityIdentifier("issue-edit-title-field") + } + + DescriptionEditorSection(text: $bodyText) + + LabelPickerSection( + availableLabels: availableLabels, + selectedLabelIDs: $selectedLabelIDs, + ) + MilestonePickerSection( + availableMilestones: availableMilestones, + selectedMilestoneID: $selectedMilestoneID, + ) + UserPickerSection( + title: "Assignees", + availableUsers: availableAssignees, + selectedLogins: $selectedAssigneeLogins, + ) + + Section { + Toggle("Open", isOn: $isOpen) + } + } + .navigationTitle("Edit Issue") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + Task { await saveIssue() } + } + .disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSubmitting) + .accessibilityIdentifier("issue-edit-save") + } + } + .errorAlert(message: $errorMessage, isPresented: $showError) + .task { + await loadMetadata() + } + } + } + + private func loadMetadata() async { + guard let repositoryService else { return } + let metadata = await loadRepositoryMetadata(service: repositoryService, owner: owner, repo: repo) + availableLabels = metadata.labels + availableMilestones = metadata.milestones + availableAssignees = metadata.assignees + } + + private func saveIssue() async { + guard let issueService else { return } + isSubmitting = true + do { + try await issueService.replaceLabels( + owner: owner, + repo: repo, + index: issue.number, + labelIDs: Array(selectedLabelIDs), + ) + let updatedIssue = try await issueService.editIssue( + owner: owner, + repo: repo, + index: issue.number, + title: title.trimmingCharacters(in: .whitespacesAndNewlines), + body: bodyText.trimmingCharacters(in: .whitespacesAndNewlines), + state: isOpen ? IssueState.open.rawValue : IssueState.closed.rawValue, + milestone: selectedMilestoneID ?? 0, // 0 clears the milestone per Forgejo API + assignees: Array(selectedAssigneeLogins), + ) + onSaved(updatedIssue) + dismiss() + } catch { + errorMessage = error.localizedDescription + showError = true + } + isSubmitting = false + } + + #if DEBUG + init(preview _: Void, repository: Repository, issue: Issue, authService: AuthenticationService, + availableLabels: [IssueLabel] = [], availableMilestones: [IssueMilestone] = [], + availableAssignees: [User] = []) + { + self.repository = repository + self.issue = issue + self.authService = authService + issueService = nil + repositoryService = nil + onSaved = { _ in } + _title = State(initialValue: issue.title) + _bodyText = State(initialValue: issue.body ?? "") + _isOpen = State(initialValue: issue.stateValue == .open) + _selectedLabelIDs = State(initialValue: Set(issue.labels.map(\.id))) + _selectedMilestoneID = State(initialValue: issue.milestone?.id) + _selectedAssigneeLogins = State(initialValue: Set((issue.assignees ?? []).map(\.login))) + _availableLabels = State(initialValue: availableLabels) + _availableMilestones = State(initialValue: availableMilestones) + _availableAssignees = State(initialValue: availableAssignees) + } + #endif +} + +#Preview { + IssueEditView( + preview: (), + repository: .preview, + issue: .preview, + authService: .previewDefault, + availableLabels: IssueLabel.previewList, + availableMilestones: [.preview], + availableAssignees: [.preview, .previewBot], + ) +} diff --git a/Forji/Forji/Views/IssueLabelView.swift b/Forji/Forji/Views/IssueLabelView.swift new file mode 100644 index 0000000..72a2d33 --- /dev/null +++ b/Forji/Forji/Views/IssueLabelView.swift @@ -0,0 +1,59 @@ +import ForgejoKit +import SwiftUI + +struct IssueLabelView: View { + let label: IssueLabel + + var body: some View { + Text(label.name) + .font(.caption2) + .fontWeight(.medium) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .foregroundStyle(textColor) + .glassEffect(.regular.tint(backgroundColor)) + } + + private var backgroundColor: Color { + Color(hex: label.color) ?? .gray + } + + private var textColor: Color { + let hex = label.color.trimmingCharacters(in: CharacterSet(charactersIn: "#")) + guard hex.count == 6, + let rgb = UInt64(hex, radix: 16) + else { + return .white + } + let red = Double((rgb >> 16) & 0xFF) / 255.0 + let green = Double((rgb >> 8) & 0xFF) / 255.0 + let blue = Double(rgb & 0xFF) / 255.0 + let luminance = 0.299 * red + 0.587 * green + 0.114 * blue + return luminance > 0.5 ? .black : .white + } +} + +extension Color { + init?(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#")) + guard hex.count == 6, + let rgb = UInt64(hex, radix: 16) + else { + return nil + } + self.init( + red: Double((rgb >> 16) & 0xFF) / 255.0, + green: Double((rgb >> 8) & 0xFF) / 255.0, + blue: Double(rgb & 0xFF) / 255.0, + ) + } +} + +#Preview { + HStack { + ForEach(IssueLabel.previewList) { label in + IssueLabelView(label: label) + } + } + .padding() +} diff --git a/Forji/Forji/Views/IssueListView.swift b/Forji/Forji/Views/IssueListView.swift new file mode 100644 index 0000000..623fffd --- /dev/null +++ b/Forji/Forji/Views/IssueListView.swift @@ -0,0 +1,199 @@ +import ForgejoKit +import SwiftUI + +struct IssueListView: View { + let repository: Repository + @State private var authService: AuthenticationService + @State private var pagination = PaginationState() + @State private var stateFilter: IssueState = .open + @State private var showCreateSheet = false + + private let issueService: IssueService? + + init(repository: Repository, authService: AuthenticationService) { + self.repository = repository + self.authService = authService + issueService = authService.client.map { IssueService(client: $0) } + } + + private var owner: String { + repository.owner + } + + private var repo: String { + repository.repoName + } + + var body: some View { + @Bindable var pagination = pagination + ZStack(alignment: .bottomTrailing) { + VStack(spacing: 0) { + SegmentedPickerSection( + title: "State", + selection: $stateFilter, + options: [("Open", .open), ("Closed", .closed)], + accessibilityIdentifier: "issue-state-picker", + ) + + List { + if pagination.isLoading, pagination.items.isEmpty { + LoadingListSection() + } else if pagination.items.isEmpty { + ContentUnavailableView { + Label( + "No Issues", + systemImage: stateFilter == .open + ? "checkmark.circle" : "exclamationmark.circle", + ) + .foregroundStyle(stateFilter == .open ? .green : .secondary) + } description: { + Text( + stateFilter == .open + ? "All clear — no open issues to review." + : "No closed issues found.", + ) + } + } else { + Section { + ForEach(pagination.items) { issue in + NavigationLink { + IssueDetailView( + repository: repository, + issueNumber: issue.number, + authService: authService, + ) + } label: { + IssueRow(issue: issue) + } + } + + if pagination.hasMore { + ProgressView() + .frame(maxWidth: .infinity) + .listRowBackground(Color.clear) + .accessibilityIdentifier("load-more-indicator") + .task { + await loadMoreIssues() + } + } + } + } + } + .listStyle(.insetGrouped) + .refreshable { + await reloadIssues().value + } + } + + // Floating glass create button + FloatingCreateButton(action: { showCreateSheet = true }) + .accessibilityIdentifier("issue-create-button") + } + .sheet(isPresented: $showCreateSheet) { + IssueCreateView( + repository: repository, + authService: authService, + ) { + reloadIssues() + } + } + .task { + reloadIssues() + } + .onChange(of: stateFilter) { + reloadIssues(clearItems: true) + } + .errorAlert(message: $pagination.errorMessage, isPresented: $pagination.showError) + } + + @discardableResult + private func reloadIssues(clearItems: Bool = false) -> Task { + guard let issueService else { return Task {} } + return pagination.reload(clearItems: clearItems) { [self] page, limit in + try await issueService.fetchIssues( + owner: owner, + repo: repo, + state: stateFilter.rawValue, + page: page, + limit: limit, + ) + } + } + + private func loadMoreIssues() async { + guard let issueService else { return } + await pagination.loadMore { page, limit in + try await issueService.fetchIssues( + owner: owner, + repo: repo, + state: stateFilter.rawValue, + page: page, + limit: limit, + ) + } + } + + #if DEBUG + init(preview _: Void, repository: Repository, authService: AuthenticationService, issues: [Issue]) { + self.repository = repository + self.authService = authService + issueService = nil + _pagination = State(initialValue: PaginationState(items: issues)) + } + #endif +} + +#Preview { + NavigationStack { + IssueListView( + preview: (), + repository: .preview, + authService: .previewDefault, + issues: [.preview], + ) + } +} + +struct IssueRow: View { + let issue: Issue + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: issue.stateValue == .open ? "circle.circle" : "checkmark.circle.fill") + .foregroundStyle(issue.stateValue == .open ? .green : .purple) + .font(.body) + .padding(.top, 2) + + VStack(alignment: .leading, spacing: 4) { + Text(issue.title) + .font(.body) + .lineLimit(2) + + if let repository = issue.repository { + Text(repository.fullName) + .font(.caption) + .foregroundStyle(.secondary) + } + + HStack(spacing: 6) { + Text("#\(issue.number)") + if issue.comments > 0 { + Label("\(issue.comments)", systemImage: "bubble.right") + } + } + .font(.caption) + .foregroundStyle(.secondary) + + if !issue.labels.isEmpty { + FlowLayout(spacing: 4) { + ForEach(issue.labels) { label in + IssueLabelView(label: label) + } + } + } + } + } + .padding(.vertical, 2) + .stateAccent(issue.stateValue == .open ? .green : .purple) + } +} diff --git a/Forji/Forji/Views/IssuesOverviewView.swift b/Forji/Forji/Views/IssuesOverviewView.swift new file mode 100644 index 0000000..26ac249 --- /dev/null +++ b/Forji/Forji/Views/IssuesOverviewView.swift @@ -0,0 +1,59 @@ +import ForgejoKit +import SwiftUI + +struct IssuesOverviewView: View { + let authService: AuthenticationService + + init(authService: AuthenticationService) { + self.authService = authService + } + + var body: some View { + SearchableOverviewView( + authService: authService, + issueType: "issues", + navigationTitle: "Issues", + searchPrompt: "Search issues", + emptyTitle: "No Issues", + emptyOpenIcon: "checkmark.circle", + emptyClosedIcon: "exclamationmark.circle", + itemNoun: "issues", + createButtonId: "issue-create-button", + showReviewRequested: false, + row: { issue in + IssueRow(issue: issue) + }, + detail: { repository, number, auth in + IssueDetailView( + repository: repository, + issueNumber: number, + authService: auth, + ) + }, + createView: { repo, auth, embedded, onCreated in + IssueCreateView( + repository: repo, + authService: auth, + embeddedInNavigation: embedded, + onCreated: onCreated, + ) + }, + ) + } + + #if DEBUG + init(preview _: Void, authService: AuthenticationService, issues _: [Issue]) { + self.authService = authService + } + #endif +} + +#Preview { + NavigationStack { + IssuesOverviewView( + preview: (), + authService: .previewDefault, + issues: [.previewWithRepo], + ) + } +} diff --git a/Forji/Forji/Views/ListHelpers.swift b/Forji/Forji/Views/ListHelpers.swift new file mode 100644 index 0000000..905eeb7 --- /dev/null +++ b/Forji/Forji/Views/ListHelpers.swift @@ -0,0 +1,47 @@ +import SwiftUI + +struct SegmentedPickerSection: View { + let title: String + @Binding var selection: Selection + let options: [(label: String, tag: Selection)] + let accessibilityIdentifier: String + + var body: some View { + HStack(spacing: 8) { + ForEach(options, id: \.tag) { option in + let isSelected = selection == option.tag + Button { + withAnimation(.snappy(duration: 0.2)) { + selection = option.tag + } + } label: { + Text(option.label) + .font(.subheadline) + .fontWeight(isSelected ? .semibold : .regular) + .padding(.horizontal, 14) + .padding(.vertical, 7) + .frame(maxWidth: .infinity) + .foregroundStyle(isSelected ? .primary : .secondary) + .glassEffect(.regular.tint(isSelected ? .blue : .clear), in: .capsule) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal) + .padding(.vertical, 6) + .accessibilityIdentifier(accessibilityIdentifier) + } +} + +struct LoadingListSection: View { + var body: some View { + Section { + HStack { + Spacer() + ProgressView() + Spacer() + } + .listRowBackground(Color.clear) + } + } +} diff --git a/Forji/Forji/Views/MarkdownComponents.swift b/Forji/Forji/Views/MarkdownComponents.swift new file mode 100644 index 0000000..1c27bee --- /dev/null +++ b/Forji/Forji/Views/MarkdownComponents.swift @@ -0,0 +1,218 @@ +import ForgejoKit +import SwiftUI +import Textual + +enum EditPreviewTab: String, CaseIterable { + case edit = "Edit" + case preview = "Preview" +} + +struct MarkdownEditorField: View { + @Binding var text: String + @Binding var selectedTab: EditPreviewTab + var minHeight: CGFloat = 150 + var showToolbar: Bool = false + + var body: some View { + VStack(spacing: 0) { + Picker("Mode", selection: $selectedTab) { + ForEach(EditPreviewTab.allCases, id: \.self) { tab in + Text(tab.rawValue).tag(tab) + } + } + .pickerStyle(.segmented) + .padding(.bottom, 10) + + if selectedTab == .edit { + VStack(spacing: 0) { + if showToolbar { + MarkdownToolbar(text: $text) + } + TextEditor(text: $text) + .frame(minHeight: minHeight, maxHeight: .infinity) + .scrollContentBackground(.hidden) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .padding(8) + .accessibilityIdentifier("markdown-text-editor") + } + .background(Color(.tertiarySystemFill)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else { + Group { + if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Text("Nothing to preview") + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + MarkdownPreview(text: text) + } + } + .frame(minHeight: minHeight, maxHeight: .infinity, alignment: .topLeading) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.tertiarySystemFill)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + } +} + +private struct MarkdownToolbar: View { + @Binding var text: String + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + toolbarButton("bold", icon: "bold") { wrap("**") } + toolbarButton("italic", icon: "italic") { wrap("_") } + toolbarButton("heading", icon: "number") { prefix("# ") } + toolbarButton("code", icon: "chevron.left.forwardslash.chevron.right") { wrap("`") } + toolbarButton("codeblock", icon: "text.page") { wrapBlock("```") } + toolbarButton("link", icon: "link") { insertLink() } + toolbarButton("list", icon: "list.bullet") { prefix("- ") } + toolbarButton("quote", icon: "text.quote") { prefix("> ") } + toolbarButton("task", icon: "checklist") { prefix("- [ ] ") } + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + } + } + + private func toolbarButton(_ id: String, icon: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: icon) + .font(.subheadline) + .frame(width: 28, height: 28) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(.primary) + .accessibilityIdentifier("markdown-toolbar-\(id)") + } + + private func wrap(_ marker: String) { + text.append("\(marker)text\(marker)") + } + + private func prefix(_ marker: String) { + if text.isEmpty || text.hasSuffix("\n") { + text.append(marker) + } else { + text.append("\n\(marker)") + } + } + + private func wrapBlock(_ fence: String) { + if text.isEmpty || text.hasSuffix("\n") { + text.append("\(fence)\n\n\(fence)") + } else { + text.append("\n\(fence)\n\n\(fence)") + } + } + + private func insertLink() { + text.append("[title](url)") + } +} + +struct MarkdownPreview: View { + let text: String + var baseURL: URL? + var onNavigateToFile: ((String) -> Void)? + + var body: some View { + let segments = MermaidParser.parse(text) + let hasMermaid = segments.contains { if case .mermaid = $0 { true } else { false } } + + if hasMermaid { + VStack(alignment: .leading, spacing: 12) { + ForEach(Array(segments.enumerated()), id: \.offset) { _, segment in + switch segment { + case let .text(markdown): + StructuredText(markdown: markdown, baseURL: baseURL) + .textual.inlineStyle( + InlineStyle() + .code( + .monospaced, .fontScale(0.85), + .foregroundColor(.secondary), + .backgroundColor(.secondary.opacity(0.15)), + ), + ) + .textual.tableStyle(.overflow) + .textual.overflowMode(.scroll) + .textual.textSelection(.enabled) + case let .mermaid(code): + MermaidDiagramView(code: code) + } + } + } + .environment(\.openURL, openURLAction) + } else { + StructuredText(markdown: text, baseURL: baseURL) + .textual.inlineStyle( + InlineStyle() + .code( + .monospaced, .fontScale(0.85), + .foregroundColor(.secondary), + .backgroundColor(.secondary.opacity(0.15)), + ), + ) + .textual.tableStyle(.overflow) + .textual.overflowMode(.scroll) + .textual.textSelection(.enabled) + .environment(\.openURL, openURLAction) + } + } + + private var openURLAction: OpenURLAction { + OpenURLAction { url in + if let onNavigateToFile, let path = repoRelativePath(from: url) { + onNavigateToFile(path) + return .handled + } + return .systemAction + } + } +} + +/// Extracts a repo-relative file path from a Forgejo URL. +/// URL pattern: `{serverURL}/[subpath/]{owner}/{repo}/src/branch/{ref}/{filePath}` +/// Searches for the `src/branch` segment pair to handle servers running under a subpath. +/// Returns the `filePath` portion if the URL matches, `nil` otherwise. +func repoRelativePath(from url: URL) -> String? { + let parts = url.pathComponents.filter { $0 != "/" } + // Find the "src" segment followed by "branch" + guard let srcIndex = parts.indices.first(where: { + parts[$0] == "src" && $0 + 1 < parts.count && parts[$0 + 1] == "branch" + }), + srcIndex + 2 < parts.count // need at least ref after "branch" + else { return nil } + // After "src", "branch", ref comes the file path + let filePathStart = srcIndex + 3 + guard filePathStart < parts.count else { return nil } + return parts.dropFirst(filePathStart).joined(separator: "/") +} + +struct MermaidDiagramView: View { + let code: String + @State private var height: CGFloat = 200 + + var body: some View { + MermaidWebView(code: code, height: $height) + .frame(height: height) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } +} + +#Preview("Editor") { + @Previewable @State var text = "# Hello\n\nThis is **bold** and *italic* text.\n\n- Item 1\n- Item 2" + @Previewable @State var tab: EditPreviewTab = .edit + MarkdownEditorField(text: $text, selectedTab: $tab) + .padding() +} + +#Preview("Preview") { + MarkdownPreview(text: "# Hello\n\nThis is **bold** and *italic* text.\n\n```swift\nlet x = 42\n```") + .padding() +} diff --git a/Forji/Forji/Views/MentionableEditorField.swift b/Forji/Forji/Views/MentionableEditorField.swift new file mode 100644 index 0000000..76904ed --- /dev/null +++ b/Forji/Forji/Views/MentionableEditorField.swift @@ -0,0 +1,88 @@ +import ForgejoKit +import SwiftUI + +struct MentionableEditorField: View { + @Binding var text: String + @Binding var selectedTab: EditPreviewTab + var users: [User] + var minHeight: CGFloat = 150 + var showToolbar: Bool = false + + @State private var mentionQuery: String? + + private var matchingUsers: [User] { + guard let query = mentionQuery, !query.isEmpty else { + return [] + } + return users.filter { $0.login.localizedCaseInsensitiveContains(query) } + } + + var body: some View { + VStack(spacing: 0) { + MarkdownEditorField( + text: $text, + selectedTab: $selectedTab, + minHeight: minHeight, + showToolbar: showToolbar, + ) + + if selectedTab == .edit, mentionQuery != nil, !matchingUsers.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(matchingUsers.prefix(10)) { user in + Button { + insertMention(user.login) + } label: { + Text("@\(user.login)") + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .glassEffect(.regular) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 4) + .padding(.vertical, 6) + } + } + } + .onChange(of: text) { + mentionQuery = detectMentionQuery(in: text) + } + } + + private func detectMentionQuery(in text: String) -> String? { + // Find the last @ that isn't preceded by a word character + guard let atIndex = text.lastIndex(of: "@") else { return nil } + + // Check that there's no space between @ and cursor (end of string) + let afterAt = text[text.index(after: atIndex)...] + guard !afterAt.contains(" "), !afterAt.contains("\n") else { return nil } + + // Check that @ is at start or preceded by whitespace/newline + if atIndex != text.startIndex { + let before = text[text.index(before: atIndex)] + guard before == " " || before == "\n" || before == "\t" else { return nil } + } + + return String(afterAt) + } + + private func insertMention(_ username: String) { + guard let atIndex = text.lastIndex(of: "@") else { return } + text = String(text[.. Coordinator { + Coordinator(parent: self) + } + + func makeUIView(context: Context) -> WKWebView { + let config = WKWebViewConfiguration() + let controller = WKUserContentController() + controller.add(context.coordinator, name: "heightReporter") + config.userContentController = controller + + let webView = WKWebView(frame: .zero, configuration: config) + webView.isOpaque = false + webView.backgroundColor = .clear + webView.scrollView.backgroundColor = .clear + webView.scrollView.isScrollEnabled = false + webView.navigationDelegate = context.coordinator + return webView + } + + // swiftlint:disable:next function_body_length + func updateUIView(_ webView: WKWebView, context: Context) { + let theme = colorScheme == .dark ? "dark" : "default" + let key = "\(code)\n---\n\(theme)" + guard key != context.coordinator.lastRenderedKey else { return } + context.coordinator.lastRenderedKey = key + + let safeCode = code + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "\"", with: """) + .replacingOccurrences(of: "'", with: "'") + + // swiftlint:disable:next line_length + let csp = "default-src 'none'; script-src https://cdn.jsdelivr.net 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:;" + let html = """ + + + + + + + + +
+        \(safeCode)
+        
+ + + + """ + + webView.loadHTMLString(html, baseURL: nil) + } + + static func dismantleUIView(_ webView: WKWebView, coordinator _: Coordinator) { + webView.configuration.userContentController.removeScriptMessageHandler(forName: "heightReporter") + } + + class Coordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate { + let parent: MermaidWebView + var lastRenderedKey: String? + + init(parent: MermaidWebView) { + self.parent = parent + } + + func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) { + if let height = message.body as? CGFloat, height > 0 { + DispatchQueue.main.async { [weak self] in + self?.parent.height = height + } + } + } + + // swiftlint:disable:next line_length + func webView(_: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + if navigationAction.navigationType == .linkActivated { + decisionHandler(.cancel) + } else { + decisionHandler(.allow) + } + } + } +} diff --git a/Forji/Forji/Views/MetadataPickers.swift b/Forji/Forji/Views/MetadataPickers.swift new file mode 100644 index 0000000..5495b93 --- /dev/null +++ b/Forji/Forji/Views/MetadataPickers.swift @@ -0,0 +1,330 @@ +import ForgejoKit +import SwiftUI + +// MARK: - Description Editor + +struct DescriptionEditorSection: View { + @Binding var text: String + var title: String = "Description" + @State private var showingEditor = false + + var body: some View { + Section(title) { + Button { + showingEditor = true + } label: { + if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Text("None") + .foregroundStyle(.secondary) + } else { + MarkdownPreview(text: text) + .font(.subheadline) + .lineLimit(3) + } + } + .tint(.primary) + .sheet(isPresented: $showingEditor) { + DescriptionEditorSheet(text: $text, title: title) + } + } + } +} + +private struct DescriptionEditorSheet: View { + @Binding var text: String + let title: String + @State private var selectedTab: EditPreviewTab = .edit + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + MarkdownEditorField( + text: $text, + selectedTab: $selectedTab, + showToolbar: true, + ) + .frame(maxHeight: .infinity) + .padding() + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + } +} + +// MARK: - Picker Sheet (shared chrome) + +private struct PickerSheet: View { + let title: String + let items: [Item] + let isSelected: (Item) -> Bool + let onToggle: (Item) -> Void + @ViewBuilder let rowContent: (Item) -> RowContent + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + ForEach(items) { item in + Button { + onToggle(item) + } label: { + HStack { + rowContent(item) + Spacer() + if isSelected(item) { + Image(systemName: "checkmark") + .foregroundStyle(.blue) + } + } + } + .tint(.primary) + } + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + } +} + +// MARK: - Label Picker + +struct LabelPickerSection: View { + let availableLabels: [IssueLabel] + @Binding var selectedLabelIDs: Set + @State private var showingPicker = false + + private var selectedLabels: [IssueLabel] { + availableLabels.filter { selectedLabelIDs.contains($0.id) } + } + + var body: some View { + Section("Labels") { + if availableLabels.isEmpty { + Text("No labels available") + .foregroundStyle(.secondary) + .font(.subheadline) + } else { + Button { + showingPicker = true + } label: { + if selectedLabels.isEmpty { + Text("None") + .foregroundStyle(.secondary) + } else { + FlowLayout(spacing: 4) { + ForEach(selectedLabels) { label in + IssueLabelView(label: label) + } + } + } + } + .tint(.primary) + .sheet(isPresented: $showingPicker) { + PickerSheet( + title: "Labels", + items: availableLabels, + isSelected: { selectedLabelIDs.contains($0.id) }, + onToggle: { toggleLabel($0.id) }, + rowContent: { IssueLabelView(label: $0) }, + ) + } + } + } + } + + private func toggleLabel(_ id: Int) { + if selectedLabelIDs.contains(id) { + selectedLabelIDs.remove(id) + } else { + selectedLabelIDs.insert(id) + } + } +} + +// MARK: - Milestone Picker + +struct MilestonePickerSection: View { + let availableMilestones: [IssueMilestone] + @Binding var selectedMilestoneID: Int? + @State private var showingPicker = false + + private var selectedMilestone: IssueMilestone? { + availableMilestones.first { $0.id == selectedMilestoneID } + } + + var body: some View { + Section("Milestone") { + if availableMilestones.isEmpty { + Text("No milestones available") + .foregroundStyle(.secondary) + .font(.subheadline) + } else { + Button { + showingPicker = true + } label: { + if let milestone = selectedMilestone { + VStack(alignment: .leading) { + Text(milestone.title) + .font(.subheadline) + if let due = milestone.dueOn { + Text("Due \(due, style: .date)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } else { + Text("None") + .foregroundStyle(.secondary) + } + } + .tint(.primary) + .sheet(isPresented: $showingPicker) { + PickerSheet( + title: "Milestone", + items: availableMilestones, + isSelected: { selectedMilestoneID == $0.id }, + onToggle: { selectMilestone($0.id) }, + rowContent: { milestone in + VStack(alignment: .leading) { + Text(milestone.title) + .font(.subheadline) + if let due = milestone.dueOn { + Text("Due \(due, style: .date)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + }, + ) + } + } + } + } + + private func selectMilestone(_ id: Int) { + if selectedMilestoneID == id { + selectedMilestoneID = nil + } else { + selectedMilestoneID = id + } + } +} + +// MARK: - User Picker (Assignees / Reviewers) + +struct UserPickerSection: View { + let title: String + let availableUsers: [User] + @Binding var selectedLogins: Set + @State private var showingPicker = false + + private var selectedUsers: [User] { + availableUsers.filter { selectedLogins.contains($0.login) } + } + + var body: some View { + Section(title) { + if availableUsers.isEmpty { + Text("No \(title.lowercased()) available") + .foregroundStyle(.secondary) + .font(.subheadline) + } else { + Button { + showingPicker = true + } label: { + if selectedUsers.isEmpty { + Text("None") + .foregroundStyle(.secondary) + } else { + Text(selectedUsers.map { $0.fullName ?? $0.login }.joined(separator: ", ")) + .font(.subheadline) + } + } + .tint(.primary) + .sheet(isPresented: $showingPicker) { + PickerSheet( + title: title, + items: availableUsers, + isSelected: { selectedLogins.contains($0.login) }, + onToggle: { toggleUser($0.login) }, + rowContent: { user in + VStack(alignment: .leading) { + Text(user.fullName ?? user.login) + .font(.subheadline) + if user.fullName != nil { + Text("@\(user.login)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + }, + ) + } + } + } + } + + private func toggleUser(_ login: String) { + if selectedLogins.contains(login) { + selectedLogins.remove(login) + } else { + selectedLogins.insert(login) + } + } +} + +#Preview("Description") { + @Previewable @State var text = "Some **markdown** description" + NavigationStack { + List { + DescriptionEditorSection(text: $text) + } + } +} + +#Preview("Labels") { + @Previewable @State var selected: Set = [1] + NavigationStack { + List { + LabelPickerSection( + availableLabels: IssueLabel.previewList, + selectedLabelIDs: $selected, + ) + } + } +} + +#Preview("Milestones") { + @Previewable @State var selected: Int? + NavigationStack { + List { + MilestonePickerSection( + availableMilestones: [.preview], + selectedMilestoneID: $selected, + ) + } + } +} + +#Preview("Users") { + @Previewable @State var selected: Set = [] + NavigationStack { + List { + UserPickerSection( + title: "Assignees", + availableUsers: [.preview, .previewBot], + selectedLogins: $selected, + ) + } + } +} diff --git a/Forji/Forji/Views/NotificationsOverviewView.swift b/Forji/Forji/Views/NotificationsOverviewView.swift new file mode 100644 index 0000000..49aac15 --- /dev/null +++ b/Forji/Forji/Views/NotificationsOverviewView.swift @@ -0,0 +1,272 @@ +import ForgejoKit +import SwiftUI + +struct NotificationsOverviewView: View { + @State private var authService: AuthenticationService + @State private var pagination = PaginationState() + @State private var statusFilter: String = "unread" + + private let notificationService: NotificationService? + + init(authService: AuthenticationService) { + self.authService = authService + notificationService = authService.client.map { NotificationService(client: $0) } + } + + private var statusTypes: [String] { + switch statusFilter { + case "unread": ["unread"] + case "read": ["read"] + default: ["unread", "read"] + } + } + + private var emptyStateDescription: String { + if statusFilter == "all" { + return "There are no notifications" + } + return "There are no \(statusFilter) notifications" + } + + var body: some View { + @Bindable var pagination = pagination + VStack(spacing: 0) { + SegmentedPickerSection( + title: "Status", + selection: $statusFilter, + options: [("Unread", "unread"), ("Read", "read"), ("All", "all")], + accessibilityIdentifier: "notification-status-picker", + ) + + List { + if pagination.isLoading, pagination.items.isEmpty { + LoadingListSection() + } else if pagination.items.isEmpty { + ContentUnavailableView { + Label("No Notifications", systemImage: "bell.slash") + .foregroundStyle(statusFilter == "unread" ? .green : .secondary) + } description: { + Text(statusFilter == "unread" ? "You're all caught up!" : emptyStateDescription) + } + } else { + Section { + ForEach(pagination.items) { notification in + notificationRow(notification) + .swipeActions(edge: .leading) { + if notification.unread { + Button { + Task { await markAsRead(notification) } + } label: { + Label("Mark Read", systemImage: "envelope.open") + } + .tint(.blue) + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button { + Task { await dismissNotification(notification) } + } label: { + Label("Dismiss", systemImage: "xmark") + } + .tint(.gray) + } + } + + if pagination.hasMore { + ProgressView() + .frame(maxWidth: .infinity) + .listRowBackground(Color.clear) + .accessibilityIdentifier("load-more-indicator") + .task { + await loadMoreNotifications() + } + } + } + } + } + .listStyle(.insetGrouped) + } + .navigationTitle("Notifications") + .refreshable { + await reloadNotifications().value + } + .task { + reloadNotifications() + } + .onChange(of: statusFilter) { + reloadNotifications(clearItems: true) + } + .errorAlert(message: $pagination.errorMessage, isPresented: $pagination.showError) + } + + @ViewBuilder + private func notificationRow(_ notification: NotificationThread) -> some View { + let destination = navigationDestination(for: notification) + if let destination { + NavigationLink { destination } label: { + NotificationRow(notification: notification) + } + } else { + NotificationRow(notification: notification) + } + } + + @ViewBuilder + private func navigationDestination(for notification: NotificationThread) -> (some View)? { + switch notification.subject.type { + case "Issue": + if let number = subjectNumber(from: notification) { + IssueDetailView( + repository: notification.repository, + issueNumber: number, + authService: authService, + ) + } + case "Pull": + if let number = subjectNumber(from: notification) { + PullRequestDetailView( + repository: notification.repository, + prNumber: number, + authService: authService, + ) + } + default: + nil as EmptyView? + } + } + + private func subjectNumber(from notification: NotificationThread) -> Int? { + notificationSubjectNumber(from: notification.subject.url) + ?? notificationSubjectNumber(from: notification.subject.htmlUrl) + } + + @discardableResult + private func reloadNotifications(clearItems: Bool = false) -> Task { + guard let notificationService else { return Task {} } + return pagination.reload(clearItems: clearItems) { [self] page, limit in + try await notificationService.fetchNotifications( + statusTypes: statusTypes, + page: page, + limit: limit, + ) + } + } + + private func loadMoreNotifications() async { + guard let notificationService else { return } + await pagination.loadMore { page, limit in + try await notificationService.fetchNotifications( + statusTypes: statusTypes, + page: page, + limit: limit, + ) + } + } + + private func markAsRead(_ notification: NotificationThread) async { + await setNotificationRead(notification) + } + + private func dismissNotification(_ notification: NotificationThread) async { + await setNotificationRead(notification) + } + + private func setNotificationRead(_ notification: NotificationThread) async { + guard let notificationService else { return } + do { + try await notificationService.markAsRead(id: notification.id) + withAnimation { + if statusFilter == "unread" { + pagination.items.removeAll { $0.id == notification.id } + } else if let index = pagination.items.firstIndex(where: { $0.id == notification.id }) { + pagination.items[index] = NotificationThread( + id: notification.id, + unread: false, + pinned: notification.pinned, + updatedAt: notification.updatedAt, + url: notification.url, + subject: notification.subject, + repository: notification.repository, + ) + } + } + } catch { + pagination.errorMessage = error.localizedDescription + pagination.showError = true + } + } + + #if DEBUG + init(preview _: Void, authService: AuthenticationService, notifications: [NotificationThread]) { + self.authService = authService + notificationService = nil + _pagination = State(initialValue: PaginationState(items: notifications)) + } + #endif +} + +private struct NotificationRow: View { + let notification: NotificationThread + + private var typeIcon: String { + switch notification.subject.type { + case "Issue": "exclamationmark.circle" + case "Pull": "arrow.triangle.pull" + case "Commit": "circle.dotted.circle" + case "Repository": "folder" + default: "bell" + } + } + + private var stateColor: Color { + switch notification.subject.stateValue { + case .open: .green + case .closed, .merged: .purple + default: .secondary + } + } + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: typeIcon) + .foregroundStyle(stateColor) + .font(.body) + .padding(.top, 2) + + VStack(alignment: .leading, spacing: 4) { + Text(notification.subject.title) + .font(.body) + .lineLimit(2) + + Text(notification.repository.fullName) + .font(.caption) + .foregroundStyle(.secondary) + + Text(formatRelativeDate(notification.updatedAt)) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + if notification.unread { + Circle() + .fill(.blue) + .frame(width: 10, height: 10) + .glassEffect(.regular.tint(.blue)) + .padding(.top, 6) + } + } + .padding(.vertical, 2) + } +} + +#Preview { + NavigationStack { + NotificationsOverviewView( + preview: (), + authService: .previewDefault, + notifications: NotificationThread.previewList, + ) + } +} diff --git a/Forji/Forji/Views/PullRequestCreateView.swift b/Forji/Forji/Views/PullRequestCreateView.swift new file mode 100644 index 0000000..22d14e6 --- /dev/null +++ b/Forji/Forji/Views/PullRequestCreateView.swift @@ -0,0 +1,250 @@ +import ForgejoKit +import SwiftUI + +struct PullRequestCreateView: View { + let repository: Repository + @State private var authService: AuthenticationService + @State private var title = "" + @State private var bodyText = "" + @State private var headBranch = "" + @State private var baseBranch = "" + @State private var branches: [Branch] = [] + @State private var isLoadingBranches = true + @State private var isSubmitting = false + @State private var errorMessage: String? + @State private var showError = false + @State private var availableLabels: [IssueLabel] = [] + @State private var availableMilestones: [IssueMilestone] = [] + @State private var availableAssignees: [User] = [] + @State private var selectedLabelIDs: Set = [] + @State private var selectedMilestoneID: Int? + @State private var selectedAssigneeLogins: Set = [] + @State private var selectedReviewerLogins: Set = [] + @Environment(\.dismiss) private var dismiss + + private let prService: PullRequestService? + private let repositoryService: RepositoryService? + private let onCreated: () -> Void + private let embeddedInNavigation: Bool + + init( + repository: Repository, + authService: AuthenticationService, + embeddedInNavigation: Bool = false, + onCreated: @escaping () -> Void, + ) { + self.repository = repository + self.authService = authService + prService = authService.client.map { PullRequestService(client: $0) } + repositoryService = authService.client.map { RepositoryService(client: $0) } + self.onCreated = onCreated + self.embeddedInNavigation = embeddedInNavigation + _baseBranch = State(initialValue: repository.defaultBranch ?? "main") + } + + private var owner: String { + repository.owner + } + + private var repo: String { + repository.repoName + } + + private var canCreate: Bool { + !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !headBranch.isEmpty + && !baseBranch.isEmpty + && headBranch != baseBranch + && !isSubmitting + } + + var body: some View { + if embeddedInNavigation { + formContent + } else { + NavigationStack { + formContent + } + } + } + + private var formContent: some View { + Form { + Section("Branches") { + if isLoadingBranches { + HStack { + Spacer() + ProgressView() + Spacer() + } + } else { + Picker("Head", selection: $headBranch) { + Text("Select branch").tag("") + ForEach(branches) { branch in + Text(branch.name).tag(branch.name) + } + } + + Picker("Base", selection: $baseBranch) { + ForEach(branches) { branch in + Text(branch.name).tag(branch.name) + } + } + + if !headBranch.isEmpty, headBranch == baseBranch { + Label("Head and base branches must be different", systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.orange) + } + } + } + + Section { + TextField("Title", text: $title) + .accessibilityIdentifier("pr-create-title-field") + } + + DescriptionEditorSection(text: $bodyText) + + LabelPickerSection( + availableLabels: availableLabels, + selectedLabelIDs: $selectedLabelIDs, + ) + MilestonePickerSection( + availableMilestones: availableMilestones, + selectedMilestoneID: $selectedMilestoneID, + ) + UserPickerSection( + title: "Assignees", + availableUsers: availableAssignees, + selectedLogins: $selectedAssigneeLogins, + ) + UserPickerSection( + title: "Reviewers", + availableUsers: availableAssignees, + selectedLogins: $selectedReviewerLogins, + ) + } + .navigationTitle("New Pull Request") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Create") { + Task { await createPullRequest() } + } + .disabled(!canCreate) + .accessibilityIdentifier("pr-create-submit") + } + } + .errorAlert(message: $errorMessage, isPresented: $showError) + .task { + await loadInitialData() + } + } + + private func loadInitialData() async { + async let branchResult: () = loadBranches() + async let metadataResult: () = loadMetadata() + _ = await (branchResult, metadataResult) + } + + private func loadBranches() async { + guard let repositoryService else { return } + isLoadingBranches = true + do { + branches = try await repositoryService.fetchBranches(owner: owner, repo: repo) + } catch { + errorMessage = error.localizedDescription + showError = true + } + isLoadingBranches = false + } + + private func loadMetadata() async { + guard let repositoryService else { return } + let metadata = await loadRepositoryMetadata(service: repositoryService, owner: owner, repo: repo) + availableLabels = metadata.labels + availableMilestones = metadata.milestones + availableAssignees = metadata.assignees + } + + private func createPullRequest() async { + guard let prService else { return } + isSubmitting = true + do { + let trimmedBody = bodyText.trimmingCharacters(in: .whitespacesAndNewlines) + let createdPR = try await prService.createPullRequest( + owner: owner, + repo: repo, + title: title.trimmingCharacters(in: .whitespacesAndNewlines), + head: headBranch, + base: baseBranch, + body: trimmedBody.isEmpty ? nil : trimmedBody, + labels: selectedLabelIDs.isEmpty ? nil : Array(selectedLabelIDs), + milestone: selectedMilestoneID, + assignees: selectedAssigneeLogins.isEmpty ? nil : Array(selectedAssigneeLogins), + ) + if !selectedReviewerLogins.isEmpty { + do { + try await prService.requestReviewers( + owner: owner, + repo: repo, + index: createdPR.number, + reviewers: Array(selectedReviewerLogins), + ) + } catch { + errorMessage = "PR created, but requesting reviewers failed: \(error.localizedDescription)" + showError = true + onCreated() + isSubmitting = false + return + } + } + onCreated() + dismiss() + } catch { + errorMessage = error.localizedDescription + showError = true + } + isSubmitting = false + } + + #if DEBUG + init(preview _: Void, repository: Repository, authService: AuthenticationService, + branches: [Branch] = [], baseBranch: String = "main", + availableLabels: [IssueLabel] = [], availableMilestones: [IssueMilestone] = [], + availableAssignees: [User] = []) + { + self.repository = repository + self.authService = authService + prService = nil + repositoryService = nil + onCreated = {} + embeddedInNavigation = false + _branches = State(initialValue: branches) + _baseBranch = State(initialValue: baseBranch) + _isLoadingBranches = State(initialValue: false) + _availableLabels = State(initialValue: availableLabels) + _availableMilestones = State(initialValue: availableMilestones) + _availableAssignees = State(initialValue: availableAssignees) + } + #endif +} + +#Preview { + NavigationStack { + PullRequestCreateView( + preview: (), + repository: .preview, + authService: .previewDefault, + branches: Branch.previewList, + baseBranch: "main", + availableLabels: IssueLabel.previewList, + availableMilestones: [.preview], + availableAssignees: [.preview, .previewBot], + ) + } +} diff --git a/Forji/Forji/Views/PullRequestDetailView.swift b/Forji/Forji/Views/PullRequestDetailView.swift new file mode 100644 index 0000000..74837c4 --- /dev/null +++ b/Forji/Forji/Views/PullRequestDetailView.swift @@ -0,0 +1,605 @@ +import ForgejoKit + +// swiftlint:disable file_length +import SwiftUI + +// swiftlint:disable:next type_body_length +struct PullRequestDetailView: View { + let repository: Repository + let prNumber: Int + @State private var authService: AuthenticationService + @State private var pullRequest: PullRequest? + @State private var comments: [IssueComment] = [] + @State private var isLoading = true + @State private var errorMessage: String? + @State private var showError = false + @State private var showEditSheet = false + @State private var showMergeSheet = false + @State private var isTogglingState = false + @State private var showCommentSheet = false + @State private var diffText: String? + @State private var isDiffExpanded = false + @State private var isLoadingDiff = false + + // Reviews + @State private var reviews: [PullRequestReview] = [] + @State private var reviewComments: [Int: [ReviewComment]] = [:] + @State private var parsedDiff: ParsedDiff? + @State private var assignees: [User] = [] + @State private var showSubmitReviewSheet = false + @State private var inlineCommentContext: InlineCommentContext? + @State private var showActionsExpanded = false + + private let prService: PullRequestService? + private let repositoryService: RepositoryService? + + init(repository: Repository, prNumber: Int, authService: AuthenticationService) { + self.repository = repository + self.prNumber = prNumber + self.authService = authService + prService = authService.client.map { PullRequestService(client: $0) } + repositoryService = authService.client.map { RepositoryService(client: $0) } + } + + private var owner: String { + repository.owner + } + + private var repo: String { + repository.repoName + } + + private var isMerged: Bool { + pullRequest?.merged == true + } + + private var hasPushPermission: Bool { + repository.permissions?.push == true || repository.permissions?.admin == true + } + + private var isOwnPR: Bool { + guard let currentUser = authService.currentUser?.login, + let prAuthor = pullRequest?.user.login + else { + return false + } + return currentUser == prAuthor + } + + private var canEditOrClose: Bool { + hasPushPermission || isOwnPR + } + + private var canMerge: Bool { + hasPushPermission + } + + private var status: PRStatusStyle { + PRStatusStyle( + state: pullRequest?.stateValue ?? .closed, + merged: pullRequest?.merged, + draft: pullRequest?.draft, + ) + } + + var body: some View { + ZStack(alignment: .bottomTrailing) { + Group { + if isLoading, pullRequest == nil { + ProgressView() + } else if let activePR = pullRequest { + List { + headerSection(activePR) + + if let milestone = activePR.milestone { + MilestoneDisplaySection(milestone: milestone) + } + + if let prAssignees = activePR.assignees, !prAssignees.isEmpty { + AssigneesDisplaySection(assignees: prAssignees) + } + + requestedReviewersSection(activePR) + + if let body = activePR.body, !body.isEmpty { + Section("Description") { + MarkdownPreview(text: body) + .padding(.vertical, 4) + } + } + + reviewsSection + changesSection + conflictSection(activePR) + commentsSection + + Section {} footer: { + Spacer().frame(height: 60) + } + } + .listStyle(.insetGrouped) + } + } + + if let activePR = pullRequest { + actionMenu(activePR) + } + } + .navigationTitle("#\(prNumber)") + .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $showEditSheet) { + if let editingPR = pullRequest { + PullRequestEditView( + repository: repository, + pullRequest: editingPR, + authService: authService, + ) { _ in + Task { await loadData() } + } + } + } + .sheet(isPresented: $showMergeSheet) { + if let mergingPR = pullRequest { + PullRequestMergeView( + repository: repository, + pullRequest: mergingPR, + authService: authService, + ) { + Task { await loadData() } + } + } + } + .sheet(isPresented: $showSubmitReviewSheet) { + PullRequestReviewSheet( + repository: repository, + prNumber: prNumber, + authService: authService, + isOwnPR: isOwnPR, + ) { + Task { await loadData() } + } + } + .sheet(item: $inlineCommentContext) { context in + if let prService { + InlineCommentSheet( + context: context, + prService: prService, + owner: owner, + repo: repo, + prNumber: prNumber, + isOwnPR: isOwnPR, + ) { + Task { await loadReviews() } + } + } + } + .sheet(isPresented: $showCommentSheet) { + CommentSheet(users: assignees) { body in + guard let prService else { throw URLError(.userAuthenticationRequired) } + let comment = try await prService.createComment( + owner: owner, + repo: repo, + index: prNumber, + body: body, + ) + comments.append(comment) + } + } + .task { + await loadData() + } + .errorAlert(message: $errorMessage, isPresented: $showError) + } + + // MARK: - View Sections + + // swiftlint:disable:next function_body_length + private func headerSection(_ pullReq: PullRequest) -> some View { + Section { + VStack(alignment: .leading, spacing: 10) { + Text(pullReq.title) + .font(.title3) + .fontWeight(.bold) + .accessibilityIdentifier("pr-detail-title") + + HStack(spacing: 8) { + HStack(spacing: 4) { + Image(systemName: status.icon) + Text(status.text) + } + .font(.caption) + .fontWeight(.medium) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .foregroundStyle(.white) + .glassEffect(.regular.tint(status.color)) + + Text("#\(pullReq.number)") + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Text("\(pullReq.user.login) opened \(formatRelativeDate(pullReq.createdAt))") + .font(.subheadline) + .foregroundStyle(.secondary) + + HStack(spacing: 4) { + Label(pullReq.head.ref, systemImage: "arrow.branch") + .font(.caption) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .glassEffect(.regular) + + Image(systemName: "arrow.right") + .font(.caption2) + .foregroundStyle(.secondary) + + Text(pullReq.base.ref) + .font(.caption) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .glassEffect(.regular) + + Spacer() + + if !isMerged { + mergeableIndicator(pullReq) + } + } + + if !pullReq.labels.isEmpty { + FlowLayout(spacing: 4) { + ForEach(pullReq.labels) { label in + IssueLabelView(label: label) + } + } + } + } + .padding(.vertical, 4) + } + .listRowBackground(status.color.opacity(0.08)) + } + + @ViewBuilder + private func mergeableIndicator(_ pullReq: PullRequest) -> some View { + if pullReq.mergeable == true { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.caption) + } else if pullReq.mergeable == false { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.red) + .font(.caption) + } else { + Image(systemName: "questionmark.circle") + .foregroundStyle(.secondary) + .font(.caption) + } + } + + @ViewBuilder + private func requestedReviewersSection(_ pullReq: PullRequest) -> some View { + if let reviewers = pullReq.requestedReviewers, !reviewers.isEmpty { + Section("Requested Reviewers") { + ForEach(reviewers) { user in + HStack(spacing: 8) { + Image(systemName: "eye") + .foregroundStyle(.orange) + Text(user.fullName ?? user.login) + .font(.subheadline) + if user.fullName != nil { + Text("@\(user.login)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + } + } + + @ViewBuilder + private var reviewsSection: some View { + if !reviews.isEmpty { + Section("Reviews (\(reviews.count))") { + ForEach(reviews) { review in + ReviewSummaryView( + review: review, + comments: reviewComments[review.id] ?? [], + ) + } + } + } + } + + private var changesSection: some View { + Section { + DisclosureGroup("Changes", isExpanded: $isDiffExpanded) { + if isLoadingDiff { + HStack { + Spacer() + ProgressView() + Spacer() + } + } else if let parsed = parsedDiff { + DiffView( + diff: parsed, + reviewComments: allReviewComments, + ) { line, path in + inlineCommentContext = InlineCommentContext(line: line, path: path) + } + .listRowInsets(EdgeInsets()) + } + } + .onChange(of: isDiffExpanded) { + if isDiffExpanded, diffText == nil, !isLoadingDiff { + Task { await loadDiff() } + } + } + } + } + + @ViewBuilder + private func conflictSection(_ pullReq: PullRequest) -> some View { + if pullReq.stateValue == .open, pullReq.mergeable == false, !isMerged { + Section { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text("This pull request has conflicts that must be resolved via git or the web interface.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + } + + @ViewBuilder + private var commentsSection: some View { + if !comments.isEmpty { + Section("Comments (\(comments.count))") { + ForEach(comments) { comment in + CommentView( + comment: comment, + currentUsername: authService.currentUser?.login, + ) { newBody in + await editComment(commentId: comment.id, body: newBody) + } + } + } + } + } + + // swiftlint:disable:next function_body_length + private func actionMenu(_ pullReq: PullRequest) -> some View { + ExpandableActionMenu(isExpanded: $showActionsExpanded) { + Button { showCommentSheet = true } label: { + Label("Comment", systemImage: "text.bubble") + } + .buttonStyle(.glassProminent) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .accessibilityIdentifier("pr-comment-button") + + if canEditOrClose { + Button { showEditSheet = true } label: { + Label("Edit", systemImage: "pencil") + } + .buttonStyle(.glassProminent) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .accessibilityIdentifier("pr-edit-button") + } + + if pullReq.stateValue == .open { + Button { showSubmitReviewSheet = true } label: { + Label("Review", systemImage: "checkmark.message.fill") + } + .buttonStyle(.glassProminent) + .tint(.blue) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .accessibilityIdentifier("pr-submit-review") + } + + if canMerge, pullReq.stateValue == .open, pullReq.draft != true, pullReq.mergeable != false { + Button { showMergeSheet = true } label: { + Label("Merge", systemImage: "arrow.triangle.merge") + } + .buttonStyle(.glassProminent) + .tint(.purple) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .accessibilityIdentifier("pr-merge-button") + } + + if !isMerged, canEditOrClose { + Button { Task { await togglePRState() } } label: { + if isTogglingState { + ProgressView() + .controlSize(.small) + } else { + Label( + pullReq.stateValue == .open ? "Close" : "Reopen", + systemImage: pullReq.stateValue == .open ? "xmark.circle" : "arrow.uturn.left.circle", + ) + } + } + .buttonStyle(.glassProminent) + .tint(pullReq.stateValue == .open ? .red : .green) + .disabled(isTogglingState) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .accessibilityIdentifier("pr-toggle-state") + } + } + } + + // MARK: - Data Loading + + private var allReviewComments: [ReviewComment] { + reviewComments.values.flatMap(\.self) + } + + private func loadData() async { + guard let prService, let repositoryService else { return } + isLoading = true + do { + async let fetchedPR = prService.fetchPullRequest(owner: owner, repo: repo, index: prNumber) + async let fetchedComments = prService.fetchComments(owner: owner, repo: repo, index: prNumber) + async let fetchedReviews = prService.fetchReviews(owner: owner, repo: repo, index: prNumber) + async let fetchedAssignees = repositoryService.fetchAssignees(owner: owner, repo: repo) + + let loadedPR = try await fetchedPR + let loadedComments = try await fetchedComments + let loadedReviews = try await fetchedReviews + let loadedAssignees = await (try? fetchedAssignees) ?? [] + + try Task.checkCancellation() + pullRequest = loadedPR + comments = loadedComments + reviews = loadedReviews + assignees = loadedAssignees + + reviewComments = await fetchAllReviewComments(for: reviews) + + if !allReviewComments.isEmpty { + await loadDiff() + isDiffExpanded = true + } + } catch is CancellationError { + // Ignore cancellation + } catch { + errorMessage = error.localizedDescription + showError = true + } + isLoading = false + } + + private func loadReviews() async { + guard let prService else { return } + do { + reviews = try await prService.fetchReviews(owner: owner, repo: repo, index: prNumber) + reviewComments = await fetchAllReviewComments(for: reviews) + + if !allReviewComments.isEmpty { + if diffText == nil { + await loadDiff() + } + isDiffExpanded = true + } + } catch { + errorMessage = error.localizedDescription + showError = true + } + } + + private func fetchAllReviewComments(for reviews: [PullRequestReview]) async -> [Int: [ReviewComment]] { + guard let prService else { return [:] } + return await withTaskGroup(of: (Int, [ReviewComment])?.self) { group in + for review in reviews { + group.addTask { + guard let comments = try? await prService.fetchReviewComments( + owner: owner, repo: repo, index: prNumber, reviewId: review.id, + ), !comments.isEmpty else { + return nil + } + return (review.id, comments) + } + } + var commentsMap: [Int: [ReviewComment]] = [:] + for await result in group { + if let (reviewId, comments) = result { + commentsMap[reviewId] = comments + } + } + return commentsMap + } + } + + private func loadDiff() async { + guard let prService else { return } + isLoadingDiff = true + do { + let raw = try await prService.fetchDiff(owner: owner, repo: repo, index: prNumber) + try Task.checkCancellation() + diffText = raw + parsedDiff = DiffParser.parse(raw) + } catch is CancellationError { + // Ignore cancellation + } catch { + errorMessage = error.localizedDescription + showError = true + } + isLoadingDiff = false + } + + private func togglePRState() async { + guard let prService, let currentPR = pullRequest else { return } + isTogglingState = true + + do { + let newState = currentPR.stateValue == .open + ? PullRequestState.closed.rawValue : PullRequestState.open.rawValue + let updated = try await prService.editPullRequest( + owner: owner, + repo: repo, + index: prNumber, + title: nil, + body: nil, + state: newState, + ) + pullRequest = updated + } catch { + errorMessage = error.localizedDescription + showError = true + } + + isTogglingState = false + } + + private func editComment(commentId: Int, body: String) async -> Bool { + guard let prService else { return false } + do { + let updated = try await prService.editComment( + owner: owner, + repo: repo, + commentId: commentId, + body: body, + ) + if let index = comments.firstIndex(where: { $0.id == commentId }) { + comments[index] = updated + } + return true + } catch { + errorMessage = error.localizedDescription + showError = true + return false + } + } + + #if DEBUG + init(preview _: Void, repository: Repository, prNumber: Int, authService: AuthenticationService, + pullRequest: PullRequest, comments: [IssueComment] = [], reviews: [PullRequestReview] = []) + { + self.repository = repository + self.prNumber = prNumber + self.authService = authService + prService = nil + repositoryService = nil + _pullRequest = State(initialValue: pullRequest) + _comments = State(initialValue: comments) + _reviews = State(initialValue: reviews) + _isLoading = State(initialValue: false) + } + #endif +} + +#Preview { + NavigationStack { + PullRequestDetailView( + preview: (), + repository: .preview, + prNumber: 4, + authService: .previewDefault, + pullRequest: .preview, + comments: [.preview], + reviews: [.preview], + ) + } +} diff --git a/Forji/Forji/Views/PullRequestEditView.swift b/Forji/Forji/Views/PullRequestEditView.swift new file mode 100644 index 0000000..d5a7ebe --- /dev/null +++ b/Forji/Forji/Views/PullRequestEditView.swift @@ -0,0 +1,216 @@ +import ForgejoKit +import SwiftUI + +struct PullRequestEditView: View { + let repository: Repository + let pullRequest: PullRequest + @State private var authService: AuthenticationService + @State private var title: String + @State private var bodyText: String + @State private var isOpen: Bool + @State private var isSubmitting = false + @State private var errorMessage: String? + @State private var showError = false + @State private var availableLabels: [IssueLabel] = [] + @State private var availableMilestones: [IssueMilestone] = [] + @State private var availableAssignees: [User] = [] + @State private var selectedLabelIDs: Set + @State private var selectedMilestoneID: Int? + @State private var selectedAssigneeLogins: Set + @State private var selectedReviewerLogins: Set + @Environment(\.dismiss) private var dismiss + + private let prService: PullRequestService? + private let repositoryService: RepositoryService? + private let onSaved: (PullRequest) -> Void + + private var isMerged: Bool { + pullRequest.merged == true + } + + init( + repository: Repository, pullRequest: PullRequest, + authService: AuthenticationService, + onSaved: @escaping (PullRequest) -> Void, + ) { + self.repository = repository + self.pullRequest = pullRequest + self.authService = authService + prService = authService.client.map { PullRequestService(client: $0) } + repositoryService = authService.client.map { RepositoryService(client: $0) } + self.onSaved = onSaved + _title = State(initialValue: pullRequest.title) + _bodyText = State(initialValue: pullRequest.body ?? "") + _isOpen = State(initialValue: pullRequest.stateValue == .open) + _selectedLabelIDs = State(initialValue: Set(pullRequest.labels.map(\.id))) + _selectedMilestoneID = State(initialValue: pullRequest.milestone?.id) + _selectedAssigneeLogins = State(initialValue: Set((pullRequest.assignees ?? []).map(\.login))) + _selectedReviewerLogins = State(initialValue: Set((pullRequest.requestedReviewers ?? []).map(\.login))) + } + + private var owner: String { + repository.owner + } + + private var repo: String { + repository.repoName + } + + var body: some View { + NavigationStack { + Form { + Section { + TextField("Title", text: $title) + .accessibilityIdentifier("pr-edit-title-field") + } + + DescriptionEditorSection(text: $bodyText) + + LabelPickerSection( + availableLabels: availableLabels, + selectedLabelIDs: $selectedLabelIDs, + ) + MilestonePickerSection( + availableMilestones: availableMilestones, + selectedMilestoneID: $selectedMilestoneID, + ) + UserPickerSection( + title: "Assignees", + availableUsers: availableAssignees, + selectedLogins: $selectedAssigneeLogins, + ) + UserPickerSection( + title: "Reviewers", + availableUsers: availableAssignees, + selectedLogins: $selectedReviewerLogins, + ) + + if !isMerged { + Section { + Toggle("Open", isOn: $isOpen) + } + } + } + .navigationTitle("Edit Pull Request") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + Task { await savePullRequest() } + } + .disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSubmitting) + .accessibilityIdentifier("pr-edit-save") + } + } + .errorAlert(message: $errorMessage, isPresented: $showError) + .task { + await loadMetadata() + } + } + } + + private func loadMetadata() async { + guard let repositoryService else { return } + let metadata = await loadRepositoryMetadata(service: repositoryService, owner: owner, repo: repo) + availableLabels = metadata.labels + availableMilestones = metadata.milestones + availableAssignees = metadata.assignees + } + + // swiftlint:disable:next function_body_length + private func savePullRequest() async { + guard let prService, let client = authService.client else { return } + isSubmitting = true + do { + let issueService = IssueService(client: client) + try await issueService.replaceLabels( + owner: owner, + repo: repo, + index: pullRequest.number, + labelIDs: Array(selectedLabelIDs), + ) + _ = try await issueService.editIssue( + owner: owner, + repo: repo, + index: pullRequest.number, + title: nil, + body: nil, + state: nil, + milestone: selectedMilestoneID ?? 0, // 0 clears the milestone per Forgejo API + ) + let updatedPR = try await prService.editPullRequest( + owner: owner, + repo: repo, + index: pullRequest.number, + title: title.trimmingCharacters(in: .whitespacesAndNewlines), + body: bodyText.trimmingCharacters(in: .whitespacesAndNewlines), + state: isMerged ? nil : (isOpen ? PullRequestState.open.rawValue : PullRequestState.closed.rawValue), + assignees: Array(selectedAssigneeLogins), + ) + let initialReviewerLogins = Set((pullRequest.requestedReviewers ?? []).map(\.login)) + let addedReviewers = selectedReviewerLogins.subtracting(initialReviewerLogins) + let removedReviewers = initialReviewerLogins.subtracting(selectedReviewerLogins) + if !addedReviewers.isEmpty { + try await prService.requestReviewers( + owner: owner, + repo: repo, + index: pullRequest.number, + reviewers: Array(addedReviewers), + ) + } + if !removedReviewers.isEmpty { + try await prService.removeReviewers( + owner: owner, + repo: repo, + index: pullRequest.number, + reviewers: Array(removedReviewers), + ) + } + onSaved(updatedPR) + dismiss() + } catch { + errorMessage = error.localizedDescription + showError = true + } + isSubmitting = false + } + + #if DEBUG + init(preview _: Void, repository: Repository, pullRequest: PullRequest, authService: AuthenticationService, + availableLabels: [IssueLabel] = [], availableMilestones: [IssueMilestone] = [], + availableAssignees: [User] = []) + { + self.repository = repository + self.pullRequest = pullRequest + self.authService = authService + prService = nil + repositoryService = nil + onSaved = { _ in } + _title = State(initialValue: pullRequest.title) + _bodyText = State(initialValue: pullRequest.body ?? "") + _isOpen = State(initialValue: pullRequest.stateValue == .open) + _selectedLabelIDs = State(initialValue: Set(pullRequest.labels.map(\.id))) + _selectedMilestoneID = State(initialValue: pullRequest.milestone?.id) + _selectedAssigneeLogins = State(initialValue: Set((pullRequest.assignees ?? []).map(\.login))) + _selectedReviewerLogins = State(initialValue: Set((pullRequest.requestedReviewers ?? []).map(\.login))) + _availableLabels = State(initialValue: availableLabels) + _availableMilestones = State(initialValue: availableMilestones) + _availableAssignees = State(initialValue: availableAssignees) + } + #endif +} + +#Preview { + PullRequestEditView( + preview: (), + repository: .preview, + pullRequest: .preview, + authService: .previewDefault, + availableLabels: IssueLabel.previewList, + availableMilestones: [.preview], + availableAssignees: [.preview, .previewBot], + ) +} diff --git a/Forji/Forji/Views/PullRequestListView.swift b/Forji/Forji/Views/PullRequestListView.swift new file mode 100644 index 0000000..aa65e72 --- /dev/null +++ b/Forji/Forji/Views/PullRequestListView.swift @@ -0,0 +1,206 @@ +import ForgejoKit +import SwiftUI + +struct PullRequestListView: View { + let repository: Repository + @State private var authService: AuthenticationService + @State private var pagination = PaginationState() + @State private var stateFilter: PullRequestState = .open + @State private var showCreateSheet = false + + private let prService: PullRequestService? + + init(repository: Repository, authService: AuthenticationService) { + self.repository = repository + self.authService = authService + prService = authService.client.map { PullRequestService(client: $0) } + } + + private var owner: String { + repository.owner + } + + private var repo: String { + repository.repoName + } + + var body: some View { + @Bindable var pagination = pagination + ZStack(alignment: .bottomTrailing) { + VStack(spacing: 0) { + SegmentedPickerSection( + title: "State", + selection: $stateFilter, + options: [("Open", .open), ("Closed", .closed)], + accessibilityIdentifier: "pr-state-picker", + ) + + List { + if pagination.isLoading, pagination.items.isEmpty { + LoadingListSection() + } else if pagination.items.isEmpty { + ContentUnavailableView { + Label( + "No Pull Requests", + systemImage: stateFilter == .open + ? "checkmark.circle" : "arrow.triangle.pull", + ) + .foregroundStyle(stateFilter == .open ? .green : .secondary) + } description: { + Text( + stateFilter == .open + ? "All clear — no open pull requests." + : "No closed pull requests found.", + ) + } + } else { + Section { + ForEach(pagination.items) { pullRequest in + NavigationLink { + PullRequestDetailView( + repository: repository, + prNumber: pullRequest.number, + authService: authService, + ) + } label: { + PullRequestRow(pullRequest: pullRequest) + } + } + + if pagination.hasMore { + ProgressView() + .frame(maxWidth: .infinity) + .listRowBackground(Color.clear) + .accessibilityIdentifier("load-more-indicator") + .task { + await loadMorePullRequests() + } + } + } + } + } + .listStyle(.insetGrouped) + .refreshable { + await reloadPullRequests().value + } + } + + // Floating glass create button + FloatingCreateButton(action: { showCreateSheet = true }) + .accessibilityIdentifier("pr-create-button") + } + .sheet(isPresented: $showCreateSheet) { + PullRequestCreateView( + repository: repository, + authService: authService, + ) { + reloadPullRequests() + } + } + .task { + reloadPullRequests() + } + .onChange(of: stateFilter) { + reloadPullRequests(clearItems: true) + } + .errorAlert(message: $pagination.errorMessage, isPresented: $pagination.showError) + } + + @discardableResult + private func reloadPullRequests(clearItems: Bool = false) -> Task { + guard let prService else { return Task {} } + return pagination.reload(clearItems: clearItems) { [self] page, limit in + try await prService.fetchPullRequests( + owner: owner, + repo: repo, + state: stateFilter.rawValue, + page: page, + limit: limit, + ) + } + } + + private func loadMorePullRequests() async { + guard let prService else { return } + await pagination.loadMore { page, limit in + try await prService.fetchPullRequests( + owner: owner, + repo: repo, + state: stateFilter.rawValue, + page: page, + limit: limit, + ) + } + } + + #if DEBUG + init(preview _: Void, repository: Repository, authService: AuthenticationService, pullRequests: [PullRequest]) { + self.repository = repository + self.authService = authService + prService = nil + _pagination = State(initialValue: PaginationState(items: pullRequests)) + } + #endif +} + +#Preview { + NavigationStack { + PullRequestListView( + preview: (), + repository: .preview, + authService: .previewDefault, + pullRequests: [.preview], + ) + } +} + +struct PullRequestRow: View { + let pullRequest: PullRequest + + private var status: PRStatusStyle { + PRStatusStyle(state: pullRequest.stateValue, merged: pullRequest.merged, draft: pullRequest.draft) + } + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: status.icon) + .foregroundStyle(status.color) + .font(.body) + .padding(.top, 2) + + VStack(alignment: .leading, spacing: 4) { + Text(pullRequest.title) + .font(.body) + .lineLimit(2) + + HStack(spacing: 6) { + Text("#\(pullRequest.number)") + Text("\(pullRequest.head.ref) \u{2192} \(pullRequest.base.ref)") + .lineLimit(1) + if pullRequest.comments > 0 { + Label("\(pullRequest.comments)", systemImage: "bubble.right") + } + } + .font(.caption) + .foregroundStyle(.secondary) + + FlowLayout(spacing: 4) { + if pullRequest.draft == true { + Text("Draft") + .font(.caption2) + .fontWeight(.medium) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .glassEffect(.regular) + } + + ForEach(pullRequest.labels) { label in + IssueLabelView(label: label) + } + } + } + } + .padding(.vertical, 2) + .stateAccent(status.color) + } +} diff --git a/Forji/Forji/Views/PullRequestMergeView.swift b/Forji/Forji/Views/PullRequestMergeView.swift new file mode 100644 index 0000000..930f93f --- /dev/null +++ b/Forji/Forji/Views/PullRequestMergeView.swift @@ -0,0 +1,111 @@ +import ForgejoKit +import SwiftUI + +struct PullRequestMergeView: View { + let repository: Repository + let pullRequest: PullRequest + @State private var authService: AuthenticationService + @State private var mergeMethod = "merge" + @State private var commitMessage = "" + @State private var deleteBranch = false + @State private var isMerging = false + @State private var errorMessage: String? + @State private var showError = false + @Environment(\.dismiss) private var dismiss + + private let prService: PullRequestService? + private let onMerged: () -> Void + + init( + repository: Repository, pullRequest: PullRequest, + authService: AuthenticationService, + onMerged: @escaping () -> Void, + ) { + self.repository = repository + self.pullRequest = pullRequest + self.authService = authService + prService = authService.client.map { PullRequestService(client: $0) } + self.onMerged = onMerged + } + + private var owner: String { + repository.owner + } + + private var repo: String { + repository.repoName + } + + var body: some View { + NavigationStack { + Form { + Section("Merge Method") { + Picker("Method", selection: $mergeMethod) { + Text("Merge Commit").tag("merge") + Text("Rebase").tag("rebase") + Text("Squash").tag("squash") + } + .pickerStyle(.segmented) + .accessibilityIdentifier("merge-method-picker") + } + + if mergeMethod != "rebase" { + Section("Commit Message") { + TextEditor(text: $commitMessage) + .frame(minHeight: 80) + .scrollContentBackground(.hidden) + } + } + + Section { + Toggle("Delete branch after merge", isOn: $deleteBranch) + .accessibilityIdentifier("merge-delete-branch") + } + } + .navigationTitle("Merge Pull Request") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Merge") { + Task { await merge() } + } + .disabled(isMerging) + .accessibilityIdentifier("merge-confirm") + } + } + .errorAlert(message: $errorMessage, isPresented: $showError) + } + } + + private func merge() async { + guard let prService else { return } + isMerging = true + do { + try await prService.mergePullRequest( + owner: owner, + repo: repo, + index: pullRequest.number, + method: mergeMethod, + message: mergeMethod != "rebase" ? commitMessage : nil, + deleteBranch: deleteBranch, + ) + onMerged() + dismiss() + } catch { + errorMessage = error.localizedDescription + showError = true + } + isMerging = false + } +} + +#Preview { + PullRequestMergeView( + repository: .preview, + pullRequest: .preview, + authService: .previewDefault, + ) {} +} diff --git a/Forji/Forji/Views/PullRequestReviewSheet.swift b/Forji/Forji/Views/PullRequestReviewSheet.swift new file mode 100644 index 0000000..9b054f7 --- /dev/null +++ b/Forji/Forji/Views/PullRequestReviewSheet.swift @@ -0,0 +1,246 @@ +import ForgejoKit +import SwiftUI + +struct PendingInlineComment: Identifiable { + let id = UUID() + let body: String + let path: String + let line: DiffLine + + var reviewComment: CreateReviewComment? { + guard let position = line.diffPosition else { return nil } + return CreateReviewComment( + body: body, + path: path, + oldPosition: line.type == .deletion ? position : nil, + newPosition: line.type == .deletion ? nil : position, + ) + } +} + +struct PullRequestReviewSheet: View { + let repository: Repository + let prNumber: Int + let isOwnPR: Bool + @State private var authService: AuthenticationService + + @State private var reviewEvent: ReviewEvent = .comment + @State private var reviewBody = "" + @State private var isSubmitting = false + @State private var errorMessage: String? + @State private var showError = false + @State private var parsedDiff: ParsedDiff? + @State private var isLoadingDiff = false + @State private var inlineCommentContext: InlineCommentContext? + @State private var pendingComments: [PendingInlineComment] = [] + @Environment(\.dismiss) private var dismiss + + private let prService: PullRequestService? + private let onSubmitted: () -> Void + + init( + repository: Repository, + prNumber: Int, + authService: AuthenticationService, + isOwnPR: Bool = false, + onSubmitted: @escaping () -> Void, + ) { + self.repository = repository + self.prNumber = prNumber + self.isOwnPR = isOwnPR + self.authService = authService + prService = authService.client.map { PullRequestService(client: $0) } + self.onSubmitted = onSubmitted + } + + private var owner: String { + repository.owner + } + + private var repo: String { + repository.repoName + } + + var body: some View { + NavigationStack { + List { + Section("Review Type") { + Picker("Type", selection: $reviewEvent) { + Label(ReviewEvent.comment.title, systemImage: ReviewEvent.comment.systemImage) + .tag(ReviewEvent.comment) + if !isOwnPR { + Label(ReviewEvent.approved.title, systemImage: ReviewEvent.approved.systemImage) + .tag(ReviewEvent.approved) + Label(ReviewEvent.requestChanges.title, systemImage: ReviewEvent.requestChanges.systemImage) + .tag(ReviewEvent.requestChanges) + } + } + .pickerStyle(.inline) + .labelsHidden() + .accessibilityIdentifier("review-type-picker") + } + + Section("Changes") { + if isLoadingDiff { + HStack { + Spacer() + ProgressView() + Spacer() + } + } else if let diff = parsedDiff { + DiffView(diff: diff) { line, path in + inlineCommentContext = InlineCommentContext(line: line, path: path) + } + .listRowInsets(EdgeInsets()) + } + } + + if !pendingComments.isEmpty { + Section("Inline Comments (\(pendingComments.count))") { + ForEach(pendingComments) { comment in + VStack(alignment: .leading, spacing: 4) { + Text(comment.path) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + Text(comment.body) + .font(.subheadline) + } + .padding(.vertical, 2) + } + .onDelete { offsets in + pendingComments.remove(atOffsets: offsets) + } + } + } + + DescriptionEditorSection(text: $reviewBody, title: "Review Body") + } + .listStyle(.insetGrouped) + .navigationTitle("Submit Review") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Submit") { + Task { await submitReview() } + } + .disabled(isSubmitting) + .accessibilityIdentifier("review-submit") + } + } + .errorAlert(message: $errorMessage, isPresented: $showError) + .sheet(item: $inlineCommentContext) { context in + AddInlineCommentSheet(context: context) { comment in + pendingComments.append(comment) + } + } + .task { + await loadDiff() + } + } + } + + private func loadDiff() async { + guard let prService else { return } + isLoadingDiff = true + do { + let raw = try await prService.fetchDiff(owner: owner, repo: repo, index: prNumber) + parsedDiff = DiffParser.parse(raw) + } catch { + errorMessage = error.localizedDescription + showError = true + } + isLoadingDiff = false + } + + private func submitReview() async { + guard let prService else { return } + isSubmitting = true + do { + _ = try await prService.createReview( + owner: owner, + repo: repo, + index: prNumber, + body: reviewBody, + event: reviewEvent.rawValue, + comments: pendingComments.compactMap(\.reviewComment), + ) + onSubmitted() + dismiss() + } catch { + errorMessage = error.localizedDescription + showError = true + } + isSubmitting = false + } + + #if DEBUG + init(preview _: Void, repository: Repository, prNumber: Int, authService: AuthenticationService, + isOwnPR: Bool = false, parsedDiff: ParsedDiff? = nil) + { + self.repository = repository + self.prNumber = prNumber + self.isOwnPR = isOwnPR + self.authService = authService + prService = nil + onSubmitted = {} + _parsedDiff = State(initialValue: parsedDiff) + _isLoadingDiff = State(initialValue: false) + } + #endif +} + +private struct AddInlineCommentSheet: View { + let context: InlineCommentContext + let onAdd: (PendingInlineComment) -> Void + + @State private var commentBody = "" + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + DiffLineHeader(path: context.path, line: context.line) + + TextEditor(text: $commentBody) + .frame(maxHeight: .infinity) + .scrollContentBackground(.hidden) + .padding(8) + .background(Color(.tertiarySystemFill)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .padding(.horizontal) + .padding(.bottom) + } + .navigationTitle("Add Comment") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Add") { + onAdd(PendingInlineComment( + body: commentBody, + path: context.path, + line: context.line, + )) + dismiss() + } + .disabled(commentBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + } +} + +#Preview { + PullRequestReviewSheet( + preview: (), + repository: .preview, + prNumber: 4, + authService: .previewDefault, + parsedDiff: .preview, + ) +} diff --git a/Forji/Forji/Views/PullRequestsOverviewView.swift b/Forji/Forji/Views/PullRequestsOverviewView.swift new file mode 100644 index 0000000..31ed068 --- /dev/null +++ b/Forji/Forji/Views/PullRequestsOverviewView.swift @@ -0,0 +1,107 @@ +import ForgejoKit +import SwiftUI + +struct PullRequestsOverviewView: View { + let authService: AuthenticationService + + init(authService: AuthenticationService) { + self.authService = authService + } + + var body: some View { + SearchableOverviewView( + authService: authService, + issueType: "pulls", + navigationTitle: "Pull Requests", + searchPrompt: "Search pull requests", + emptyTitle: "No Pull Requests", + emptyOpenIcon: "arrow.triangle.pull", + emptyClosedIcon: "arrow.triangle.pull", + itemNoun: "pull requests", + createButtonId: "pr-create-button", + showReviewRequested: true, + row: { issue in + PullRequestOverviewRow(issue: issue) + }, + detail: { repository, number, auth in + PullRequestDetailView( + repository: repository, + prNumber: number, + authService: auth, + ) + }, + createView: { repo, auth, embedded, onCreated in + PullRequestCreateView( + repository: repo, + authService: auth, + embeddedInNavigation: embedded, + onCreated: onCreated, + ) + }, + ) + } + + #if DEBUG + init(preview _: Void, authService: AuthenticationService, pullRequests _: [Issue]) { + self.authService = authService + } + #endif +} + +struct PullRequestOverviewRow: View { + let issue: Issue + + private var status: PRStatusStyle { + PRStatusStyle(state: issue.pullRequestStateValue, merged: issue.pullRequest?.merged, draft: nil) + } + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: status.icon) + .foregroundStyle(status.color) + .font(.body) + .padding(.top, 2) + + VStack(alignment: .leading, spacing: 4) { + Text(issue.title) + .font(.body) + .lineLimit(2) + + if let repository = issue.repository { + Text(repository.fullName) + .font(.caption) + .foregroundStyle(.secondary) + } + + HStack(spacing: 6) { + Text("#\(issue.number)") + if issue.comments > 0 { + Label("\(issue.comments)", systemImage: "bubble.right") + } + } + .font(.caption) + .foregroundStyle(.secondary) + + if !issue.labels.isEmpty { + FlowLayout(spacing: 4) { + ForEach(issue.labels) { label in + IssueLabelView(label: label) + } + } + } + } + } + .padding(.vertical, 2) + .stateAccent(status.color) + } +} + +#Preview { + NavigationStack { + PullRequestsOverviewView( + preview: (), + authService: .previewDefault, + pullRequests: [.previewPullRequest], + ) + } +} diff --git a/Forji/Forji/Views/RepositoryDetailView.swift b/Forji/Forji/Views/RepositoryDetailView.swift new file mode 100644 index 0000000..ada353c --- /dev/null +++ b/Forji/Forji/Views/RepositoryDetailView.swift @@ -0,0 +1,566 @@ +import ForgejoKit + +// swiftlint:disable file_length +import SwiftUI + +struct FileNavigation: Hashable { + let path: String + let branch: String +} + +struct RepositoryDetailView: View { + let repository: Repository + @State private var authService: AuthenticationService + @State private var selectedTab: DetailTab = .code + @State private var fileNavigation: FileNavigation? + @State private var selectedBranch: String + @State private var showCommitHistory = false + @State private var branches: [Branch] = [] + @State private var showBranchPicker = false + + private let repositoryService: RepositoryService? + + init(repository: Repository, authService: AuthenticationService) { + self.repository = repository + self.authService = authService + repositoryService = authService.client.map { RepositoryService(client: $0) } + _selectedBranch = State(initialValue: repository.defaultBranch ?? "main") + } + + enum DetailTab: String, CaseIterable { + case code = "Code" + case issues = "Issues" + case pulls = "Pull Requests" + + var icon: String { + switch self { + case .code: "doc.text" + case .issues: "exclamationmark.circle" + case .pulls: "arrow.triangle.pull" + } + } + } + + var body: some View { + VStack(spacing: 0) { + // Repository header + VStack(alignment: .leading, spacing: 12) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(repository.name) + .font(.title2) + .fontWeight(.bold) + + if let description = repository.description { + Text(description) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + Spacer() + + if repository.private ?? false { + Label("Private", systemImage: "lock.fill") + .font(.caption2) + .fontWeight(.medium) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .glassEffect(.regular) + } + } + + // Stats + HStack(spacing: 20) { + Label("\(repository.starsCount ?? 0)", systemImage: "star.fill") + .font(.subheadline) + + Label("\(repository.forksCount ?? 0)", systemImage: "tuningfork") + .font(.subheadline) + + if let language = repository.language { + HStack(spacing: 4) { + Circle() + .fill(colorForLanguage(language)) + .frame(width: 12, height: 12) + Text(language) + .font(.subheadline) + } + } + } + .foregroundStyle(.secondary) + } + .padding() + + Divider() + + // Tab selector + Picker("View", selection: $selectedTab) { + ForEach(DetailTab.allCases, id: \.self) { tab in + Label(tab.rawValue, systemImage: tab.icon) + .tag(tab) + } + } + .pickerStyle(.segmented) + .padding() + .accessibilityIdentifier("repo-detail-tab-picker") + + // Tab content + TabView(selection: $selectedTab) { + RepositoryCodeView( + repository: repository, + authService: authService, + selectedBranch: $selectedBranch, + onFileNavigation: { path, branch in + fileNavigation = FileNavigation(path: path, branch: branch) + }, + ) + .tag(DetailTab.code) + + IssueListView( + repository: repository, + authService: authService, + ) + .tag(DetailTab.issues) + + PullRequestListView( + repository: repository, + authService: authService, + ) + .tag(DetailTab.pulls) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + } + .navigationTitle(repository.name) + .navigationBarTitleDisplayMode(.inline) + .navigationDestination(item: $fileNavigation) { nav in + let fileName = nav.path.components(separatedBy: "/").last ?? nav.path + FileViewerView( + repository: repository, + filePath: nav.path, + fileName: fileName, + authService: authService, + ref: nav.branch, + ) + } + .navigationDestination(isPresented: $showCommitHistory) { + CommitHistoryView( + repository: repository, + branch: $selectedBranch, + authService: authService, + ) + .navigationTitle("Commits") + .navigationBarTitleDisplayMode(.inline) + } + .task { + guard let repositoryService else { return } + do { + branches = try await repositoryService.fetchBranches( + owner: repository.owner, + repo: repository.repoName, + ) + } catch { + // Non-critical — branch selector stays disabled + } + } + .toolbar { + if selectedTab == .code { + ToolbarItem(placement: .topBarTrailing) { + Button { + showBranchPicker = true + } label: { + HStack(spacing: 4) { + Image(systemName: "arrow.triangle.branch") + Text(selectedBranch) + .lineLimit(1) + .frame(maxWidth: 140) + } + .font(.subheadline.weight(.medium)) + } + .disabled(branches.isEmpty) + .accessibilityIdentifier("branch-selector") + } + ToolbarItem(placement: .topBarTrailing) { + Button { + showCommitHistory = true + } label: { + Image(systemName: "clock.arrow.circlepath") + } + .accessibilityIdentifier("commits-button") + } + } + } + .sheet(isPresented: $showBranchPicker) { + NavigationStack { + List(branches) { branch in + Button { + selectedBranch = branch.name + showBranchPicker = false + } label: { + HStack { + Text(branch.name) + Spacer() + if branch.name == selectedBranch { + Image(systemName: "checkmark") + .foregroundStyle(.blue) + } + } + } + .accessibilityIdentifier("branch-option-\(branch.name)") + } + .navigationTitle("Switch Branch") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + showBranchPicker = false + } + } + } + } + .presentationDetents([.medium]) + } + } +} + +// swiftlint:disable:next type_body_length +struct RepositoryCodeView: View { + let repository: Repository + @State private var authService: AuthenticationService + @State private var contents: [RepositoryContent] = [] + @State private var currentPath: String = "" + @State private var pathComponents: [String] = [] + @State private var isLoading = false + @State private var errorMessage: String? + @State private var showError = false + @State private var readmeContent: String? + @State private var scrollToFiles = false + @State private var scrollToReadme = false + @State private var isScrolledToFiles = false + @Binding var selectedBranch: String + @State private var branchContentTask: Task? + @State private var contentLoadTask: Task? + + private let repositoryService: RepositoryService? + var onFileNavigation: ((String, String) -> Void)? + + init( + repository: Repository, + authService: AuthenticationService, + selectedBranch: Binding, + onFileNavigation: ((String, String) -> Void)? = nil, + ) { + self.repository = repository + self.authService = authService + repositoryService = authService.client.map { RepositoryService(client: $0) } + self.onFileNavigation = onFileNavigation + _selectedBranch = selectedBranch + } + + private var readmeBaseURL: URL? { + guard let serverURL = authService.client?.serverURL else { return nil } + let encodedBranch = selectedBranch.addingPercentEncoding( + withAllowedCharacters: .urlPathAllowed, + ) ?? selectedBranch + let pathSuffix: String = if currentPath.isEmpty { + "" + } else { + currentPath.components(separatedBy: "/") + .map { $0.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? $0 } + .joined(separator: "/") + "/" + } + return URL(string: "\(serverURL)/\(repository.fullName)/src/branch/\(encodedBranch)/\(pathSuffix)") + } + + var body: some View { + ZStack(alignment: .bottomTrailing) { + VStack(spacing: 0) { + // Breadcrumb navigation + if !pathComponents.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 4) { + Button { + navigateToRoot() + } label: { + Text(repository.name) + .font(.subheadline) + } + + ForEach(Array(pathComponents.enumerated()), id: \.offset) { index, component in + Text("/") + .foregroundStyle(.secondary) + .font(.subheadline) + + Button { + navigateToPath(upTo: index) + } label: { + Text(component) + .font(.subheadline) + } + } + } + .padding(.horizontal) + .padding(.vertical, 8) + } + } + + // Content list + ScrollViewReader { proxy in + List { + if let readme = readmeContent { + Section("README") { + MarkdownPreview( + text: readme, + baseURL: readmeBaseURL, + onNavigateToFile: { path in + onFileNavigation?(path, selectedBranch) + }, + ) + .padding() + } + .id("readme") + } + + // Files and folders + Section("Files") { + if isLoading { + HStack { + Spacer() + ProgressView() + Spacer() + } + } else { + ForEach(contents) { content in + if content.type == .dir { + Button { + handleContentTap(content) + } label: { + ContentRow(content: content) + } + .buttonStyle(.plain) + .accessibilityIdentifier("directory-\(content.name)") + } else { + NavigationLink { + FileViewerView( + repository: repository, + filePath: currentPath.isEmpty + ? content.name : "\(currentPath)/\(content.name)", + fileName: content.name, + authService: authService, + ref: selectedBranch, + ) + } label: { + ContentRow(content: content) + } + } + } + } + } + .id("files") + } + .onChange(of: scrollToFiles) { + if scrollToFiles { + withAnimation { + proxy.scrollTo("files", anchor: .top) + } + scrollToFiles = false + isScrolledToFiles = true + } + } + .onChange(of: scrollToReadme) { + if scrollToReadme { + withAnimation { + proxy.scrollTo("readme", anchor: .top) + } + scrollToReadme = false + isScrolledToFiles = false + } + } + } + } + .task { + await loadContents() + } + .onChange(of: selectedBranch) { + pathComponents = [] + currentPath = "" + readmeContent = nil + branchContentTask?.cancel() + branchContentTask = Task { + await loadContents() + } + } + .errorAlert(message: $errorMessage, isPresented: $showError) + + // Floating button to jump between README and Files + if readmeContent != nil { + Button { + if isScrolledToFiles { + scrollToReadme = true + } else { + scrollToFiles = true + } + } label: { + Label( + isScrolledToFiles ? "README" : "Files", + systemImage: isScrolledToFiles ? "arrow.up.doc.fill" : "arrow.down.doc.fill", + ) + .font(.headline) + .foregroundStyle(.white) + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + .glassEffect(.regular.tint(.blue).interactive()) + .padding(.trailing, 20) + .padding(.bottom, 20) + .animation(.easeInOut, value: isScrolledToFiles) + .accessibilityIdentifier("code-scroll-toggle") + } + } + } + + private func loadContents() async { + guard let repositoryService else { return } + isLoading = true + errorMessage = nil + + do { + contents = try await repositoryService.fetchContents( + owner: repository.owner, + repo: repository.repoName, + path: currentPath, + ref: selectedBranch, + ) + + // Sort: directories first, then files + contents.sort { lhs, rhs in + if lhs.type == .dir, rhs.type != .dir { + true + } else if lhs.type != .dir, rhs.type == .dir { + false + } else { + lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + } + + readmeContent = nil + await loadReadme(owner: repository.owner, repo: repository.repoName) + + } catch is CancellationError { + // Ignore cancellation + return + } catch { + errorMessage = error.localizedDescription + showError = true + } + + isLoading = false + } + + private func loadReadme(owner: String, repo: String) async { + guard let repositoryService else { return } + let readmeFile = contents.first { + $0.type == .file && $0.name.lowercased().hasPrefix("readme") + } + guard let readmeFile else { return } + + do { + let readmePath = currentPath.isEmpty ? readmeFile.name : "\(currentPath)/\(readmeFile.name)" + let fileContent = try await repositoryService.fetchFileContent( + owner: owner, + repo: repo, + path: readmePath, + ref: selectedBranch, + ) + readmeContent = fileContent.decodedContent + } catch { + // README exists in listing but couldn't be fetched — ignore + } + } + + private func handleContentTap(_ content: RepositoryContent) { + if content.type == .dir { + pathComponents.append(content.name) + currentPath = pathComponents.joined(separator: "/") + contentLoadTask?.cancel() + contentLoadTask = Task { + await loadContents() + } + } + } + + private func navigateToRoot() { + pathComponents = [] + currentPath = "" + contentLoadTask?.cancel() + contentLoadTask = Task { + await loadContents() + } + } + + private func navigateToPath(upTo index: Int) { + pathComponents = Array(pathComponents.prefix(index + 1)) + currentPath = pathComponents.joined(separator: "/") + contentLoadTask?.cancel() + contentLoadTask = Task { + await loadContents() + } + } +} + +struct ContentRow: View { + let content: RepositoryContent + + var body: some View { + HStack(spacing: 12) { + Image(systemName: iconForType(content.type)) + .foregroundStyle(content.type == .dir ? .blue : .secondary) + .frame(width: 24) + + VStack(alignment: .leading, spacing: 2) { + Text(content.name) + .foregroundStyle(.primary) + + if content.type == .file { + Text(formatFileSize(content.size)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + if content.type == .dir { + Image(systemName: "chevron.right") + .foregroundStyle(.tertiary) + .font(.caption) + } + } + .padding(.vertical, 4) + } + + private func iconForType(_ type: RepositoryContent.ContentType) -> String { + switch type { + case .dir: + "folder.fill" + case .file: + "doc.text" + case .symlink: + "link" + case .submodule: + "folder.badge.gearshape" + } + } + + private func formatFileSize(_ bytes: Int) -> String { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + return formatter.string(fromByteCount: Int64(bytes)) + } +} + +#Preview { + NavigationStack { + RepositoryDetailView(repository: .preview, authService: .previewDefault) + } +} diff --git a/Forji/Forji/Views/RepositoryListView.swift b/Forji/Forji/Views/RepositoryListView.swift new file mode 100644 index 0000000..f19488b --- /dev/null +++ b/Forji/Forji/Views/RepositoryListView.swift @@ -0,0 +1,334 @@ +import ForgejoKit +import SwiftUI + +struct RepositoryListView: View { + @State private var authService: AuthenticationService + @State private var repositories: [Repository] = [] + @State private var starredRepoIds: Set = [] + @State private var isLoading = false + @State private var errorMessage: String? + @State private var showError = false + @State private var selectedFilter: RepositoryFilter = .all + @State private var searchText = "" + @State private var searchTask: Task? + @State private var hasMore = true + @State private var currentPage = 1 + @State private var starringInFlight: Set = [] + + private let repositoryService: RepositoryService? + private let pageSize = 20 + + init(authService: AuthenticationService) { + self.authService = authService + repositoryService = authService.client.map { RepositoryService(client: $0) } + } + + enum RepositoryFilter: String, CaseIterable { + case all = "All" + case starred = "Starred" + + var icon: String { + switch self { + case .all: "folder.fill" + case .starred: "star.fill" + } + } + } + + var body: some View { + VStack(spacing: 0) { + SegmentedPickerSection( + title: "Filter", + selection: $selectedFilter, + options: RepositoryFilter.allCases.map { ($0.rawValue, $0) }, + accessibilityIdentifier: "repo-filter-picker", + ) + + List { + if isLoading, repositories.isEmpty { + Section { + HStack { + Spacer() + ProgressView() + Spacer() + } + .listRowBackground(Color.clear) + } + } else if repositories.isEmpty { + ContentUnavailableView { + Label("No Repositories", systemImage: "folder.badge.questionmark") + .foregroundStyle(.secondary) + } description: { + Text( + searchText.isEmpty + ? "No repositories yet." + : "No repositories matching your search.", + ) + } + } else { + Section { + ForEach(repositories) { repo in + NavigationLink { + RepositoryDetailView(repository: repo, authService: authService) + } label: { + RepositoryRow( + repository: repo, + isStarred: starredRepoIds.contains(repo.id), + isStarring: starringInFlight.contains(repo.id), + ) { + await toggleStar(for: repo) + } + } + } + + if hasMore { + ProgressView() + .frame(maxWidth: .infinity) + .listRowBackground(Color.clear) + .accessibilityIdentifier("load-more-indicator") + .task { + await loadMoreRepositories() + } + } + } + } + } + .listStyle(.insetGrouped) + .accessibilityIdentifier("repo-list") + .refreshable { + currentPage = 1 + hasMore = true + await loadRepositories() + } + } + .navigationTitle("Repositories") + .searchable(text: $searchText, prompt: "Search repositories") + .task { + await loadRepositories() + } + .onChange(of: selectedFilter) { _, _ in + Task { + currentPage = 1 + hasMore = true + repositories = [] + await loadRepositories() + } + } + .debouncedSearch(text: $searchText, task: $searchTask) { + currentPage = 1 + hasMore = true + await loadRepositories() + } + .errorAlert(message: $errorMessage, isPresented: $showError) + } + + private func loadRepositories() async { + guard let repositoryService else { return } + isLoading = true + errorMessage = nil + + do { + async let allStarredIds = repositoryService.fetchAllStarredRepoIds() + + if searchText.isEmpty { + switch selectedFilter { + case .all: + let fetched = try await repositoryService.fetchUserRepositories(page: 1, limit: pageSize) + repositories = fetched + hasMore = fetched.count >= pageSize + case .starred: + let fetched = try await repositoryService.fetchStarredRepositories(page: 1, limit: pageSize) + repositories = fetched + hasMore = fetched.count >= pageSize + } + } else { + let results = try await repositoryService.searchRepositories( + query: searchText, page: 1, limit: pageSize, + ) + switch selectedFilter { + case .all: + repositories = results + hasMore = results.count >= pageSize + case .starred: + let ids = try await allStarredIds + let filtered = results.filter { ids.contains($0.id) } + repositories = filtered + hasMore = results.count >= pageSize + } + } + + starredRepoIds = try await allStarredIds + currentPage = 2 + } catch is CancellationError { + // Ignore cancellation + } catch { + errorMessage = error.localizedDescription + showError = true + } + + isLoading = false + } + + private func loadMoreRepositories() async { + guard let repositoryService, hasMore, !isLoading else { return } + isLoading = true + + do { + if searchText.isEmpty { + switch selectedFilter { + case .all: + let fetched = try await repositoryService.fetchUserRepositories(page: currentPage, limit: pageSize) + repositories.append(contentsOf: fetched) + hasMore = fetched.count >= pageSize + case .starred: + let fetched = try await repositoryService.fetchStarredRepositories( + page: currentPage, limit: pageSize, + ) + repositories.append(contentsOf: fetched) + hasMore = fetched.count >= pageSize + } + } else { + let results = try await repositoryService.searchRepositories( + query: searchText, page: currentPage, limit: pageSize, + ) + switch selectedFilter { + case .all: + repositories.append(contentsOf: results) + hasMore = results.count >= pageSize + case .starred: + let filtered = results.filter { starredRepoIds.contains($0.id) } + repositories.append(contentsOf: filtered) + hasMore = results.count >= pageSize + } + } + currentPage += 1 + } catch is CancellationError { + // Ignore cancellation + } catch { + errorMessage = error.localizedDescription + showError = true + } + + isLoading = false + } + + private func toggleStar(for repo: Repository) async { + guard let repositoryService else { return } + guard !starringInFlight.contains(repo.id) else { return } + let parts = repo.fullName.split(separator: "/") + guard parts.count == 2 else { return } + let owner = String(parts[0]) + let repoName = String(parts[1]) + + starringInFlight.insert(repo.id) + let isCurrentlyStarred = starredRepoIds.contains(repo.id) + + // Optimistic update + if isCurrentlyStarred { + starredRepoIds.remove(repo.id) + } else { + starredRepoIds.insert(repo.id) + } + + do { + if isCurrentlyStarred { + try await repositoryService.unstarRepository(owner: owner, repo: repoName) + } else { + try await repositoryService.starRepository(owner: owner, repo: repoName) + } + if selectedFilter == .starred { + currentPage = 1 + hasMore = true + await loadRepositories() + } + } catch { + // Revert optimistic update + if isCurrentlyStarred { + starredRepoIds.insert(repo.id) + } else { + starredRepoIds.remove(repo.id) + } + errorMessage = error.localizedDescription + showError = true + } + starringInFlight.remove(repo.id) + } +} + +struct RepositoryRow: View { + let repository: Repository + let isStarred: Bool + var isStarring: Bool = false + let onToggleStar: () async -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(repository.name) + .font(.headline) + + if let description = repository.description { + Text(description) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + + Spacer() + + if repository.private ?? false { + Label("Private", systemImage: "lock.fill") + .font(.caption2) + .fontWeight(.medium) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .glassEffect(.regular) + } + + Button { + Task { await onToggleStar() } + } label: { + Image(systemName: isStarred ? "star.fill" : "star") + .foregroundStyle(isStarred ? .yellow : .secondary) + .font(.body) + } + .buttonStyle(.borderless) + .disabled(isStarring) + .accessibilityIdentifier("star-button") + } + + HStack(spacing: 12) { + if let language = repository.language { + Text(language) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .glassEffect(.regular.tint(colorForLanguage(language))) + } + + Label("\(repository.starsCount ?? 0)", systemImage: "star.fill") + + Label("\(repository.forksCount ?? 0)", systemImage: "tuningfork") + + if (repository.openIssuesCount ?? 0) > 0 { + Label("\(repository.openIssuesCount ?? 0)", systemImage: "exclamationmark.circle") + } + } + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + + if let updatedAt = repository.updatedAt { + Text(formatRelativeDate(updatedAt)) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + .padding(.vertical, 4) + } +} + +#Preview { + RepositoryListView(authService: .previewDefault) +} diff --git a/Forji/Forji/Views/RepositoryPickerView.swift b/Forji/Forji/Views/RepositoryPickerView.swift new file mode 100644 index 0000000..a43d6ee --- /dev/null +++ b/Forji/Forji/Views/RepositoryPickerView.swift @@ -0,0 +1,183 @@ +import ForgejoKit +import SwiftUI + +struct RepositoryPickerView: View { + @State private var authService: AuthenticationService + @State private var repositories: [Repository] = [] + @State private var isLoading = false + @State private var errorMessage: String? + @State private var showError = false + @State private var searchText = "" + @State private var searchTask: Task? + @State private var selectedRepository: Repository? + @State private var hasMore = true + @State private var currentPage = 1 + @Environment(\.dismiss) private var dismiss + + private let repositoryService: RepositoryService? + private let pageSize = 20 + let destination: (Repository) -> Destination + + init(authService: AuthenticationService, @ViewBuilder destination: @escaping (Repository) -> Destination) { + self.authService = authService + repositoryService = authService.client.map { RepositoryService(client: $0) } + self.destination = destination + } + + var body: some View { + NavigationStack { + List { + if isLoading, repositories.isEmpty { + LoadingListSection() + } else if repositories.isEmpty { + ContentUnavailableView { + Label("No Repositories", systemImage: "folder.badge.questionmark") + .foregroundStyle(.secondary) + } description: { + Text( + searchText.isEmpty + ? "No repositories yet." + : "No repositories matching your search.", + ) + } + } else { + Section { + ForEach(repositories) { repo in + Button { + selectedRepository = repo + } label: { + RepositoryPickerRow(repository: repo) + } + .accessibilityIdentifier("repo-picker-\(repo.fullName)") + } + + if hasMore { + ProgressView() + .frame(maxWidth: .infinity) + .listRowBackground(Color.clear) + .accessibilityIdentifier("load-more-indicator") + .task { + await loadMoreRepositories() + } + } + } + } + } + .listStyle(.insetGrouped) + .navigationTitle("Select Repository") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + .searchable(text: $searchText, prompt: "Search repositories") + .task { + await loadRepositories() + } + .debouncedSearch(text: $searchText, task: $searchTask) { + currentPage = 1 + hasMore = true + await loadRepositories() + } + .errorAlert(message: $errorMessage, isPresented: $showError) + .navigationDestination(item: $selectedRepository) { repo in + destination(repo) + } + } + } + + private func loadRepositories() async { + guard let repositoryService else { return } + isLoading = true + defer { isLoading = false } + do { + let fetched: [Repository] = if searchText.isEmpty { + try await repositoryService.fetchUserRepositories(page: 1, limit: pageSize) + } else { + try await repositoryService.searchRepositories(query: searchText, page: 1, limit: pageSize) + } + repositories = fetched + hasMore = fetched.count >= pageSize + currentPage = 2 + } catch is CancellationError { + // Ignore cancellation + } catch { + errorMessage = error.localizedDescription + showError = true + } + } + + private func loadMoreRepositories() async { + guard let repositoryService, hasMore, !isLoading else { return } + isLoading = true + defer { isLoading = false } + do { + let fetched: [Repository] = if searchText.isEmpty { + try await repositoryService.fetchUserRepositories(page: currentPage, limit: pageSize) + } else { + try await repositoryService.searchRepositories( + query: searchText, page: currentPage, limit: pageSize, + ) + } + repositories.append(contentsOf: fetched) + hasMore = fetched.count >= pageSize + currentPage += 1 + } catch is CancellationError { + // Ignore cancellation + } catch { + errorMessage = error.localizedDescription + showError = true + } + } + + #if DEBUG + init(preview _: Void, authService: AuthenticationService, repositories: [Repository], + @ViewBuilder destination: @escaping (Repository) -> Destination) + { + self.authService = authService + repositoryService = nil + self.destination = destination + _repositories = State(initialValue: repositories) + _hasMore = State(initialValue: false) + } + #endif +} + +private struct RepositoryPickerRow: View { + let repository: Repository + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(repository.fullName) + .font(.body) + + if let description = repository.description, !description.isEmpty { + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + + Spacer() + + if repository.private ?? false { + Label("Private", systemImage: "lock.fill") + .font(.caption2) + .fontWeight(.medium) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .glassEffect(.regular) + } + } + .padding(.vertical, 2) + } +} + +#Preview { + RepositoryPickerView(preview: (), authService: .previewDefault, repositories: [.preview]) { repo in + Text(repo.fullName) + } +} diff --git a/Forji/Forji/Views/ReviewSummaryView.swift b/Forji/Forji/Views/ReviewSummaryView.swift new file mode 100644 index 0000000..1929168 --- /dev/null +++ b/Forji/Forji/Views/ReviewSummaryView.swift @@ -0,0 +1,85 @@ +import ForgejoKit +import SwiftUI + +struct ReviewSummaryView: View { + let review: PullRequestReview + let comments: [ReviewComment] + + private var stateIcon: String { + switch ReviewState(rawValue: review.state) { + case .approved: "checkmark.circle.fill" + case .requestChanges: "xmark.circle.fill" + default: "message.circle.fill" + } + } + + private var stateColor: Color { + switch ReviewState(rawValue: review.state) { + case .approved: .green + case .requestChanges: .red + default: .blue + } + } + + private var stateLabel: String { + ReviewState(rawValue: review.state)?.label ?? review.state.lowercased() + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Image(systemName: stateIcon) + .foregroundStyle(stateColor) + .font(.subheadline) + Text(review.user?.login ?? "Unknown") + .font(.subheadline) + .fontWeight(.medium) + Text(stateLabel) + .font(.subheadline) + .foregroundStyle(.secondary) + Spacer() + if let date = review.submittedAt { + Text(formatRelativeDate(date)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if let body = review.body, !body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + MarkdownPreview(text: body) + } + + if !comments.isEmpty { + DisclosureGroup("Inline comments (\(comments.count))") { + ForEach(comments) { comment in + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Text(comment.path) + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(.blue) + if let line = comment.position ?? comment.originalPosition { + Text("Pos \(line)") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + MarkdownPreview(text: comment.body) + } + .padding(.vertical, 2) + } + } + } + } + .padding(.vertical, 4) + } +} + +#Preview { + List { + ReviewSummaryView( + review: .preview, + comments: [.preview], + ) + } +} diff --git a/Forji/Forji/Views/SearchableOverviewView.swift b/Forji/Forji/Views/SearchableOverviewView.swift new file mode 100644 index 0000000..20556f3 --- /dev/null +++ b/Forji/Forji/Views/SearchableOverviewView.swift @@ -0,0 +1,316 @@ +import ForgejoKit +import SwiftUI + +// swiftlint:disable:next type_body_length +struct SearchableOverviewView: View { + @State private var authService: AuthenticationService + @State private var pagination = PaginationState() + @State private var stateFilter: IssueFilterState = .open + @State private var searchText = "" + @State private var searchTask: Task? + @State private var showCreateFlow = false + @State private var involvementScope: InvolvementScope = .created + + private let issueService: IssueService? + private let issueType: String + private let navigationTitle: String + private let searchPrompt: String + private let emptyTitle: String + private let emptyOpenIcon: String + private let emptyClosedIcon: String + private let itemNoun: String + private let createButtonId: String + private let showReviewRequested: Bool + private let rowContent: (Issue) -> Row + private let detailContent: (Repository, Int, AuthenticationService) -> Detail + private let createContent: (Repository, AuthenticationService, Bool, @escaping () -> Void) -> CreateView + + init( + authService: AuthenticationService, + issueType: String, + navigationTitle: String, + searchPrompt: String, + emptyTitle: String, + emptyOpenIcon: String, + emptyClosedIcon: String, + itemNoun: String, + createButtonId: String, + showReviewRequested: Bool, + @ViewBuilder row: @escaping (Issue) -> Row, + @ViewBuilder detail: @escaping (Repository, Int, AuthenticationService) -> Detail, + @ViewBuilder createView: @escaping ( + Repository, AuthenticationService, Bool, @escaping () -> Void, + ) -> CreateView, + ) { + self.authService = authService + self.issueType = issueType + self.navigationTitle = navigationTitle + self.searchPrompt = searchPrompt + self.emptyTitle = emptyTitle + self.emptyOpenIcon = emptyOpenIcon + self.emptyClosedIcon = emptyClosedIcon + self.itemNoun = itemNoun + self.createButtonId = createButtonId + self.showReviewRequested = showReviewRequested + rowContent = row + detailContent = detail + createContent = createView + issueService = authService.client.map { IssueService(client: $0) } + } + + private var emptyDescription: String { + if !searchText.isEmpty { + return "No \(itemNoun) matching your search." + } + if stateFilter == .open { + return "All clear — no open \(itemNoun) to review." + } + let prefix = stateFilter == .all ? "" : stateFilter.rawValue + " " + return "No \(prefix)\(itemNoun) found." + } + + private var hasNonDefaultFilters: Bool { + stateFilter != .open || involvementScope != .created + } + + private var filterSummaryText: String { + let stateLabel = switch stateFilter { + case .open: "Open" + case .closed: "Closed" + case .all: "All" + } + let scopeLabel = scopeDisplayLabel(involvementScope) + if stateFilter == .open, involvementScope == .involved { + return stateLabel + } + return "\(stateLabel) · \(scopeLabel)" + } + + private func scopeDisplayLabel(_ scope: InvolvementScope) -> String { + switch scope { + case .involved: "All" + case .created: "Created by you" + case .assigned: "Assigned to you" + case .mentioned: "Mentioned" + case .reviewRequested: "Review requested" + } + } + + var body: some View { + @Bindable var pagination = pagination + ZStack(alignment: .bottomTrailing) { + VStack(spacing: 0) { + Text(filterSummaryText) + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .padding(.horizontal) + .padding(.vertical, 6) + .accessibilityIdentifier("filter-summary") + + List { + if pagination.isLoading, pagination.items.isEmpty { + LoadingListSection() + } else if pagination.items.isEmpty { + ContentUnavailableView { + Label(emptyTitle, systemImage: stateFilter == .open ? emptyOpenIcon : emptyClosedIcon) + .foregroundStyle(stateFilter == .open ? .green : .secondary) + } description: { + Text(emptyDescription) + } + } else { + Section { + ForEach(pagination.items) { issue in + if let repository = issue.repository { + NavigationLink { + detailContent(repository, issue.number, authService) + } label: { + rowContent(issue) + } + } else { + rowContent(issue) + } + } + + if pagination.hasMore { + ProgressView() + .frame(maxWidth: .infinity) + .listRowBackground(Color.clear) + .accessibilityIdentifier("load-more-indicator") + .task { + await loadMore() + } + } + } + } + } + .listStyle(.insetGrouped) + } + + FloatingCreateButton(action: { showCreateFlow = true }) + .accessibilityIdentifier(createButtonId) + } + .navigationTitle(navigationTitle) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Section("State") { + filterButton("Open", isSelected: stateFilter == .open) { + stateFilter = .open + } + filterButton("Closed", isSelected: stateFilter == .closed) { + stateFilter = .closed + } + filterButton("All", isSelected: stateFilter == .all) { + stateFilter = .all + } + } + Section("Scope") { + filterButton("All", isSelected: involvementScope == .involved) { + involvementScope = .involved + } + filterButton("Created by you", isSelected: involvementScope == .created) { + involvementScope = .created + } + filterButton("Assigned to you", isSelected: involvementScope == .assigned) { + involvementScope = .assigned + } + filterButton("Mentioned", isSelected: involvementScope == .mentioned) { + involvementScope = .mentioned + } + if showReviewRequested { + filterButton( + "Review requested", + isSelected: involvementScope == .reviewRequested, + ) { + involvementScope = .reviewRequested + } + } + } + } label: { + Image( + systemName: hasNonDefaultFilters + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease.circle", + ) + } + .accessibilityIdentifier("filter-menu-button") + } + } + .searchable(text: $searchText, prompt: searchPrompt) + .refreshable { + await reloadItems().value + } + .task { + reloadItems() + } + .onChange(of: stateFilter) { + reloadItems(clearItems: true) + } + .onChange(of: involvementScope) { + reloadItems(clearItems: true) + } + .debouncedSearch(text: $searchText, task: $searchTask) { + await reloadItems().value + } + .sheet(isPresented: $showCreateFlow) { + RepositoryPickerView(authService: authService) { repo in + createContent(repo, authService, true) { + showCreateFlow = false + reloadItems() + } + } + } + .errorAlert(message: $pagination.errorMessage, isPresented: $pagination.showError) + } + + private func filterButton( + _ label: String, + isSelected: Bool, + action: @escaping () -> Void, + ) -> some View { + Button(action: action) { + if isSelected { + Label(label, systemImage: "checkmark") + } else { + Text(label) + } + } + } + + private func searchIssues(service: IssueService, page: Int, limit: Int) async throws -> [Issue] { + switch involvementScope { + case .involved: + // No involvement flags — the API uses AND logic when multiple are set, + // so we omit all flags to show everything visible to the user. + try await service.searchIssues( + type: issueType, state: stateFilter.rawValue, query: searchText, + page: page, limit: limit, + ) + case .created: + try await service.searchIssues( + type: issueType, state: stateFilter.rawValue, query: searchText, + page: page, limit: limit, created: true, + ) + case .assigned: + try await service.searchIssues( + type: issueType, state: stateFilter.rawValue, query: searchText, + page: page, limit: limit, assigned: true, + ) + case .mentioned: + try await service.searchIssues( + type: issueType, state: stateFilter.rawValue, query: searchText, + page: page, limit: limit, mentioned: true, + ) + case .reviewRequested: + try await service.searchIssues( + type: issueType, state: stateFilter.rawValue, query: searchText, + page: page, limit: limit, reviewRequested: true, + ) + } + } + + @discardableResult + private func reloadItems(clearItems: Bool = false) -> Task { + guard let issueService else { return Task {} } + return pagination.reload(clearItems: clearItems) { [self] page, limit in + try await searchIssues(service: issueService, page: page, limit: limit) + } + } + + private func loadMore() async { + guard let issueService else { return } + await pagination.loadMore { [self] page, limit in + try await searchIssues(service: issueService, page: page, limit: limit) + } + } + + #if DEBUG + init( + preview _: Void, + authService: AuthenticationService, + items: [Issue], + @ViewBuilder row: @escaping (Issue) -> Row, + @ViewBuilder detail: @escaping (Repository, Int, AuthenticationService) -> Detail, + @ViewBuilder createView: @escaping ( + Repository, AuthenticationService, Bool, @escaping () -> Void, + ) -> CreateView, + ) { + self.authService = authService + issueService = nil + issueType = "issues" + navigationTitle = "" + searchPrompt = "" + emptyTitle = "" + emptyOpenIcon = "" + emptyClosedIcon = "" + itemNoun = "" + createButtonId = "" + showReviewRequested = false + rowContent = row + detailContent = detail + createContent = createView + _pagination = State(initialValue: PaginationState(items: items)) + } + #endif +} diff --git a/Forji/Forji/Views/StateAccent.swift b/Forji/Forji/Views/StateAccent.swift new file mode 100644 index 0000000..16aaa77 --- /dev/null +++ b/Forji/Forji/Views/StateAccent.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct StateAccentModifier: ViewModifier { + let color: Color + + func body(content: Content) -> some View { + content + .overlay(alignment: .leading) { + RoundedRectangle(cornerRadius: 1.5) + .fill(color) + .frame(width: 3) + .padding(.vertical, 4) + .offset(x: -12) + } + } +} + +extension View { + func stateAccent(_ color: Color) -> some View { + modifier(StateAccentModifier(color: color)) + } +} diff --git a/Forji/ForjiTests/CommentSheetTests.swift b/Forji/ForjiTests/CommentSheetTests.swift new file mode 100644 index 0000000..6fdb8da --- /dev/null +++ b/Forji/ForjiTests/CommentSheetTests.swift @@ -0,0 +1,36 @@ +import Foundation +import Testing +import ForgejoKit +@testable import Forji + +@MainActor +struct CommentSheetTests { + + @Test func onSubmitClosureIsInvoked() async throws { + var submittedBody: String? + let sheet = CommentSheet(users: []) { body in + submittedBody = body + } + #expect(sheet.users.isEmpty) + try await sheet.onSubmit(" hello world ") + #expect(submittedBody == " hello world ") + } + + @Test func usersPassedThrough() { + let user = User( + id: 1, + login: "testuser", + fullName: "Test User", + email: "test@example.com", + avatarUrl: "https://example.com/avatar.png" + ) + let sheet = CommentSheet(users: [user]) { _ in } + #expect(sheet.users.count == 1) + #expect(sheet.users.first?.login == "testuser") + } + + @Test func emptyUsersForIssues() { + let sheet = CommentSheet(users: []) { _ in } + #expect(sheet.users.isEmpty) + } +} diff --git a/Forji/ForjiTests/ForjiTests.swift b/Forji/ForjiTests/ForjiTests.swift new file mode 100644 index 0000000..fd1156c --- /dev/null +++ b/Forji/ForjiTests/ForjiTests.swift @@ -0,0 +1,26 @@ +import Foundation +import Testing +import ForgejoKit +@testable import Forji + +struct ForjiTests { + + @Test func formatRelativeDateReturnsNonEmptyString() { + let fixedDate = Date(timeIntervalSince1970: 0) + let result = formatRelativeDate(fixedDate) + #expect(!result.isEmpty) + } + + @Test func formatRelativeDateHandlesRecentDate() { + let fixedDate = Date(timeIntervalSince1970: 1000) + let result = formatRelativeDate(fixedDate) + #expect(!result.isEmpty) + } + + @Test func formatRelativeDateReturnsSameValueOnRepeatedCalls() { + let date = Date().addingTimeInterval(-3600) + let first = formatRelativeDate(date) + let second = formatRelativeDate(date) + #expect(first == second) + } +} diff --git a/Forji/ForjiTests/KeychainManagerTests.swift b/Forji/ForjiTests/KeychainManagerTests.swift new file mode 100644 index 0000000..db8dd1d --- /dev/null +++ b/Forji/ForjiTests/KeychainManagerTests.swift @@ -0,0 +1,136 @@ +import Foundation +import Testing +@testable import Forji + +struct KeychainManagerTests { + + // MARK: - Key format consistency + + @Test func keychainKeyFormatMatchesBetweenSaveAndGet() async throws { + let server = "https://forgejo.example.com" + let username = "testuser" + let password = "secret123" + + // Save and retrieve to verify key format consistency + try await KeychainManager.shared.savePassword(password, for: server, username: username) + let retrieved = try await KeychainManager.shared.getPassword(for: server, username: username) + #expect(retrieved == password) + + // Clean up + try await KeychainManager.shared.deletePassword(for: server, username: username) + } + + @Test func keychainDeleteNonExistentDoesNotThrow() async throws { + // Deleting a key that doesn't exist should not throw + // (errSecItemNotFound is accepted) + try await KeychainManager.shared.deletePassword( + for: "https://nonexistent.example.com", + username: "nobody" + ) + } + + @Test func keychainGetNonExistentThrowsNotFound() async { + do { + _ = try await KeychainManager.shared.getPassword( + for: "https://nonexistent.example.com", + username: "nobody" + ) + Issue.record("Expected KeychainError.notFound") + } catch is KeychainError { + // Expected + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test func keychainSaveOverwritesExistingEntry() async throws { + let server = "https://overwrite-test.example.com" + let username = "user" + + try await KeychainManager.shared.savePassword("first", for: server, username: username) + try await KeychainManager.shared.savePassword("second", for: server, username: username) + + let retrieved = try await KeychainManager.shared.getPassword(for: server, username: username) + #expect(retrieved == "second") + + // Clean up + try await KeychainManager.shared.deletePassword(for: server, username: username) + } + + @Test func keychainDifferentUsernamesSameServer() async throws { + let server = "https://multi-user.example.com" + + try await KeychainManager.shared.savePassword("pass1", for: server, username: "user1") + try await KeychainManager.shared.savePassword("pass2", for: server, username: "user2") + + let p1 = try await KeychainManager.shared.getPassword(for: server, username: "user1") + let p2 = try await KeychainManager.shared.getPassword(for: server, username: "user2") + #expect(p1 == "pass1") + #expect(p2 == "pass2") + + // Clean up + try await KeychainManager.shared.deletePassword(for: server, username: "user1") + try await KeychainManager.shared.deletePassword(for: server, username: "user2") + } + + @Test func keychainTokenSaveAndRetrieve() async throws { + let server = "https://token-test.example.com" + let username = "tokenuser" + let token = "abc123tokenvalue" + + try await KeychainManager.shared.saveToken(token, for: server, username: username) + let retrieved = try await KeychainManager.shared.getToken(for: server, username: username) + #expect(retrieved == token) + + // Clean up + try await KeychainManager.shared.deleteToken(for: server, username: username) + + // Verify deletion + do { + _ = try await KeychainManager.shared.getToken(for: server, username: username) + Issue.record("Expected KeychainError.notFound after delete") + } catch is KeychainError { + // Expected + } + } + + @Test func keychainTokenAndPasswordAreSeparateKeys() async throws { + let server = "https://separate-keys.example.com" + let username = "user" + let password = "mypassword" + let token = "mytoken" + + try await KeychainManager.shared.savePassword(password, for: server, username: username) + try await KeychainManager.shared.saveToken(token, for: server, username: username) + + let retrievedPassword = try await KeychainManager.shared.getPassword(for: server, username: username) + let retrievedToken = try await KeychainManager.shared.getToken(for: server, username: username) + + #expect(retrievedPassword == password) + #expect(retrievedToken == token) + + // Clean up + try await KeychainManager.shared.deletePassword(for: server, username: username) + try await KeychainManager.shared.deleteToken(for: server, username: username) + } + + @Test func keychainNormalizedAndRawURLsAreDifferentKeys() async throws { + // This tests the exact issue: if you save with normalized URL but delete with raw URL, + // the deletion will miss. + let raw = "https://forgejo.example.com/" + let normalized = "https://forgejo.example.com" + + try await KeychainManager.shared.savePassword("pass", for: normalized, username: "user") + + // Getting with the raw URL (with trailing slash) should fail because keys differ + do { + _ = try await KeychainManager.shared.getPassword(for: raw, username: "user") + Issue.record("Should not find password with non-normalized key") + } catch is KeychainError { + // Expected — keys differ + } + + // Clean up + try await KeychainManager.shared.deletePassword(for: normalized, username: "user") + } +} diff --git a/Forji/ForjiTests/LanguageColorTests.swift b/Forji/ForjiTests/LanguageColorTests.swift new file mode 100644 index 0000000..140d12a --- /dev/null +++ b/Forji/ForjiTests/LanguageColorTests.swift @@ -0,0 +1,48 @@ +import SwiftUI +import Testing +@testable import Forji + +struct LanguageColorTests { + + // MARK: - Determinism + + @Test func sameLanguageReturnsSameColor() { + let color1 = colorForLanguage("Swift") + let color2 = colorForLanguage("Swift") + #expect(color1 == color2) + } + + @Test func differentLanguagesReturnDifferentColors() { + let swift = colorForLanguage("Swift") + let python = colorForLanguage("Python") + let rust = colorForLanguage("Rust") + // At least some should differ (extremely unlikely all 3 hash to same hue) + let allSame = (swift == python) && (python == rust) + #expect(!allSame) + } + + // MARK: - Edge cases + + @Test func emptyStringDoesNotCrash() { + let color = colorForLanguage("") + // Should return a valid color, not crash + _ = color + } + + @Test func caseMatters() { + let lower = colorForLanguage("swift") + let upper = colorForLanguage("Swift") + #expect(lower != upper) + } + + @Test func nonAsciiLanguageNames() { + // Should handle non-ASCII without crashing + let color = colorForLanguage("Ren\u{2019}Py") + _ = color + } + + @Test func longLanguageName() { + let color = colorForLanguage(String(repeating: "x", count: 10000)) + _ = color + } +} diff --git a/Forji/ForjiTests/MarkdownComponentsTests.swift b/Forji/ForjiTests/MarkdownComponentsTests.swift new file mode 100644 index 0000000..46fedc2 --- /dev/null +++ b/Forji/ForjiTests/MarkdownComponentsTests.swift @@ -0,0 +1,32 @@ +import Foundation +import Testing +@testable import Forji + +struct MarkdownComponentsTests { + + @Test func repoRelativePathExtractsFilePath() { + let url = URL(string: "https://forgejo.example.com/owner/repo/src/branch/main/path/to/file.swift")! + #expect(repoRelativePath(from: url) == "path/to/file.swift") + } + + @Test func repoRelativePathReturnsNilForBranchOnly() { + // URL ends right after the ref — no file path follows + let url = URL(string: "https://forgejo.example.com/owner/repo/src/branch/main")! + #expect(repoRelativePath(from: url) == nil) + } + + @Test func repoRelativePathReturnsNilForUnrelatedURL() { + let url = URL(string: "https://example.com/some/other/page")! + #expect(repoRelativePath(from: url) == nil) + } + + @Test func repoRelativePathHandlesSubpath() { + let url = URL(string: "https://example.com/forgejo/owner/repo/src/branch/main/README.md")! + #expect(repoRelativePath(from: url) == "README.md") + } + + @Test func repoRelativePathHandlesNestedFilePath() { + let url = URL(string: "https://forgejo.example.com/owner/repo/src/branch/feature/a/b/c.txt")! + #expect(repoRelativePath(from: url) == "a/b/c.txt") + } +} diff --git a/Forji/ForjiTests/MermaidParserTests.swift b/Forji/ForjiTests/MermaidParserTests.swift new file mode 100644 index 0000000..69e53f0 --- /dev/null +++ b/Forji/ForjiTests/MermaidParserTests.swift @@ -0,0 +1,188 @@ +import Foundation +import Testing +@testable import Forji + +@MainActor +struct MermaidParserTests { + + // MARK: - No mermaid blocks + + @Test func noMermaidBlocks() { + let markdown = "# Hello\n\nSome regular markdown text." + let segments = MermaidParser.parse(markdown) + #expect(segments == [.text(markdown)]) + } + + @Test func emptyString() { + let segments = MermaidParser.parse("") + #expect(segments == [.text("")]) + } + + // MARK: - Single mermaid block + + @Test func singleMermaidBlockWithSurroundingText() { + let markdown = """ + # Title + + Some text before. + + ```mermaid + graph TD + A --> B + ``` + + Some text after. + """ + let segments = MermaidParser.parse(markdown) + #expect(segments.count == 3) + + if case .text(let before) = segments[0] { + #expect(before.contains("Title")) + #expect(before.contains("Some text before.")) + } else { + Issue.record("Expected .text segment") + } + + if case .mermaid(let code) = segments[1] { + #expect(code.contains("graph TD")) + #expect(code.contains("A --> B")) + } else { + Issue.record("Expected .mermaid segment") + } + + if case .text(let after) = segments[2] { + #expect(after.contains("Some text after.")) + } else { + Issue.record("Expected .text segment") + } + } + + // MARK: - Multiple mermaid blocks + + @Test func multipleMermaidBlocks() { + let markdown = """ + Text before first. + + ```mermaid + graph LR + A --> B + ``` + + Text between. + + ```mermaid + sequenceDiagram + Alice->>Bob: Hello + ``` + + Text after second. + """ + let segments = MermaidParser.parse(markdown) + #expect(segments.count == 5) + + if case .text = segments[0] {} else { Issue.record("Expected .text") } + if case .mermaid(let code1) = segments[1] { + #expect(code1.contains("graph LR")) + } else { Issue.record("Expected .mermaid") } + if case .text = segments[2] {} else { Issue.record("Expected .text") } + if case .mermaid(let code2) = segments[3] { + #expect(code2.contains("sequenceDiagram")) + } else { Issue.record("Expected .mermaid") } + if case .text = segments[4] {} else { Issue.record("Expected .text") } + } + + // MARK: - Edge cases + + @Test func mermaidBlockAtStart() { + let markdown = """ + ```mermaid + graph TD + A --> B + ``` + + Text after. + """ + let segments = MermaidParser.parse(markdown) + #expect(segments.count == 2) + if case .mermaid = segments[0] {} else { Issue.record("Expected .mermaid") } + if case .text = segments[1] {} else { Issue.record("Expected .text") } + } + + @Test func mermaidBlockAtEnd() { + let markdown = """ + Text before. + + ```mermaid + graph TD + A --> B + ``` + """ + let segments = MermaidParser.parse(markdown) + #expect(segments.count == 2) + if case .text = segments[0] {} else { Issue.record("Expected .text") } + if case .mermaid = segments[1] {} else { Issue.record("Expected .mermaid") } + } + + @Test func emptyMermaidBlock() { + let markdown = """ + Text before. + + ```mermaid + ``` + + Text after. + """ + let segments = MermaidParser.parse(markdown) + for segment in segments { + if case .mermaid = segment { + Issue.record("Empty mermaid block should be skipped") + } + } + } + + // MARK: - Non-mermaid code blocks + + @Test func nonMermaidCodeBlocksLeftAsText() { + let markdown = """ + # Code example + + ```python + print("hello") + ``` + + ```javascript + console.log("hi") + ``` + """ + let segments = MermaidParser.parse(markdown) + #expect(segments == [.text(markdown)]) + } + + @Test func mixedMermaidAndNonMermaidBlocks() { + let markdown = """ + ```python + print("hello") + ``` + + ```mermaid + graph TD + A --> B + ``` + + ```javascript + console.log("hi") + ``` + """ + let segments = MermaidParser.parse(markdown) + #expect(segments.count == 3) + if case .text(let before) = segments[0] { + #expect(before.contains("python")) + } else { Issue.record("Expected .text") } + if case .mermaid(let code) = segments[1] { + #expect(code.contains("graph TD")) + } else { Issue.record("Expected .mermaid") } + if case .text(let after) = segments[2] { + #expect(after.contains("javascript")) + } else { Issue.record("Expected .text") } + } +} diff --git a/Forji/ForjiTests/PaginationStateTests.swift b/Forji/ForjiTests/PaginationStateTests.swift new file mode 100644 index 0000000..edaa0b0 --- /dev/null +++ b/Forji/ForjiTests/PaginationStateTests.swift @@ -0,0 +1,387 @@ +import Foundation +import Testing +@testable import Forji + +// MARK: - Test helpers + +/// Waits on MainActor until a condition is true, yielding between checks. +@MainActor +private func yieldUntil(_ condition: @MainActor () -> Bool) async { + for _ in 0 ..< 500 { + if condition() { return } + await Task.yield() + } +} + +/// A fetch whose completion the test controls via continuations. +@MainActor +private final class ControllableFetch { + private(set) var callCount = 0 + private var continuation: CheckedContinuation<[String], any Error>? + var isPending: Bool { continuation != nil } + + func fetch(page: Int, limit: Int) async throws -> [String] { + callCount += 1 + return try await withCheckedThrowingContinuation { cont in + self.continuation = cont + } + } + + func complete(returning items: [String]) { + let cont = continuation + continuation = nil + cont?.resume(returning: items) + } + + func complete(throwing error: any Error) { + let cont = continuation + continuation = nil + cont?.resume(throwing: error) + } +} + +// MARK: - Basic reload + +struct PaginationStateBasicTests { + + @Test @MainActor func reloadSetsItems() async { + let pagination = PaginationState(pageSize: 5) + await pagination.reload { _, _ in ["a", "b", "c"] }.value + #expect(pagination.items == ["a", "b", "c"]) + #expect(!pagination.isLoading) + #expect(!pagination.hasMore) // 3 < pageSize 5 + } + + @Test @MainActor func reloadSetsHasMoreWhenFull() async { + let pagination = PaginationState(pageSize: 3) + await pagination.reload { _, _ in ["a", "b", "c"] }.value + #expect(pagination.hasMore) // 3 >= pageSize 3 + } + + @Test @MainActor func reloadReplacesExistingItems() async { + let pagination = PaginationState(pageSize: 20) + await pagination.reload { _, _ in ["old"] }.value + await pagination.reload { _, _ in ["new"] }.value + #expect(pagination.items == ["new"]) + } + + @Test @MainActor func reloadError() async { + let pagination = PaginationState(pageSize: 20) + await pagination.reload { _, _ in throw URLError(.badServerResponse) }.value + #expect(pagination.items.isEmpty) + #expect(pagination.showError) + #expect(pagination.errorMessage != nil) + #expect(!pagination.isLoading) + } + + @Test @MainActor func reloadClearsErrorState() async { + let pagination = PaginationState(pageSize: 20) + await pagination.reload { _, _ in throw URLError(.badServerResponse) }.value + #expect(pagination.showError) + + await pagination.reload { _, _ in ["ok"] }.value + #expect(!pagination.showError) + #expect(pagination.items == ["ok"]) + } + + @Test @MainActor func clearItemsEmptiesImmediately() async { + let pagination = PaginationState(pageSize: 20) + await pagination.reload { _, _ in ["a", "b"] }.value + + let fetcher = ControllableFetch() + let task = pagination.reload(clearItems: true) { page, limit in + try await fetcher.fetch(page: page, limit: limit) + } + // Items should be cleared synchronously before the fetch starts + #expect(pagination.items.isEmpty) + #expect(pagination.isLoading) + + await yieldUntil { fetcher.isPending } + fetcher.complete(returning: ["c"]) + await task.value + #expect(pagination.items == ["c"]) + #expect(!pagination.isLoading) + } +} + +// MARK: - Cancellation + +struct PaginationStateCancellationTests { + + @Test @MainActor func cancellationErrorIgnored() async { + let pagination = PaginationState(pageSize: 20) + await pagination.reload { _, _ in ["initial"] }.value + + await pagination.reload { _, _ in throw CancellationError() }.value + + // Items from new reload are empty since it threw before returning items + // But cancellation should not surface an error + #expect(!pagination.showError) + #expect(!pagination.isLoading) + } + + @Test @MainActor func urlErrorCancelledIgnored() async { + let pagination = PaginationState(pageSize: 20) + + await pagination.reload { _, _ in throw URLError(.cancelled) }.value + + #expect(pagination.items.isEmpty) + #expect(!pagination.showError) + #expect(!pagination.isLoading) + } +} + +// MARK: - Concurrent reloads (internal task cancellation) + +struct PaginationStateConcurrentTests { + + @Test @MainActor func secondReloadCancelsFirst() async { + let pagination = PaginationState(pageSize: 20) + let fetcherA = ControllableFetch() + let fetcherB = ControllableFetch() + + // Start reload A + pagination.reload { page, limit in + try await fetcherA.fetch(page: page, limit: limit) + } + await yieldUntil { fetcherA.isPending } + + // Start reload B — cancels A's internal task + let taskB = pagination.reload { page, limit in + try await fetcherB.fetch(page: page, limit: limit) + } + await yieldUntil { fetcherB.isPending } + + // Complete both — only B's results should be applied + fetcherA.complete(returning: ["stale"]) + fetcherB.complete(returning: ["fresh"]) + await taskB.value + + #expect(pagination.items == ["fresh"]) + #expect(!pagination.isLoading) + } + + @Test @MainActor func staleLoadCompletingFirstIsDiscarded() async { + let pagination = PaginationState(pageSize: 20) + let fetcherA = ControllableFetch() + let fetcherB = ControllableFetch() + + pagination.reload { page, limit in + try await fetcherA.fetch(page: page, limit: limit) + } + await yieldUntil { fetcherA.isPending } + + let taskB = pagination.reload { page, limit in + try await fetcherB.fetch(page: page, limit: limit) + } + await yieldUntil { fetcherB.isPending } + + // Complete A first (stale, its task was cancelled) — must be discarded + fetcherA.complete(returning: ["stale"]) + await yieldUntil { fetcherA.callCount == 1 } + + #expect(pagination.items.isEmpty) // B hasn't completed yet + #expect(pagination.isLoading) // B is still loading + + // Complete B (fresh) + fetcherB.complete(returning: ["fresh"]) + await taskB.value + + #expect(pagination.items == ["fresh"]) + #expect(!pagination.isLoading) + } + + @Test @MainActor func threeOverlappingReloadsOnlyLatestApplied() async { + let pagination = PaginationState(pageSize: 20) + let fetcherA = ControllableFetch() + let fetcherB = ControllableFetch() + let fetcherC = ControllableFetch() + + pagination.reload { page, limit in + try await fetcherA.fetch(page: page, limit: limit) + } + await yieldUntil { fetcherA.isPending } + + pagination.reload { page, limit in + try await fetcherB.fetch(page: page, limit: limit) + } + await yieldUntil { fetcherB.isPending } + + let taskC = pagination.reload { page, limit in + try await fetcherC.fetch(page: page, limit: limit) + } + await yieldUntil { fetcherC.isPending } + + // Complete in order A, B, C — only C should be applied + fetcherA.complete(returning: ["a"]) + fetcherB.complete(returning: ["b"]) + fetcherC.complete(returning: ["c"]) + await taskC.value + + #expect(pagination.items == ["c"]) + #expect(!pagination.isLoading) + } + + @Test @MainActor func staleErrorDoesNotSurface() async { + let pagination = PaginationState(pageSize: 20) + let fetcherA = ControllableFetch() + let fetcherB = ControllableFetch() + + pagination.reload { page, limit in + try await fetcherA.fetch(page: page, limit: limit) + } + await yieldUntil { fetcherA.isPending } + + let taskB = pagination.reload { page, limit in + try await fetcherB.fetch(page: page, limit: limit) + } + await yieldUntil { fetcherB.isPending } + + // A fails with error — but its task was cancelled, so no alert + fetcherA.complete(throwing: URLError(.badServerResponse)) + fetcherB.complete(returning: ["ok"]) + await taskB.value + + #expect(pagination.items == ["ok"]) + #expect(!pagination.showError) + } + + @Test @MainActor func isLoadingStaysTrueWhileLatestIsInFlight() async { + let pagination = PaginationState(pageSize: 20) + let fetcherA = ControllableFetch() + let fetcherB = ControllableFetch() + + pagination.reload { page, limit in + try await fetcherA.fetch(page: page, limit: limit) + } + await yieldUntil { fetcherA.isPending } + #expect(pagination.isLoading) + + let taskB = pagination.reload { page, limit in + try await fetcherB.fetch(page: page, limit: limit) + } + await yieldUntil { fetcherB.isPending } + #expect(pagination.isLoading) + + // Complete stale A — isLoading must REMAIN true (B is still in flight) + fetcherA.complete(returning: ["stale"]) + await yieldUntil { fetcherA.callCount == 1 } + #expect(pagination.isLoading, "isLoading must stay true while latest reload (B) is pending") + + fetcherB.complete(returning: ["fresh"]) + await taskB.value + #expect(!pagination.isLoading) + } + + /// Simulates the exact pattern from SearchableOverviewView: + /// .task fires reload → user changes filter → onChange fires reload (cancels previous) + @Test @MainActor func simulatedViewFilterChange() async { + let pagination = PaginationState(pageSize: 20) + let initialFetcher = ControllableFetch() + let filterFetcher = ControllableFetch() + + // Simulate .task { pagination.reload { ... } } + pagination.reload { page, limit in + try await initialFetcher.fetch(page: page, limit: limit) + } + await yieldUntil { initialFetcher.isPending } + + // Simulate onChange: user changed filter — just call reload again + let filterTask = pagination.reload(clearItems: true) { page, limit in + try await filterFetcher.fetch(page: page, limit: limit) + } + await yieldUntil { filterFetcher.isPending } + + // Initial load completes (stale, its task was cancelled) + initialFetcher.complete(returning: ["stale-created-results"]) + // Filter load completes with correct results + filterFetcher.complete(returning: ["fresh-involved-results"]) + + await filterTask.value + + #expect(pagination.items == ["fresh-involved-results"]) + #expect(!pagination.isLoading) + #expect(!pagination.showError) + } +} + +// MARK: - loadMore + +struct PaginationStateLoadMoreTests { + + @Test @MainActor func loadMoreAppendsItems() async { + let pagination = PaginationState(pageSize: 2) + await pagination.reload { _, _ in ["a", "b"] }.value + #expect(pagination.hasMore) + + await pagination.loadMore { page, limit in + #expect(page == 2) + return ["c", "d"] + } + #expect(pagination.items == ["a", "b", "c", "d"]) + } + + @Test @MainActor func loadMoreStopsWhenNoMore() async { + let pagination = PaginationState(pageSize: 5) + await pagination.reload { _, _ in ["a", "b", "c", "d", "e"] }.value + + await pagination.loadMore { _, _ in ["f"] } // 1 < pageSize -> no more + #expect(!pagination.hasMore) + #expect(pagination.items == ["a", "b", "c", "d", "e", "f"]) + } + + @Test @MainActor func loadMoreSkipsWhenAlreadyLoading() async { + let pagination = PaginationState(pageSize: 2) + await pagination.reload { _, _ in ["a", "b"] }.value + + let fetcher = ControllableFetch() + let firstMore = Task { + await pagination.loadMore { page, limit in + try await fetcher.fetch(page: page, limit: limit) + } + } + await yieldUntil { fetcher.isPending } + + // Second loadMore should bail because isLoading is true + var secondCalled = false + await pagination.loadMore { _, _ in + secondCalled = true + return ["should-not-run"] + } + #expect(!secondCalled) + + fetcher.complete(returning: ["c"]) + await firstMore.value + } + + @Test @MainActor func loadMoreDiscardedWhenReloadSupersedes() async { + let pagination = PaginationState(pageSize: 2) + await pagination.reload { _, _ in ["a", "b"] }.value + + let moreFetcher = ControllableFetch() + let moreTask = Task { + await pagination.loadMore { page, limit in + try await moreFetcher.fetch(page: page, limit: limit) + } + } + await yieldUntil { moreFetcher.isPending } + + // A reload supersedes the in-flight loadMore (cancels its task) + let freshFetcher = ControllableFetch() + let reloadTask = pagination.reload { page, limit in + try await freshFetcher.fetch(page: page, limit: limit) + } + await yieldUntil { freshFetcher.isPending } + + // loadMore completes — should be discarded (task was cancelled) + moreFetcher.complete(returning: ["stale-more"]) + await moreTask.value + + // reload completes — should be applied + freshFetcher.complete(returning: ["fresh"]) + await reloadTask.value + + #expect(pagination.items == ["fresh"]) + #expect(!pagination.isLoading) + } +} diff --git a/Forji/ForjiTests/StateTests.swift b/Forji/ForjiTests/StateTests.swift new file mode 100644 index 0000000..eaac1ae --- /dev/null +++ b/Forji/ForjiTests/StateTests.swift @@ -0,0 +1,136 @@ +import Foundation +import Testing +import ForgejoKit +@testable import Forji + +struct StateTests { + + // MARK: - IssueState + + @Test func issueStateOpenRawValue() { + let state = IssueState(rawValue: "open") + #expect(state == .open) + } + + @Test func issueStateClosedRawValue() { + let state = IssueState(rawValue: "closed") + #expect(state == .closed) + } + + @Test func issueStateUnknownReturnsNil() { + let state = IssueState(rawValue: "unknown") + #expect(state == nil) + } + + @Test func issueStateEmptyReturnsNil() { + let state = IssueState(rawValue: "") + #expect(state == nil) + } + + // MARK: - PullRequestState + + @Test func pullRequestStateOpenRawValue() { + let state = PullRequestState(rawValue: "open") + #expect(state == .open) + } + + @Test func pullRequestStateClosedRawValue() { + let state = PullRequestState(rawValue: "closed") + #expect(state == .closed) + } + + @Test func pullRequestStateUnknownReturnsNil() { + let state = PullRequestState(rawValue: "unknown") + #expect(state == nil) + } + + // MARK: - Issue stateValue extension defaults to closed for unknown + + @Test func issueStateValueDefaultsToClosedForUnknown() { + let issue = Issue( + id: 1, + number: 1, + title: "Test", + state: "unknown", + user: User(id: 1, login: "test", avatarUrl: nil), + createdAt: Date(), + updatedAt: Date() + ) + #expect(issue.stateValue == .closed) + } + + @Test func issueStateValueReturnsOpenForOpen() { + let issue = Issue( + id: 1, + number: 1, + title: "Test", + state: "open", + user: User(id: 1, login: "test", avatarUrl: nil), + createdAt: Date(), + updatedAt: Date() + ) + #expect(issue.stateValue == .open) + } + + // MARK: - NotificationSubjectState + + @Test func notificationSubjectStateMerged() { + let state = NotificationSubjectState(rawValue: "merged") + #expect(state == .merged) + } + + @Test func notificationSubjectStateUnknownReturnsNil() { + let state = NotificationSubjectState(rawValue: "pending") + #expect(state == nil) + } + + // MARK: - IssueFilterState + + @Test func issueFilterStateAllRawValue() { + let state = IssueFilterState(rawValue: "all") + #expect(state == .all) + } + + // MARK: - PullRequestFilterState + + @Test func pullRequestFilterStateAllRawValue() { + let state = PullRequestFilterState(rawValue: "all") + #expect(state == .all) + } + + // MARK: - InvolvementScope + + @Test func involvementScopeInvolvedRawValue() { + let scope = InvolvementScope(rawValue: "involved") + #expect(scope == .involved) + } + + @Test func involvementScopeCreatedRawValue() { + let scope = InvolvementScope(rawValue: "created") + #expect(scope == .created) + } + + @Test func involvementScopeAssignedRawValue() { + let scope = InvolvementScope(rawValue: "assigned") + #expect(scope == .assigned) + } + + @Test func involvementScopeMentionedRawValue() { + let scope = InvolvementScope(rawValue: "mentioned") + #expect(scope == .mentioned) + } + + @Test func involvementScopeReviewRequestedRawValue() { + let scope = InvolvementScope(rawValue: "review_requested") + #expect(scope == .reviewRequested) + } + + @Test func involvementScopeUnknownReturnsNil() { + let scope = InvolvementScope(rawValue: "unknown") + #expect(scope == nil) + } + + @Test func involvementScopeAllCases() { + #expect(InvolvementScope.allCases.count == 5) + } +} diff --git a/Forji/ForjiUITests/CommitHistoryUITests.swift b/Forji/ForjiUITests/CommitHistoryUITests.swift new file mode 100644 index 0000000..717f519 --- /dev/null +++ b/Forji/ForjiUITests/CommitHistoryUITests.swift @@ -0,0 +1,47 @@ +import XCTest + +final class CommitHistoryUITests: ForgejoReadOnlyUITestBase { + + override func tearDown() { + navigateBackToHome() + super.tearDown() + } + + // MARK: - Helpers + + @MainActor + private func navigateToCommitHistory() { + navigateToRepoDetail() + let commitsButton = app.buttons["commits-button"] + XCTAssertTrue(commitsButton.waitForExistence(timeout: 10), "Commits button not found") + commitsButton.tap() + } + + // MARK: - Commit History + + @MainActor + func testCommitHistoryNavigation() throws { + navigateToCommitHistory() + + // Commits list should appear with rows from seed data + let commitList = app.collectionViews.firstMatch + XCTAssertTrue(commitList.waitForExistence(timeout: 10), "Commit list not found") + + let firstCommitCell = commitList.cells.firstMatch + XCTAssertTrue(firstCommitCell.waitForExistence(timeout: 10), "No commit rows found") + + // The test repo has multiple commits from setup (auto_init + file creates) + XCTAssertTrue(commitList.cells.count >= 2, "Should have at least 2 commits from seed data") + + // Verify cells contain text (commit messages rendered) + let cellTexts = firstCommitCell.staticTexts + XCTAssertTrue(cellTexts.count >= 1, "Commit cells should contain text content") + + // Tap a commit to view its detail with diff + firstCommitCell.tap() + + // The Changes section should appear with diff content + let changesHeader = app.staticTexts["Changes"] + XCTAssertTrue(changesHeader.waitForExistence(timeout: 10), "Changes section not found in commit detail") + } +} diff --git a/Forji/ForjiUITests/ForgejoReadOnlyUITestBase.swift b/Forji/ForjiUITests/ForgejoReadOnlyUITestBase.swift new file mode 100644 index 0000000..ec8f555 --- /dev/null +++ b/Forji/ForjiUITests/ForgejoReadOnlyUITestBase.swift @@ -0,0 +1,123 @@ +import XCTest + +/// Base class for read-only UI tests that share a single app launch per class. +/// The app is launched once in `class setUp()` and reused across all test methods, +/// avoiding the overhead of repeated app launches for tests that don't mutate data. +class ForgejoReadOnlyUITestBase: XCTestCase, UITestNavigating { + + static var sharedApp: XCUIApplication! + static var sharedServerURL: String! + + override class func setUp() { + super.setUp() + + guard let url = ForgejoUITestBase.resolveTestServerURL(), + !url.isEmpty + else { + // Can't XCTSkip from class setUp — individual tests will skip + return + } + sharedServerURL = url + + let app = XCUIApplication() + app.launchArguments += [ + "-dev_serverURL", url, + "-dev_username", "testadmin", + "-dev_password", "admin1234", + ] + app.launch() + + let reposTab = app.tabBars.buttons["Repositories"] + guard reposTab.waitForExistence(timeout: 15) else { + XCTFail("HomeView did not appear after auto-login in class setUp") + return + } + sharedApp = app + } + + override class func tearDown() { + sharedApp = nil + sharedServerURL = nil + super.tearDown() + } + + // MARK: - Instance setup + + /// Non-optional convenience accessor (instance-level). + var app: XCUIApplication! { Self.sharedApp } + var serverURL: String! { Self.sharedServerURL } + + override func setUpWithError() throws { + continueAfterFailure = true + + guard Self.sharedServerURL != nil, Self.sharedApp != nil else { + throw XCTSkip("Integration server not running") + } + + // Force portrait orientation + XCUIDevice.shared.orientation = .portrait + + // Ensure we're at the home screen (Repositories tab as anchor) + let reposTab = app.tabBars.buttons["Repositories"] + if !reposTab.waitForExistence(timeout: 5) { + // Recovery: app may have crashed or navigated away — relaunch + let freshApp = XCUIApplication() + freshApp.launchArguments += [ + "-dev_serverURL", Self.sharedServerURL, + "-dev_username", "testadmin", + "-dev_password", "admin1234", + ] + freshApp.launch() + guard freshApp.tabBars.buttons["Repositories"].waitForExistence(timeout: 15) else { + XCTFail("Could not recover app to home screen") + return + } + Self.sharedApp = freshApp + } + } + + // MARK: - Helpers + + func navigateBackToHome() { + guard app != nil else { return } + + // Switch to Repositories tab first + let reposTab = app.tabBars.buttons["Repositories"] + if reposTab.exists && reposTab.isHittable { + reposTab.tap() + } + + // Pop any navigation stack on the Repositories tab back to root + let repoList = app.collectionViews["repo-list"] + for _ in 0..<10 { + if repoList.waitForExistence(timeout: 1) { break } + let navBarButtons = app.navigationBars.buttons + guard navBarButtons.count > 0 else { break } + let backButton = navBarButtons.element(boundBy: 0) + guard backButton.exists && backButton.isHittable else { break } + backButton.tap() + } + } + + func navigateToRepoDetail(_ repoName: String = "test-repo") { + app.tabBars.buttons["Repositories"].tap() + + // Ensure we're at the repo list root, not deep in navigation + let repoList = app.collectionViews["repo-list"] + if !repoList.waitForExistence(timeout: 3) { + for _ in 0..<5 { + let navBarButtons = app.navigationBars.buttons + guard navBarButtons.count > 0 else { break } + let backButton = navBarButtons.element(boundBy: 0) + guard backButton.exists && backButton.isHittable else { break } + backButton.tap() + if repoList.waitForExistence(timeout: 1) { break } + } + } + + let repoCell = app.staticTexts[repoName].firstMatch + XCTAssertTrue(repoCell.waitForExistence(timeout: 10)) + repoCell.tap() + } + +} diff --git a/Forji/ForjiUITests/ForgejoUITestBase.swift b/Forji/ForjiUITests/ForgejoUITestBase.swift new file mode 100644 index 0000000..2adbe69 --- /dev/null +++ b/Forji/ForjiUITests/ForgejoUITestBase.swift @@ -0,0 +1,62 @@ +import XCTest + +class ForgejoUITestBase: XCTestCase, UITestNavigating { + + var app: XCUIApplication! + var serverURL: String! + + override func setUpWithError() throws { + continueAfterFailure = true + + guard let url = Self.resolveTestServerURL(), + !url.isEmpty + else { + throw XCTSkip("Integration server not running") + } + serverURL = url + + // Force portrait orientation for consistent vertical space across test runs + XCUIDevice.shared.orientation = .portrait + + app = XCUIApplication() + app.launchArguments += [ + "-dev_serverURL", serverURL, + "-dev_username", "testadmin", + "-dev_password", "admin1234", + ] + } + + // MARK: - Server URL Resolution + + /// Resolves the Forgejo test server URL from simulator-specific temp files. + /// Each simulator gets its own file (keyed by device name) so parallel test + /// groups on different simulators can target different Forgejo instances. + static func resolveTestServerURL() -> String? { + let deviceName = (ProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"] ?? "") + .replacingOccurrences(of: " ", with: "_") + let specificPath = "/tmp/forgejo_test_url_\(deviceName).txt" + let genericPath = "/tmp/forgejo_test_url.txt" + + return ((try? String(contentsOfFile: specificPath, encoding: .utf8)) + ?? (try? String(contentsOfFile: genericPath, encoding: .utf8))) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + } + + // MARK: - Helpers + + /// Launches the app and waits for HomeView to appear via auto-login. + func loginAndWaitForHome() { + app.launch() + let reposTab = app.tabBars.buttons["Repositories"] + XCTAssertTrue(reposTab.waitForExistence(timeout: 15), "HomeView did not appear after auto-login") + } + + func navigateToRepoDetail(_ repoName: String = "test-repo") { + app.tabBars.buttons["Repositories"].tap() + + let repoCell = app.staticTexts[repoName].firstMatch + XCTAssertTrue(repoCell.waitForExistence(timeout: 10)) + repoCell.tap() + } + +} diff --git a/Forji/ForjiUITests/ForjiUITestsLaunchTests.swift b/Forji/ForjiUITests/ForjiUITestsLaunchTests.swift new file mode 100644 index 0000000..bdb3d7a --- /dev/null +++ b/Forji/ForjiUITests/ForjiUITestsLaunchTests.swift @@ -0,0 +1,31 @@ +import XCTest + +final class ForjiUITestsLaunchTests: XCTestCase { + + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + + // Skip during integration test runs — these tests need manual credentials + if FileManager.default.fileExists(atPath: "/tmp/forgejo_test_url.txt") { + throw XCTSkip("Skipping launch tests during integration test run") + } + } + + @MainActor + func testLaunch() throws { + let app = XCUIApplication() + app.launch() + + // Insert steps here to perform after app launch but before taking a screenshot, + // such as logging into a test account or navigating somewhere in the app + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/Forji/ForjiUITests/HomeScreenUITests.swift b/Forji/ForjiUITests/HomeScreenUITests.swift new file mode 100644 index 0000000..2a96752 --- /dev/null +++ b/Forji/ForjiUITests/HomeScreenUITests.swift @@ -0,0 +1,34 @@ +import XCTest + +final class HomeScreenUITests: ForgejoUITestBase { + + // MARK: - Home Screen (login, nav links, logout) + + @MainActor + func testHomeScreen() throws { + loginAndWaitForHome() + + // Verify home screen content + XCTAssertTrue(app.tabBars.buttons["Repositories"].exists) + + // All nav links present + XCTAssertTrue(app.tabBars.buttons["Issues"].exists) + XCTAssertTrue(app.tabBars.buttons["Pull Requests"].exists) + XCTAssertTrue(app.tabBars.buttons["Notifications"].exists) + + // Navigate to Settings tab and verify user info + app.tabBars.buttons["Settings"].tap() + XCTAssertTrue(app.staticTexts["@testadmin"].waitForExistence(timeout: 5)) + + // Scroll down to reveal logout button (may be below the fold) + app.swipeUp() + + // Logout returns to instance list + let logoutButton = app.buttons["home-logout-button"] + XCTAssertTrue(logoutButton.waitForExistence(timeout: 5)) + logoutButton.tap() + + let addButton = app.buttons["instance-add-button"] + XCTAssertTrue(addButton.waitForExistence(timeout: 5)) + } +} diff --git a/Forji/ForjiUITests/IssueMutatingUITests.swift b/Forji/ForjiUITests/IssueMutatingUITests.swift new file mode 100644 index 0000000..8c37279 --- /dev/null +++ b/Forji/ForjiUITests/IssueMutatingUITests.swift @@ -0,0 +1,88 @@ +import XCTest + +final class IssueMutatingUITests: ForgejoUITestBase { + + // MARK: - Issue Tab (list, filter, detail, label, create, edit, close/reopen) + + @MainActor + func testIssueTab() throws { + loginAndWaitForHome() + navigateToIssueTab() + + // Open issues visible + XCTAssertTrue(app.staticTexts["Test issue 1 from integration tests"].waitForExistence(timeout: 10)) + let issueList = app.collectionViews.firstMatch + XCTAssertTrue(issueList.cells.count >= 2, "Should have at least 2 open issues") + + // Filter by Closed + let closedButton = app.buttons["Closed"] + XCTAssertTrue(closedButton.waitForExistence(timeout: 10)) + closedButton.tap() + XCTAssertTrue(app.staticTexts["Test issue 3 from integration tests"].waitForExistence(timeout: 10)) + + // Back to Open + let openButton = app.buttons["Open"] + XCTAssertTrue(openButton.waitForExistence(timeout: 5)) + openButton.tap() + XCTAssertTrue(app.staticTexts["Test issue 1 from integration tests"].waitForExistence(timeout: 10)) + + // Issue detail — title, comments, label, milestone, assignee + let issueCell = app.staticTexts["Test issue 1 from integration tests"].firstMatch + issueCell.tap() + + let titleText = app.staticTexts["issue-detail-title"] + XCTAssertTrue(titleText.waitForExistence(timeout: 10)) + XCTAssertTrue(app.staticTexts["bug"].waitForExistence(timeout: 10)) + + // Milestone and assignee (may need scroll to see assignee section) + XCTAssertTrue(app.staticTexts["v1.0"].waitForExistence(timeout: 5), "Milestone should be visible") + app.swipeUp() + app.swipeUp() + XCTAssertTrue(app.staticTexts["@testadmin"].waitForExistence(timeout: 5), "Assignee should be visible") + XCTAssertTrue(app.staticTexts["testbot"].waitForExistence(timeout: 5)) + app.swipeDown() + app.swipeDown() + + // Back to issue list + app.navigationBars.buttons.firstMatch.tap() + XCTAssertTrue(app.staticTexts["Test issue 1 from integration tests"].waitForExistence(timeout: 10)) + + // Note: Create button is inside a ZStack within a paging TabView, + // which clips accessibility elements. Create is tested via overview. + + // Edit issue #2 title + let issue2Cell = app.staticTexts["Test issue 2 from integration tests"].firstMatch + XCTAssertTrue(issue2Cell.waitForExistence(timeout: 10)) + issue2Cell.tap() + + XCTAssertTrue(app.staticTexts["issue-detail-title"].waitForExistence(timeout: 10)) + + expandActionMenu() + let issueEditButton = app.buttons["issue-edit-button"] + XCTAssertTrue(issueEditButton.waitForExistence(timeout: 5)) + issueEditButton.tap() + + let editTitleField = app.textFields["issue-edit-title-field"] + XCTAssertTrue(editTitleField.waitForExistence(timeout: 5)) + editTitleField.tap(withNumberOfTaps: 3, numberOfTouches: 1) + editTitleField.typeText("Edited issue title") + + let saveButton = app.buttons["issue-edit-save"] + XCTAssertTrue(saveButton.waitForExistence(timeout: 5)) + saveButton.tap() + XCTAssertTrue(app.staticTexts["Edited issue title"].waitForExistence(timeout: 10)) + + // Close and reopen + expandActionMenu() + let toggleButton = app.buttons["issue-toggle-state"] + XCTAssertTrue(toggleButton.waitForExistence(timeout: 10)) + toggleButton.tap() + XCTAssertTrue(app.staticTexts["Closed"].waitForExistence(timeout: 10)) + + expandActionMenu() + let reopenButton = app.buttons["issue-toggle-state"] + XCTAssertTrue(reopenButton.waitForExistence(timeout: 5)) + reopenButton.tap() + XCTAssertTrue(app.staticTexts["Open"].waitForExistence(timeout: 10)) + } +} diff --git a/Forji/ForjiUITests/IssueUITests.swift b/Forji/ForjiUITests/IssueUITests.swift new file mode 100644 index 0000000..ee2ccd3 --- /dev/null +++ b/Forji/ForjiUITests/IssueUITests.swift @@ -0,0 +1,123 @@ +import XCTest + +final class IssueUITests: ForgejoReadOnlyUITestBase { + + override func tearDown() { + navigateBackToHome() + super.tearDown() + } + + // MARK: - Issues Overview (all issues, filter, search) + + @MainActor + func testIssuesOverview() throws { + app.tabBars.buttons["Issues"].tap() + + // Issues list loads (may show pagination test issues first on page 1) + XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 10), "Issues should load") + + // Filter All via toolbar menu + let filterMenuButton = app.buttons["filter-menu-button"] + XCTAssertTrue(filterMenuButton.waitForExistence(timeout: 5)) + filterMenuButton.tap() + app.buttons["All"].firstMatch.tap() + XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 10), "Issues should load after filter change") + + // Search narrows results to specific issue (unique keyword avoids pagination test issues) + let searchField = app.searchFields.firstMatch + XCTAssertTrue(searchField.waitForExistence(timeout: 5)) + searchField.tap() + searchField.typeText("integration") + + XCTAssertTrue(app.staticTexts["Test issue 1 from integration tests"].waitForExistence(timeout: 10)) + } + + // MARK: - Issues Overview Involvement Filter + + @MainActor + func testIssuesOverviewInvolvementFilter() throws { + app.tabBars.buttons["Issues"].tap() + + // Reset filters to defaults (previous test may have changed them) + resetOverviewFilters() + + // Wait for list to load + XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 10), "Issues should load") + + // Default filter summary should show "Open · Created by you" + let filterSummary = app.staticTexts["filter-summary"] + XCTAssertTrue(filterSummary.waitForExistence(timeout: 5)) + XCTAssertEqual(filterSummary.label, "Open · Created by you") + + let filterMenuButton = app.buttons["filter-menu-button"] + + // Tap "Assigned to you" via filter menu + filterMenuButton.tap() + app.buttons["Assigned to you"].tap() + sleep(2) + XCTAssertTrue(filterSummary.label.contains("Assigned to you")) + + // Tap "Mentioned" via filter menu + filterMenuButton.tap() + app.buttons["Mentioned"].tap() + sleep(2) + XCTAssertTrue(filterSummary.label.contains("Mentioned")) + + // Tap back to "All" scope via filter menu + filterMenuButton.tap() + // The scope "All" button — use firstMatch since "All" also appears in State section + app.buttons["All"].firstMatch.tap() + sleep(2) + + // "Review requested" should NOT be present for issues + filterMenuButton.tap() + XCTAssertFalse(app.buttons["Review requested"].exists, "Review filter should not appear for issues") + // Dismiss the menu by tapping elsewhere + app.tap() + } + + // MARK: - All State + All Scope loads items + + @MainActor + func testAllStateAllScopeLoadsIssues() throws { + app.tabBars.buttons["Issues"].tap() + + let filterSummary = app.staticTexts["filter-summary"] + let filterMenuButton = app.buttons["filter-menu-button"] + XCTAssertTrue(filterMenuButton.waitForExistence(timeout: 10)) + + // Reset to known state: Open + Created by you + filterMenuButton.tap() + app.buttons["Open"].tap() + sleep(1) + filterMenuButton.tap() + app.buttons["Created by you"].tap() + + XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 10), + "Issues should load with default Open + Created filters") + XCTAssertEqual(filterSummary.label, "Open · Created by you") + + // Step 1: Change state to All (first "All" in menu is from State section) + filterMenuButton.tap() + app.buttons["All"].firstMatch.tap() + + XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 10), + "Issues should load after state changed to All") + XCTAssertEqual(filterSummary.label, "All · Created by you") + + // Step 2: Change scope to All (second "All" in menu is from Scope section) + filterMenuButton.tap() + let allButtons = app.buttons.matching(identifier: "All") + XCTAssertGreaterThanOrEqual(allButtons.count, 2, + "Menu should have at least 2 'All' buttons (State + Scope)") + allButtons.element(boundBy: 1).tap() + + XCTAssertTrue(filterSummary.waitForExistence(timeout: 5)) + XCTAssertEqual(filterSummary.label, "All · All", + "Filter summary should show All state and All scope") + + // The key assertion: items must actually load + XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 10), + "Issues MUST load with All state + All scope (involved)") + } +} diff --git a/Forji/ForjiUITests/LoginUITests.swift b/Forji/ForjiUITests/LoginUITests.swift new file mode 100644 index 0000000..3d8b4f4 --- /dev/null +++ b/Forji/ForjiUITests/LoginUITests.swift @@ -0,0 +1,33 @@ +import XCTest + +final class LoginUITests: ForgejoUITestBase { + + // MARK: - Login Bad Credentials + + @MainActor + func testLoginBadCredentials() throws { + // Skip auto-login entirely so the instance list appears immediately, + // regardless of stored tokens/sessions from previous test classes. + app.launchArguments = [ + "-dev_serverURL", serverURL!, + "-dev_username", "testadmin", + "-dev_password", "wrongpassword", + "-dev_skipAutoLogin", "true", + ] + app.launch() + + let addButton = app.buttons["instance-add-button"] + XCTAssertTrue(addButton.waitForExistence(timeout: 15), "Instance list did not appear") + addButton.tap() + + // Scroll form sheet to reveal login button (may be below the fold) + app.swipeUp() + + let loginButton = app.buttons["login-button"] + XCTAssertTrue(loginButton.waitForExistence(timeout: 5)) + loginButton.tap() + + let alert = app.alerts["Login Failed"] + XCTAssertTrue(alert.waitForExistence(timeout: 10)) + } +} diff --git a/Forji/ForjiUITests/NotificationsUITests.swift b/Forji/ForjiUITests/NotificationsUITests.swift new file mode 100644 index 0000000..3ef71a3 --- /dev/null +++ b/Forji/ForjiUITests/NotificationsUITests.swift @@ -0,0 +1,52 @@ +import XCTest + +final class NotificationsUITests: ForgejoUITestBase { + + // MARK: - Notifications (unread, filter, swipe mark-read, swipe dismiss) + + @MainActor + func testNotifications() throws { + loginAndWaitForHome() + app.tabBars.buttons["Notifications"].tap() + + let notificationsList = app.collectionViews.firstMatch + XCTAssertTrue(notificationsList.waitForExistence(timeout: 10)) + XCTAssertTrue(app.staticTexts["testadmin/test-repo"].waitForExistence(timeout: 5)) + + // Filter by Read + let readButton = app.buttons["Read"] + XCTAssertTrue(readButton.waitForExistence(timeout: 5)) + readButton.tap() + + // Switch to All + let allButton = app.buttons["All"] + XCTAssertTrue(allButton.waitForExistence(timeout: 5)) + allButton.tap() + + // Switch back to Unread for swipe tests + let unreadButton = app.buttons["Unread"] + XCTAssertTrue(unreadButton.waitForExistence(timeout: 5)) + unreadButton.tap() + XCTAssertTrue(notificationsList.waitForExistence(timeout: 10)) + + // Swipe right to mark as read + let firstCell = notificationsList.cells.firstMatch + XCTAssertTrue(firstCell.waitForExistence(timeout: 5)) + firstCell.swipeRight() + + let markReadButton = app.buttons["Mark Read"] + if markReadButton.waitForExistence(timeout: 3) { + markReadButton.tap() + } + + // Swipe left to dismiss + let nextCell = notificationsList.cells.firstMatch + if nextCell.waitForExistence(timeout: 5) { + nextCell.swipeLeft() + let dismissButton = app.buttons["Dismiss"] + if dismissButton.waitForExistence(timeout: 3) { + dismissButton.tap() + } + } + } +} diff --git a/Forji/ForjiUITests/OverviewCreateMutatingUITests.swift b/Forji/ForjiUITests/OverviewCreateMutatingUITests.swift new file mode 100644 index 0000000..a5200e1 --- /dev/null +++ b/Forji/ForjiUITests/OverviewCreateMutatingUITests.swift @@ -0,0 +1,49 @@ +import XCTest + +final class OverviewCreateMutatingUITests: ForgejoUITestBase { + + // MARK: - Create Issue from Issues Overview (mutates — creates an issue) + + @MainActor + func testCreateIssueFromOverview() throws { + loginAndWaitForHome() + app.tabBars.buttons["Issues"].tap() + + // Wait for the issues list to load + XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 10), "Issues should load") + + // Tap floating create button + let createButton = app.buttons["issue-create-button"] + XCTAssertTrue(createButton.waitForExistence(timeout: 10), "Floating create button should exist on Issues overview") + createButton.tap() + + // Repository picker sheet should appear + XCTAssertTrue(app.staticTexts["Select Repository"].waitForExistence(timeout: 10), "Repository picker should appear") + + // Select test-repo via accessibility identifier on the button + let repoButton = app.buttons["repo-picker-testadmin/test-repo"] + XCTAssertTrue(repoButton.waitForExistence(timeout: 10), "test-repo should appear in the picker") + repoButton.tap() + + // Issue create form should appear after selecting a repo + XCTAssertTrue(app.staticTexts["New Issue"].waitForExistence(timeout: 15), "Issue create form should appear after selecting a repo") + + // Fill in the title + let titleField = app.textFields["issue-create-title-field"] + XCTAssertTrue(titleField.waitForExistence(timeout: 5)) + titleField.tap() + titleField.typeText("Issue created from overview") + + // Submit + let submitButton = app.buttons["issue-create-submit"] + XCTAssertTrue(submitButton.waitForExistence(timeout: 5)) + submitButton.tap() + + // After creation, the sheet dismisses and the issues list should refresh + // showing the newly created issue + XCTAssertTrue( + app.staticTexts["Issue created from overview"].waitForExistence(timeout: 15), + "Newly created issue should appear in the overview list" + ) + } +} diff --git a/Forji/ForjiUITests/OverviewCreateUITests.swift b/Forji/ForjiUITests/OverviewCreateUITests.swift new file mode 100644 index 0000000..828774b --- /dev/null +++ b/Forji/ForjiUITests/OverviewCreateUITests.swift @@ -0,0 +1,75 @@ +import XCTest + +final class OverviewCreateUITests: ForgejoReadOnlyUITestBase { + + override func tearDown() { + navigateBackToHome() + super.tearDown() + } + + // MARK: - Create PR from Pull Requests Overview + + @MainActor + func testCreatePRFromOverview() throws { + app.tabBars.buttons["Pull Requests"].tap() + + // Test PRs were created by testbot — switch scope to "All" to see them + setOverviewScopeAll() + + // Wait for the PR list to load + XCTAssertTrue(app.staticTexts["Add feature file"].waitForExistence(timeout: 10)) + + // Tap floating create button + let createButton = app.buttons["pr-create-button"] + XCTAssertTrue(createButton.waitForExistence(timeout: 10), "Floating create button should exist on Pull Requests overview") + createButton.tap() + + // Repository picker sheet should appear + XCTAssertTrue(app.staticTexts["Select Repository"].waitForExistence(timeout: 10), "Repository picker should appear") + + // Select test-repo via accessibility identifier on the button + let repoButton = app.buttons["repo-picker-testadmin/test-repo"] + XCTAssertTrue(repoButton.waitForExistence(timeout: 10), "test-repo should appear in the picker") + repoButton.tap() + + // PR create form should appear with branch pickers after selecting a repo + XCTAssertTrue(app.staticTexts["New Pull Request"].waitForExistence(timeout: 15), "PR create form should appear after selecting a repo") + XCTAssertTrue(app.staticTexts["Branches"].waitForExistence(timeout: 10), "Branch picker section should be visible") + + // Cancel and verify we return to the overview + let cancelButton = app.buttons["Cancel"] + XCTAssertTrue(cancelButton.waitForExistence(timeout: 5)) + cancelButton.tap() + + XCTAssertTrue( + app.staticTexts["Add feature file"].waitForExistence(timeout: 10), + "Should return to PR overview after cancelling" + ) + } + + // MARK: - Cancel Repository Picker + + @MainActor + func testCancelRepoPicker() throws { + app.tabBars.buttons["Issues"].tap() + + // Wait for the floating create button (confirms the overview rendered) + let createButton = app.buttons["issue-create-button"] + XCTAssertTrue(createButton.waitForExistence(timeout: 15), "Floating create button should exist on Issues overview") + createButton.tap() + + // Repository picker sheet should appear + XCTAssertTrue(app.staticTexts["Select Repository"].waitForExistence(timeout: 10)) + + // Cancel + let cancelButton = app.buttons["Cancel"] + XCTAssertTrue(cancelButton.waitForExistence(timeout: 5)) + cancelButton.tap() + + // Should return to the issues overview without opening the create form + XCTAssertTrue( + createButton.waitForExistence(timeout: 10), + "Should return to issues overview after cancelling repo picker" + ) + } +} diff --git a/Forji/ForjiUITests/PaginationUITests.swift b/Forji/ForjiUITests/PaginationUITests.swift new file mode 100644 index 0000000..9f4bae5 --- /dev/null +++ b/Forji/ForjiUITests/PaginationUITests.swift @@ -0,0 +1,95 @@ +import XCTest + +final class PaginationUITests: ForgejoReadOnlyUITestBase { + + override func tearDown() { + navigateBackToHome() + super.tearDown() + } + + // MARK: - Issue List Pagination + + @MainActor + func testIssueListPaginationLoadsAllItems() throws { + navigateToRepoDetail("test-repo-2") + app.buttons["Issues"].firstMatch.tap() + + // Wait for page 1 issues to load (25 issues in test-repo-2, page size 20) + XCTAssertTrue(app.staticTexts["Pagination test issue 25"].waitForExistence(timeout: 10), + "Should show recent issues on page 1") + + // Scroll to bottom to trigger page 2 auto-load + for _ in 0..<10 { + app.swipeUp() + } + + // Verify older issues from page 2 have loaded + let olderIssue = app.staticTexts["Pagination test issue 1"] + var found = false + for _ in 0..<5 { + if olderIssue.exists { + found = true + break + } + app.swipeUp() + // Wait for element to appear instead of fixed sleep + if olderIssue.waitForExistence(timeout: 2) { + found = true + break + } + } + XCTAssertTrue(found, "Page 2 issues should have loaded after scrolling to bottom") + } + + @MainActor + func testIssueListFilterResetsPagination() throws { + navigateToIssueTab() + + // Wait for open issues to load (test-repo has only 2 open issues) + XCTAssertTrue(app.staticTexts["Test issue 1 from integration tests"].waitForExistence(timeout: 10)) + + // Switch to Closed filter + let closedButton = app.buttons["Closed"] + XCTAssertTrue(closedButton.waitForExistence(timeout: 5)) + closedButton.tap() + + // Only 1 closed issue (#3) — should not show load-more + XCTAssertTrue(app.staticTexts["Test issue 3 from integration tests"].waitForExistence(timeout: 10)) + + let loadMore = app.activityIndicators["load-more-indicator"] + // Wait briefly for UI to settle, then check absence + _ = loadMore.waitForExistence(timeout: 2) + XCTAssertFalse(loadMore.exists, "load-more should not exist with <20 closed issues") + } + + // MARK: - Pull Request List (few items) + + @MainActor + func testPullRequestListNoLoadMoreWhenFewItems() throws { + navigateToPRTab() + + // Only 2 PRs exist + XCTAssertTrue(app.staticTexts["Add feature file"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.staticTexts["Merge test PR"].waitForExistence(timeout: 5)) + + let loadMore = app.activityIndicators["load-more-indicator"] + _ = loadMore.waitForExistence(timeout: 2) + XCTAssertFalse(loadMore.exists, "load-more should not exist when there are only 2 PRs") + } + + // MARK: - Notifications (few items) + + @MainActor + func testNotificationsLoadWithoutLoadMore() throws { + app.tabBars.buttons["Notifications"].tap() + + // Wait for notifications to load + let firstNotification = app.staticTexts["Test issue 1 from integration tests"] + XCTAssertTrue(firstNotification.waitForExistence(timeout: 15), + "Should show at least one notification") + + let loadMore = app.activityIndicators["load-more-indicator"] + _ = loadMore.waitForExistence(timeout: 2) + XCTAssertFalse(loadMore.exists, "load-more should not exist with few notifications") + } +} diff --git a/Forji/ForjiUITests/PermissionUITests.swift b/Forji/ForjiUITests/PermissionUITests.swift new file mode 100644 index 0000000..63c753e --- /dev/null +++ b/Forji/ForjiUITests/PermissionUITests.swift @@ -0,0 +1,80 @@ +import XCTest + +final class PermissionUITests: ForgejoUITestBase { + + override func setUpWithError() throws { + try super.setUpWithError() + + // Override credentials to use the read-only user + app = XCUIApplication() + app.launchArguments += [ + "-dev_serverURL", serverURL, + "-dev_username", "readonlyuser", + "-dev_password", "readonly1234", + ] + } + + // MARK: - Issue Detail — read-only user + + @MainActor + func testIssueDetailHidesActionsForReadOnlyUser() throws { + loginAndWaitForHome() + + navigateToIssueTab() + + let issueCell = app.staticTexts["Test issue 1 from integration tests"].firstMatch + XCTAssertTrue(issueCell.waitForExistence(timeout: 10)) + issueCell.tap() + + let title = app.staticTexts["issue-detail-title"] + XCTAssertTrue(title.waitForExistence(timeout: 10)) + + expandActionMenu() + + // Comment button should always be visible + let commentButton = app.buttons["issue-comment-button"] + XCTAssertTrue(commentButton.waitForExistence(timeout: 5), "Comment button should be visible for read-only user") + + // Edit and Close/Reopen should be hidden + let editButton = app.buttons["issue-edit-button"] + XCTAssertFalse(editButton.exists, "Edit button should be hidden for read-only user") + + let toggleStateButton = app.buttons["issue-toggle-state"] + XCTAssertFalse(toggleStateButton.exists, "Close/Reopen button should be hidden for read-only user") + } + + // MARK: - PR Detail — read-only user + + @MainActor + func testPRDetailHidesActionsForReadOnlyUser() throws { + loginAndWaitForHome() + + navigateToPRTab() + + let prCell = app.staticTexts["Add feature file"].firstMatch + XCTAssertTrue(prCell.waitForExistence(timeout: 10)) + prCell.tap() + + let title = app.staticTexts["pr-detail-title"] + XCTAssertTrue(title.waitForExistence(timeout: 10)) + + expandActionMenu() + + // Comment and Review should always be visible + let commentButton = app.buttons["pr-comment-button"] + XCTAssertTrue(commentButton.waitForExistence(timeout: 5), "Comment button should be visible for read-only user") + + let reviewButton = app.buttons["pr-submit-review"] + XCTAssertTrue(reviewButton.exists, "Review button should be visible for read-only user") + + // Edit, Merge, Close/Reopen should be hidden + let editButton = app.buttons["pr-edit-button"] + XCTAssertFalse(editButton.exists, "Edit button should be hidden for read-only user") + + let mergeButton = app.buttons["pr-merge-button"] + XCTAssertFalse(mergeButton.exists, "Merge button should be hidden for read-only user") + + let toggleStateButton = app.buttons["pr-toggle-state"] + XCTAssertFalse(toggleStateButton.exists, "Close/Reopen button should be hidden for read-only user") + } +} diff --git a/Forji/ForjiUITests/PullRequestMutatingUITests.swift b/Forji/ForjiUITests/PullRequestMutatingUITests.swift new file mode 100644 index 0000000..c2f97ea --- /dev/null +++ b/Forji/ForjiUITests/PullRequestMutatingUITests.swift @@ -0,0 +1,139 @@ +import XCTest + +final class PullRequestMutatingUITests: ForgejoUITestBase { + + // MARK: - PR Tab Actions (add comment, edit sheet, submit review, close/reopen) + + @MainActor + func testPRTabActions() throws { + loginAndWaitForHome() + navigateToPRTab() + + let prCell = app.staticTexts["Add feature file"].firstMatch + XCTAssertTrue(prCell.waitForExistence(timeout: 10)) + prCell.tap() + + XCTAssertTrue(app.staticTexts["pr-detail-title"].waitForExistence(timeout: 10)) + + // Add comment via sheet + expandActionMenu() + let commentButton = app.buttons["pr-comment-button"] + XCTAssertTrue(commentButton.waitForExistence(timeout: 5)) + commentButton.tap() + + XCTAssertTrue(app.staticTexts["New Comment"].waitForExistence(timeout: 5)) + + let commentEditor = app.textViews["markdown-text-editor"].firstMatch + XCTAssertTrue(commentEditor.waitForExistence(timeout: 5)) + commentEditor.tap() + commentEditor.typeText("UI test comment on PR") + + let submitButton = app.buttons["Submit"] + XCTAssertTrue(submitButton.waitForExistence(timeout: 5)) + submitButton.tap() + + // Wait for sheet to dismiss and scroll to bottom to find new comment + XCTAssertTrue(app.staticTexts["pr-detail-title"].waitForExistence(timeout: 10)) + app.swipeUp() + app.swipeUp() + XCTAssertTrue(app.staticTexts["UI test comment on PR"].waitForExistence(timeout: 10)) + + // Scroll back up + app.swipeDown() + app.swipeDown() + + // Edit sheet + expandActionMenu() + let editButton = app.buttons["pr-edit-button"] + XCTAssertTrue(editButton.waitForExistence(timeout: 5)) + editButton.tap() + + let titleField = app.textFields["pr-edit-title-field"] + XCTAssertTrue(titleField.waitForExistence(timeout: 5)) + app.buttons["Cancel"].tap() + XCTAssertTrue(app.staticTexts["pr-detail-title"].waitForExistence(timeout: 10)) + + // Submit review sheet + app.swipeUp() + + expandActionMenu() + let reviewButton = app.buttons["pr-submit-review"] + XCTAssertTrue(reviewButton.waitForExistence(timeout: 10)) + reviewButton.tap() + + XCTAssertTrue(app.staticTexts["Submit Review"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts["Approve"].waitForExistence(timeout: 5)) + app.buttons["Cancel"].tap() + + // Close and reopen + expandActionMenu() + let closeButton = app.buttons["pr-toggle-state"] + XCTAssertTrue(closeButton.waitForExistence(timeout: 10)) + closeButton.tap() + + app.swipeDown() + app.swipeDown() + XCTAssertTrue(app.staticTexts["Closed"].waitForExistence(timeout: 10)) + + app.swipeUp() + expandActionMenu() + let reopenButton = app.buttons["pr-toggle-state"] + XCTAssertTrue(reopenButton.waitForExistence(timeout: 5)) + reopenButton.tap() + + app.swipeDown() + app.swipeDown() + XCTAssertTrue(app.staticTexts["Open"].waitForExistence(timeout: 10)) + } + + // MARK: - PR Merge (separate — mutates PR state) + + @MainActor + func testPRMerge() throws { + loginAndWaitForHome() + navigateToPRTab() + + let prCell = app.staticTexts["Merge test PR"].firstMatch + XCTAssertTrue(prCell.waitForExistence(timeout: 10)) + prCell.tap() + + XCTAssertTrue(app.staticTexts["pr-detail-title"].waitForExistence(timeout: 10)) + + app.swipeUp() + + expandActionMenu() + let mergeButton = app.buttons["pr-merge-button"] + XCTAssertTrue(mergeButton.waitForExistence(timeout: 10)) + mergeButton.tap() + + XCTAssertTrue(app.staticTexts["Merge Pull Request"].waitForExistence(timeout: 5)) + + let methodPicker = app.segmentedControls["merge-method-picker"] + XCTAssertTrue(methodPicker.waitForExistence(timeout: 5)) + + let deleteBranch = app.switches["merge-delete-branch"] + XCTAssertTrue(deleteBranch.waitForExistence(timeout: 5)) + + let mergeConfirm = app.buttons["merge-confirm"] + XCTAssertTrue(mergeConfirm.waitForExistence(timeout: 5)) + mergeConfirm.tap() + + // Check if the merge sheet was dismissed (success) or still showing (error) + let mergeSheetTitle = app.staticTexts["Merge Pull Request"] + if mergeSheetTitle.waitForExistence(timeout: 5) { + let errorAlert = app.alerts.firstMatch + if errorAlert.exists { + let alertText = errorAlert.staticTexts.allElementsBoundByIndex.map { $0.label }.joined(separator: " | ") + XCTFail("Merge failed with error alert: \(alertText)") + } else { + XCTFail("Merge sheet still visible but no error alert — merge may be in progress") + } + return + } + + XCTAssertTrue( + app.staticTexts["pr-detail-title"].waitForExistence(timeout: 10), + "Should return to PR detail after successful merge" + ) + } +} diff --git a/Forji/ForjiUITests/PullRequestUITests.swift b/Forji/ForjiUITests/PullRequestUITests.swift new file mode 100644 index 0000000..622b030 --- /dev/null +++ b/Forji/ForjiUITests/PullRequestUITests.swift @@ -0,0 +1,153 @@ +import XCTest + +final class PullRequestUITests: ForgejoReadOnlyUITestBase { + + override func tearDown() { + navigateBackToHome() + super.tearDown() + } + + // MARK: - PR Tab Content (list, filter, detail, review, diff, comment) + + @MainActor + func testPRTabContent() throws { + navigateToPRTab() + + // PR list shows open PRs + XCTAssertTrue(app.staticTexts["Add feature file"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.staticTexts["Merge test PR"].waitForExistence(timeout: 5)) + + // Filter by Closed + let closedButton = app.buttons["Closed"] + XCTAssertTrue(closedButton.waitForExistence(timeout: 10)) + closedButton.tap() + XCTAssertTrue(app.staticTexts["No Pull Requests"].waitForExistence(timeout: 10)) + + // Back to Open + let openButton = app.buttons["Open"] + XCTAssertTrue(openButton.waitForExistence(timeout: 5)) + openButton.tap() + XCTAssertTrue(app.staticTexts["Add feature file"].waitForExistence(timeout: 10)) + + // PR detail — title, branches + let prCell = app.staticTexts["Add feature file"].firstMatch + prCell.tap() + + let titleText = app.staticTexts["pr-detail-title"] + XCTAssertTrue(titleText.waitForExistence(timeout: 10)) + XCTAssertTrue(app.staticTexts["feature-branch"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts["main"].waitForExistence(timeout: 5)) + + // Milestone and assignee (adjacent sections, both near top of list) + XCTAssertTrue(app.staticTexts["v1.0"].waitForExistence(timeout: 5), "PR milestone should be visible") + XCTAssertTrue(app.staticTexts["@testadmin"].waitForExistence(timeout: 10), "PR assignee should be visible") + + // Review (may need scroll to reach review section) + app.swipeUp() + XCTAssertTrue(app.staticTexts["commented"].waitForExistence(timeout: 15)) + + // Diff shows feature.txt (scroll down past description and reviews) + app.swipeUp() + XCTAssertTrue(app.staticTexts["feature.txt"].waitForExistence(timeout: 10)) + + // Scroll down to see comment from testbot + app.swipeUp() + XCTAssertTrue(app.staticTexts["testbot"].waitForExistence(timeout: 10)) + } + + // MARK: - Diff View — context line has no comment button + + @MainActor + func testDiffViewContextLineHasNoCommentButton() throws { + navigateToPRTab() + + let prCell = app.staticTexts["Add feature file"].firstMatch + XCTAssertTrue(prCell.waitForExistence(timeout: 10)) + prCell.tap() + + XCTAssertTrue(app.staticTexts["pr-detail-title"].waitForExistence(timeout: 10)) + + // Open review sheet to see the diff with inline comment support + app.swipeUp() + + expandActionMenu() + let reviewButton = app.buttons["pr-submit-review"] + XCTAssertTrue(reviewButton.waitForExistence(timeout: 10)) + reviewButton.tap() + + XCTAssertTrue(app.staticTexts["Submit Review"].waitForExistence(timeout: 5)) + + // The diff should show feature.txt + XCTAssertTrue(app.staticTexts["feature.txt"].waitForExistence(timeout: 10)) + + // Dismiss + app.buttons["Cancel"].tap() + } + + // MARK: - Pull Requests Overview (all PRs, filter, search) + + @MainActor + func testPullRequestsOverview() throws { + app.tabBars.buttons["Pull Requests"].tap() + + // Test PRs were created by testbot — switch scope to "All" to see them + setOverviewScopeAll() + + XCTAssertTrue(app.staticTexts["Add feature file"].waitForExistence(timeout: 10)) + + // Filter state to All via toolbar menu + let filterMenuButton = app.buttons["filter-menu-button"] + XCTAssertTrue(filterMenuButton.waitForExistence(timeout: 5)) + filterMenuButton.tap() + app.buttons["All"].firstMatch.tap() + + // Search + let searchField = app.searchFields.firstMatch + XCTAssertTrue(searchField.waitForExistence(timeout: 5)) + searchField.tap() + searchField.typeText("feature") + + XCTAssertTrue(app.staticTexts["Add feature file"].waitForExistence(timeout: 10)) + } + + // MARK: - Pull Requests Overview Involvement Filter + + @MainActor + func testPullRequestsOverviewInvolvementFilter() throws { + app.tabBars.buttons["Pull Requests"].tap() + + // Reset filters to defaults (previous test may have changed them) + resetOverviewFilters() + + // Default filter summary should show "Open · Created by you" + let filterSummary = app.staticTexts["filter-summary"] + XCTAssertTrue(filterSummary.waitForExistence(timeout: 5)) + XCTAssertEqual(filterSummary.label, "Open · Created by you") + + let filterMenuButton = app.buttons["filter-menu-button"] + + // Tap "Assigned to you" via filter menu + filterMenuButton.tap() + app.buttons["Assigned to you"].tap() + sleep(2) + XCTAssertTrue(filterSummary.label.contains("Assigned to you")) + + // Tap "Mentioned" via filter menu + filterMenuButton.tap() + app.buttons["Mentioned"].tap() + sleep(2) + XCTAssertTrue(filterSummary.label.contains("Mentioned")) + + // Tap back to "All" scope via filter menu + filterMenuButton.tap() + app.buttons["All"].firstMatch.tap() + sleep(2) + + // "Review requested" SHOULD be present for PRs + filterMenuButton.tap() + let reviewButton = app.buttons["Review requested"] + XCTAssertTrue(reviewButton.exists, "Review filter should appear for pull requests") + // Dismiss the menu by tapping elsewhere + app.tap() + } +} diff --git a/Forji/ForjiUITests/RepositoryMutatingUITests.swift b/Forji/ForjiUITests/RepositoryMutatingUITests.swift new file mode 100644 index 0000000..cf0d462 --- /dev/null +++ b/Forji/ForjiUITests/RepositoryMutatingUITests.swift @@ -0,0 +1,73 @@ +import XCTest + +final class RepositoryMutatingUITests: ForgejoUITestBase { + + // MARK: - Code Browser & File Viewer (mutates repo via file edit + commit) + + @MainActor + func testCodeBrowserAndFileViewer() throws { + loginAndWaitForHome() + navigateToRepoDetail() + + // Code tab shows files + XCTAssertTrue(app.staticTexts["README.md"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.staticTexts["hello.py"].waitForExistence(timeout: 5)) + + // Navigate into src directory + let srcFolder = app.staticTexts["src"].firstMatch + XCTAssertTrue(srcFolder.waitForExistence(timeout: 10)) + srcFolder.tap() + + XCTAssertTrue(app.staticTexts["main.py"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.buttons["test-repo"].waitForExistence(timeout: 5)) + + // Breadcrumb back to root + let rootBreadcrumb = app.buttons["test-repo"] + rootBreadcrumb.tap() + XCTAssertTrue(app.staticTexts["hello.py"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.staticTexts["README.md"].waitForExistence(timeout: 5)) + + // Scroll toggle button + let toggleButton = app.buttons["code-scroll-toggle"] + XCTAssertTrue(toggleButton.waitForExistence(timeout: 5)) + toggleButton.tap() + toggleButton.tap() + + // File viewer — tap hello.py + let fileCell = app.staticTexts["hello.py"].firstMatch + XCTAssertTrue(fileCell.waitForExistence(timeout: 10)) + fileCell.tap() + + let editButton = app.buttons["file-edit-button"] + XCTAssertTrue(editButton.waitForExistence(timeout: 10)) + + // Enter edit mode + editButton.tap() + + let cancelButton = app.buttons["file-edit-cancel"] + XCTAssertTrue(cancelButton.waitForExistence(timeout: 5)) + cancelButton.tap() + XCTAssertTrue(editButton.waitForExistence(timeout: 5)) + + // Edit and commit flow + editButton.tap() + + let textEditor = app.textViews.firstMatch + XCTAssertTrue(textEditor.waitForExistence(timeout: 5)) + textEditor.tap() + textEditor.typeText("\n# UI test edit") + + let commitButton = app.buttons["file-edit-commit"] + XCTAssertTrue(commitButton.waitForExistence(timeout: 5)) + commitButton.tap() + + let commitMessageField = app.textFields["commit-message-field"] + XCTAssertTrue(commitMessageField.waitForExistence(timeout: 5)) + + let commitSubmit = app.buttons["commit-submit"] + XCTAssertTrue(commitSubmit.waitForExistence(timeout: 5)) + commitSubmit.tap() + + XCTAssertTrue(editButton.waitForExistence(timeout: 10)) + } +} diff --git a/Forji/ForjiUITests/RepositoryUITests.swift b/Forji/ForjiUITests/RepositoryUITests.swift new file mode 100644 index 0000000..455bb98 --- /dev/null +++ b/Forji/ForjiUITests/RepositoryUITests.swift @@ -0,0 +1,86 @@ +import XCTest + +final class RepositoryUITests: ForgejoReadOnlyUITestBase { + + // MARK: - Repository List (repos, star, filter, search) + + @MainActor + func testRepositoryList() throws { + app.tabBars.buttons["Repositories"].tap() + + let repoList = app.collectionViews["repo-list"] + XCTAssertTrue(repoList.waitForExistence(timeout: 10)) + + // Both repos visible + XCTAssertTrue(app.staticTexts["test-repo"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts["test-repo-2"].waitForExistence(timeout: 5)) + + // Filter by Starred + let starredButton = app.buttons["Starred"] + XCTAssertTrue(starredButton.waitForExistence(timeout: 5)) + starredButton.tap() + + // Switch back to All + let allButton = app.buttons["All"] + XCTAssertTrue(allButton.waitForExistence(timeout: 5)) + allButton.tap() + XCTAssertTrue(app.staticTexts["test-repo"].waitForExistence(timeout: 10)) + + // Search + let searchField = app.searchFields.firstMatch + XCTAssertTrue(searchField.waitForExistence(timeout: 5)) + searchField.tap() + searchField.typeText("test-repo-2") + + XCTAssertTrue(app.staticTexts["test-repo-2"].waitForExistence(timeout: 10)) + + // Clear search to restore full list + searchField.buttons["Clear text"].tap() + XCTAssertTrue(app.staticTexts["test-repo"].waitForExistence(timeout: 10)) + + // Star toggle (last — tap triggers NavigationLink, only tests tappability) + let starButton = repoList.buttons["star-button"].firstMatch + XCTAssertTrue(starButton.waitForExistence(timeout: 5), "No star button found in repo list") + starButton.tap() + } + + override func tearDown() { + navigateBackToHome() + super.tearDown() + } + + // MARK: - Branch Selector + + @MainActor + func testBranchSelector() throws { + navigateToRepoDetail() + + // Branch selector should exist and show "main" by default + let branchSelector = app.buttons["branch-selector"] + XCTAssertTrue(branchSelector.waitForExistence(timeout: 10), "Branch selector not found") + XCTAssertTrue(branchSelector.label.contains("main"), "Branch selector should show 'main' initially") + + // Verify main branch files are visible + XCTAssertTrue(app.staticTexts["hello.py"].waitForExistence(timeout: 10)) + + // Switch to feature-branch via branch picker sheet + branchSelector.tap() + let featureBranchOption = app.buttons["branch-option-feature-branch"] + XCTAssertTrue(featureBranchOption.waitForExistence(timeout: 5), "feature-branch option not found") + featureBranchOption.tap() + + // feature.txt should appear (only exists on feature-branch) + XCTAssertTrue(app.staticTexts["feature.txt"].waitForExistence(timeout: 10), "feature.txt should be visible on feature-branch") + + // Switch back to main + let updatedSelector = app.buttons["branch-selector"] + XCTAssertTrue(updatedSelector.waitForExistence(timeout: 5)) + updatedSelector.tap() + let mainBranchOption = app.buttons["branch-option-main"] + XCTAssertTrue(mainBranchOption.waitForExistence(timeout: 5), "main option not found") + mainBranchOption.tap() + + // feature.txt should no longer be visible, hello.py should be back + XCTAssertTrue(app.staticTexts["hello.py"].waitForExistence(timeout: 10), "hello.py should be visible on main") + } +} diff --git a/Forji/ForjiUITests/UITestNavigating.swift b/Forji/ForjiUITests/UITestNavigating.swift new file mode 100644 index 0000000..b159d1d --- /dev/null +++ b/Forji/ForjiUITests/UITestNavigating.swift @@ -0,0 +1,66 @@ +import XCTest + +/// Shared navigation helpers for UI test base classes. +/// Each conforming class provides its own `app` and `navigateToRepoDetail` +/// implementation; the protocol extension supplies the rest. +protocol UITestNavigating { + var app: XCUIApplication! { get } + func navigateToRepoDetail(_ repoName: String) +} + +extension UITestNavigating { + + func navigateToRepoDetail() { + navigateToRepoDetail("test-repo") + } + + func navigateToIssueTab() { + navigateToRepoDetail() + let picker = app.segmentedControls["repo-detail-tab-picker"] + XCTAssertTrue(picker.waitForExistence(timeout: 10)) + picker.buttons["Issues"].tap() + } + + func navigateToPRTab() { + navigateToRepoDetail() + let picker = app.segmentedControls["repo-detail-tab-picker"] + XCTAssertTrue(picker.waitForExistence(timeout: 10)) + picker.buttons["Pull Requests"].tap() + } + + /// Resets the overview filter to Open + Created by you (the app defaults). + func resetOverviewFilters() { + let filterMenuButton = app.buttons["filter-menu-button"] + guard filterMenuButton.waitForExistence(timeout: 5) else { return } + filterMenuButton.tap() + app.buttons["Open"].tap() + sleep(1) + filterMenuButton.tap() + app.buttons["Created by you"].tap() + sleep(1) + } + + /// Switches the overview scope to "All" to show items from all users. + /// The Scope "All" is the second "All" button in the filter menu + /// (the first is from the State section). + func setOverviewScopeAll() { + let filterMenuButton = app.buttons["filter-menu-button"] + guard filterMenuButton.waitForExistence(timeout: 5) else { return } + filterMenuButton.tap() + let allButtons = app.buttons.matching(identifier: "All") + if allButtons.count >= 2 { + allButtons.element(boundBy: 1).tap() + } else { + allButtons.firstMatch.tap() + } + sleep(1) + } + + func expandActionMenu() { + let toggle = app.buttons["action-menu-toggle"] + XCTAssertTrue(toggle.waitForExistence(timeout: 10), "Action menu toggle not found") + if toggle.value as? String != "expanded" { + toggle.tap() + } + } +} diff --git a/Forji/Info.plist b/Forji/Info.plist new file mode 100644 index 0000000..6a6654d --- /dev/null +++ b/Forji/Info.plist @@ -0,0 +1,11 @@ + + + + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..59bd55d --- /dev/null +++ b/LICENSE @@ -0,0 +1,716 @@ +Forji — A native iOS/macOS client for Forgejo +Copyright (C) 2026 Stefan Hausotte + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + + +## App Store Exception + +As a special exception to the terms of the GNU General Public License +version 3, you are granted additional permission to convey the compiled +application through Apple's App Store, under Apple's standard App Store +terms of service, provided that: + +1. The complete Corresponding Source of the application remains freely + available under the terms of the GNU General Public License version 3 + (or later) at the project's public repository. +2. This exception applies solely to distribution through Apple's App + Store and does not modify the terms of the GNU General Public License + in any other respect. +3. All other rights and obligations under the GNU General Public License + version 3 remain in full effect. + +This exception is granted under Section 7 of the GNU General Public +License version 3, which permits additional permissions to be added to +covered works. + +By contributing to this project, you agree that your contributions may +be distributed through Apple's App Store under this exception. + + +# GNU GENERAL PUBLIC LICENSE + +Version 3, 29 June 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. + + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + +## Preamble + +The GNU General Public License is a free, copyleft license for +software and other kinds of works. + +The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom +to share and change all versions of a program--to make sure it remains +free software for all its users. We, the Free Software Foundation, use +the GNU General Public License for most of our software; it applies +also to any other work released this way by its authors. You can apply +it to your programs, too. + +When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + +To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you +have certain responsibilities if you distribute copies of the +software, or if you modify it: responsibilities to respect the freedom +of others. + +For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + +Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + +For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + +Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the +manufacturer can do so. This is fundamentally incompatible with the +aim of protecting users' freedom to change the software. The +systematic pattern of such abuse occurs in the area of products for +individuals to use, which is precisely where it is most unacceptable. +Therefore, we have designed this version of the GPL to prohibit the +practice for those products. If such problems arise substantially in +other domains, we stand ready to extend this provision to those +domains in future versions of the GPL, as needed to protect the +freedom of users. + +Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish +to avoid the special danger that patents applied to a free program +could make it effectively proprietary. To prevent this, the GPL +assures that patents cannot be used to render the program non-free. + +The precise terms and conditions for copying, distribution and +modification follow. + +## TERMS AND CONDITIONS + +### 0. Definitions. + +"This License" refers to version 3 of the GNU General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds +of works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of +an exact copy. The resulting work is called a "modified version" of +the earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based +on the Program. + +To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user +through a computer network, with no transfer of a copy, is not +conveying. + +An interactive user interface displays "Appropriate Legal Notices" to +the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + +### 1. Source Code. + +The "source code" for a work means the preferred form of the work for +making modifications to it. "Object code" means any non-source form of +a work. + +A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can +regenerate automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same +work. + +### 2. Basic Permissions. + +All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not convey, +without conditions so long as your license otherwise remains in force. +You may convey covered works to others for the sole purpose of having +them make modifications exclusively for you, or provide you with +facilities for running those works, provided that you comply with the +terms of this License in conveying all material for which you do not +control copyright. Those thus making or running the covered works for +you must do so exclusively on your behalf, under your direction and +control, on terms that prohibit them from making any copies of your +copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes +it unnecessary. + +### 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + +No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such +circumvention is effected by exercising rights under this License with +respect to the covered work, and you disclaim any intention to limit +operation or modification of the work as a means of enforcing, against +the work's users, your or third parties' legal rights to forbid +circumvention of technological measures. + +### 4. Conveying Verbatim Copies. + +You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + +### 5. Conveying Modified Source Versions. + +You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these +conditions: + +- a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. +- b) The work must carry prominent notices stating that it is + released under this License and any conditions added under + section 7. This requirement modifies the requirement in section 4 + to "keep intact all notices". +- c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. +- d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + +A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + +### 6. Conveying Non-Source Forms. + +You may convey a covered work in object code form under the terms of +sections 4 and 5, provided that you also convey the machine-readable +Corresponding Source under the terms of this License, in one of these +ways: + +- a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. +- b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the Corresponding + Source from a network server at no charge. +- c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. +- d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. +- e) Convey the object code using peer-to-peer transmission, + provided you inform other peers where the object code and + Corresponding Source of the work are being offered to the general + public at no charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, +family, or household purposes, or (2) anything designed or sold for +incorporation into a dwelling. In determining whether a product is a +consumer product, doubtful cases shall be resolved in favor of +coverage. For a particular product received by a particular user, +"normally used" refers to a typical or common use of that class of +product, regardless of the status of the particular user or of the way +in which the particular user actually uses, or expects or is expected +to use, the product. A product is a consumer product regardless of +whether the product has substantial commercial, industrial or +non-consumer uses, unless such uses represent the only significant +mode of use of the product. + +"Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to +install and execute modified versions of a covered work in that User +Product from a modified version of its Corresponding Source. The +information must suffice to ensure that the continued functioning of +the modified object code is in no case prevented or interfered with +solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or +updates for a work that has been modified or installed by the +recipient, or for the User Product in which it has been modified or +installed. Access to a network may be denied when the modification +itself materially and adversely affects the operation of the network +or violates the rules and protocols for communication across the +network. + +Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + +### 7. Additional Terms. + +"Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders +of that material) supplement the terms of this License with terms: + +- a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or +- b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or +- c) Prohibiting misrepresentation of the origin of that material, + or requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or +- d) Limiting the use for publicity purposes of names of licensors + or authors of the material; or +- e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or +- f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions + of it) with contractual assumptions of liability to the recipient, + for any liability that these contractual assumptions directly + impose on those licensors and authors. + +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; the +above requirements apply either way. + +### 8. Termination. + +You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + +However, if you cease all violation of this License, then your license +from a particular copyright holder is reinstated (a) provisionally, +unless and until the copyright holder explicitly and finally +terminates your license, and (b) permanently, if the copyright holder +fails to notify you of the violation by some reasonable means prior to +60 days after the cessation. + +Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + +Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + +### 9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or run +a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + +### 10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + +### 11. Patents. + +A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned +or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + +If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + +A patent license is "discriminatory" if it does not include within the +scope of its coverage, prohibits the exercise of, or is conditioned on +the non-exercise of one or more of the rights that are specifically +granted under this License. You may not convey a covered work if you +are a party to an arrangement with a third party that is in the +business of distributing software, under which you make payment to the +third party based on the extent of your activity of conveying the +work, and under which the third party grants, to any of the parties +who would receive the covered work from you, a discriminatory patent +license (a) in connection with copies of the covered work conveyed by +you (or copies made from those copies), or (b) primarily for and in +connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + +### 12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under +this License and any other pertinent obligations, then as a +consequence you may not convey it at all. For example, if you agree to +terms that obligate you to collect a royalty for further conveying +from those to whom you convey the Program, the only way you could +satisfy both those terms and this License would be to refrain entirely +from conveying the Program. + +### 13. Use with the GNU Affero General Public License. + +Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + +### 14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions +of the GNU General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in +detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU General Public +License "or any later version" applies to it, you have the option of +following the terms and conditions either of that numbered version or +of any later version published by the Free Software Foundation. If the +Program does not specify a version number of the GNU General Public +License, you may choose any version ever published by the Free +Software Foundation. + +If the Program specifies that a proxy can decide which future versions +of the GNU General Public License can be used, that proxy's public +statement of acceptance of a version permanently authorizes you to +choose that version for the Program. + +Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + +### 15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT +WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND +PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE +DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR +CORRECTION. + +### 16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR +CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT +NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR +LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM +TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER +PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +### 17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + +END OF TERMS AND CONDITIONS + +## How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these +terms. + +To do so, attach the following notices to the program. It is safest to +attach them to the start of each source file to most effectively state +the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper +mail. + +If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands \`show w' and \`show c' should show the +appropriate parts of the General Public License. Of course, your +program's commands might be different; for a GUI interface, you would +use an "about box". + +You should also get your employer (if you work as a programmer) or +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. For more information on this, and how to apply and follow +the GNU GPL, see . + +The GNU General Public License does not permit incorporating your +program into proprietary programs. If your program is a subroutine +library, you may consider it more useful to permit linking proprietary +applications with the library. If this is what you want to do, use the +GNU Lesser General Public License instead of this License. But first, +please read . diff --git a/README.md b/README.md new file mode 100644 index 0000000..747ee73 --- /dev/null +++ b/README.md @@ -0,0 +1,115 @@ +# Forji + +A native iOS client for [Forgejo](https://forgejo.org) + +--- + +Forji is a native iOS app for managing your Forgejo repositories, issues, pull requests, and notifications from your phone. Built with SwiftUI and Swift concurrency. + +## Screenshots + +![Repositories](screenshots/01_repositories.png) +![Code browser](screenshots/02_code_browser.png) +![Pull request detail](screenshots/03_pull_request.png) +![Notifications](screenshots/04_notifications.png) + +## Features + +### Repositories +- Browse and search your repositories +- Filter by All or Starred +- Star/unstar repositories +- View repository stats (stars, forks, language, open issues) + +### Code Browser +- Navigate file trees +- View files with syntax highlighting +- Render Markdown previews +- Edit files and commit changes directly +- Browse commit history with diffs +- Switch branches + +### Issues +- View issues across all repositories or per-repo +- Filter by open/closed state and search +- Create, edit, and close/reopen issues +- Manage labels, milestones, and assignees +- Comment with Markdown support + +### Pull Requests +- View PRs across all repositories or per-repo +- Create PRs with branch selection, labels, milestones, assignees, and reviewers +- Review diffs with inline comments +- Submit reviews (comment, approve, request changes) +- Merge with merge commit, rebase, or squash +- Close, reopen, and edit PRs + +### Notifications +- View unread, read, and all notifications +- Swipe to mark as read or dismiss +- Unread badge on the tab bar + +### Other +- Connect to any Forgejo instance (including self-hosted) +- Self-signed certificate support +- Multiple instance management +- Light, dark, and system theme +- Mermaid diagram rendering + +## Development + +Requires Xcode 26. The project uses [ForgejoKit](https://codeberg.org/secana/ForgejoKit) as a remote Swift package dependency. + +### Dev Environment + +A [Nix flake](flake.nix) provides all CLI tooling (`just`, `xcbeautify`, `swiftlint`, `swiftformat`): + +```bash +nix develop # Enter dev shell with all tools available +``` + +### Building & Testing + +A [justfile](https://github.com/casey/just) is provided for common tasks: + +```bash +just build # Build the app +just test # Run app unit tests +just run # Build, install, and launch in the simulator +just lint # Lint Swift code with SwiftLint +just format # Format Swift code with SwiftFormat +just test-ui # Run UI integration tests (requires Docker) +just test-one Class # Run a single integration test +just test-list # List all available integration tests +``` + +Integration tests spin up Forgejo instances in Docker, seed test data, and run UI tests against them. See [integration/](integration/) for details. + +## Architecture + +| Layer | Location | Purpose | +|-------|----------|---------| +| **[ForgejoKit](https://codeberg.org/secana/ForgejoKit)** | Remote SPM package | Platform-agnostic Swift package with all Forgejo API logic, models, and services | +| **Forji** | `Forji/` | SwiftUI iOS app with views, authentication, and persistence | + +ForgejoKit has no SwiftUI or platform dependencies, making it reusable for other clients (macOS, CLI, etc.). It is published as a separate package at [codeberg.org/secana/ForgejoKit](https://codeberg.org/secana/ForgejoKit). + +## Acknowledgments + +The Forji logo is based on the [Forgejo logo](https://codeberg.org/forgejo/forgejo) by Caesar Schinas and is licensed under [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/). + +### Libraries + +- [ForgejoKit](https://codeberg.org/secana/ForgejoKit) — Forgejo API client (MIT) +- [Textual](https://github.com/gonzalezreal/textual) — Markdown rendering (MIT) +- [HighlightSwift](https://github.com/appstefan/HighlightSwift) — Code syntax highlighting (MIT) + +## Contributing + +Contributions are welcome! By submitting a pull request, you agree that your contributions are licensed under the [GNU General Public License v3.0](LICENSE) and may be distributed through Apple's App Store under the [App Store Exception](LICENSE#app-store-exception) included in the license. + +## License + +Forji is licensed under the [GNU General Public License v3.0](LICENSE) with an [App Store Exception](LICENSE#app-store-exception) that permits distribution of the compiled application through Apple's App Store. + +ForgejoKit is licensed under the [MIT License](https://codeberg.org/secana/ForgejoKit/src/branch/main/LICENSE). diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..21cf7ce --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1771207753, + "narHash": "sha256-b9uG8yN50DRQ6A7JdZBfzq718ryYrlmGgqkRm9OOwCE=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "d1c15b7d5806069da59e819999d70e1cec0760bf", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..20edbe7 --- /dev/null +++ b/flake.nix @@ -0,0 +1,24 @@ +{ + description = "Forji — native iOS/macOS client for Forgejo"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { nixpkgs, flake-utils, ... }: + flake-utils.lib.eachSystem [ "aarch64-darwin" "x86_64-darwin" ] (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + { + devShells.default = pkgs.mkShellNoCC { + packages = [ + pkgs.just + pkgs.xcbeautify + pkgs.swiftlint + pkgs.swiftformat + ]; + }; + }); +} diff --git a/integration/.forgejo-seed-hash b/integration/.forgejo-seed-hash new file mode 100644 index 0000000..d49875f --- /dev/null +++ b/integration/.forgejo-seed-hash @@ -0,0 +1 @@ +11b89bd63fe2a77529f2f8124efafaadcf4deccb435ae43957501aab0795fffd diff --git a/integration/.forgejo-seed-snapshot.tar.gz b/integration/.forgejo-seed-snapshot.tar.gz new file mode 100644 index 0000000..2d67bbf Binary files /dev/null and b/integration/.forgejo-seed-snapshot.tar.gz differ diff --git a/integration/docker-compose.yml b/integration/docker-compose.yml new file mode 100644 index 0000000..53d60de --- /dev/null +++ b/integration/docker-compose.yml @@ -0,0 +1,38 @@ +services: + forgejo-1: + image: codeberg.org/forgejo/forgejo:14 + ports: + - "13001:3000" + environment: + - FORGEJO__security__INSTALL_LOCK=true + - FORGEJO__service__DISABLE_REGISTRATION=true + - FORGEJO__server__ROOT_URL=http://localhost:13001 + volumes: + - forgejo-1-data:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/api/v1/version"] + interval: 5s + timeout: 3s + retries: 12 + start_period: 10s + + forgejo-2: + image: codeberg.org/forgejo/forgejo:14 + ports: + - "13002:3000" + environment: + - FORGEJO__security__INSTALL_LOCK=true + - FORGEJO__service__DISABLE_REGISTRATION=true + - FORGEJO__server__ROOT_URL=http://localhost:13002 + volumes: + - forgejo-2-data:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/api/v1/version"] + interval: 5s + timeout: 3s + retries: 12 + start_period: 10s + +volumes: + forgejo-1-data: + forgejo-2-data: diff --git a/integration/setup.sh b/integration/setup.sh new file mode 100755 index 0000000..0659fac --- /dev/null +++ b/integration/setup.sh @@ -0,0 +1,207 @@ +#!/usr/bin/env bash +set -eo pipefail + +SCRIPT_DIR="$(dirname "$0")" +COMPOSE_FILE="$SCRIPT_DIR/docker-compose.yml" +BASE_URL="${1:-http://localhost:13001}" +SERVICE_NAME="${2:-forgejo-1}" +ADMIN_USER="testadmin" +ADMIN_PASS="admin1234" +ADMIN_AUTH="$ADMIN_USER:$ADMIN_PASS" +TESTBOT_AUTH="testbot:testbot1234" + +# --- Phase 1: Create users (sequential — prerequisite for everything) --- +echo "Creating admin user..." +docker compose -f "$COMPOSE_FILE" exec -T -u git "$SERVICE_NAME" \ + forgejo admin user create --admin \ + --username "$ADMIN_USER" \ + --password "$ADMIN_PASS" \ + --email testadmin@test.local \ + --must-change-password=false + +echo "Creating testbot user..." +docker compose -f "$COMPOSE_FILE" exec -T -u git "$SERVICE_NAME" \ + forgejo admin user create \ + --username testbot \ + --password testbot1234 \ + --email testbot@test.local \ + --must-change-password=false + +echo "Creating readonlyuser..." +docker compose -f "$COMPOSE_FILE" exec -T -u git "$SERVICE_NAME" \ + forgejo admin user create \ + --username readonlyuser \ + --password readonly1234 \ + --email readonlyuser@test.local \ + --must-change-password=false + +# --- Phase 2: Create both repos (parallel) --- +echo "Creating repositories..." +curl -sf -X POST "$BASE_URL/api/v1/user/repos" \ + -u "$ADMIN_AUTH" \ + -H "Content-Type: application/json" \ + -d '{"name": "test-repo", "private": false, "auto_init": true}' & + +curl -sf -X POST "$BASE_URL/api/v1/user/repos" \ + -u "$ADMIN_AUTH" \ + -H "Content-Type: application/json" \ + -d '{"name": "test-repo-2", "description": "A second test repository", "private": false, "auto_init": true}' & + +wait +echo "Repositories created." + +# --- Phase 3: Create issues + code files + pagination issues (parallel) --- + +# Create 25 pagination issues sequentially in a background subshell +# (sequential for deterministic issue numbering, background to overlap with other work) +echo "Creating pagination test issues..." +(for i in $(seq 1 25); do + curl -sf -X POST "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo-2/issues" \ + -u "$ADMIN_AUTH" \ + -H "Content-Type: application/json" \ + -d "{\"title\": \"Pagination test issue $i\"}" +done) & + +# Create issues 1-3 as testbot (generates notifications for testadmin) +echo "Creating issues as testbot..." +for i in 1 2 3; do + curl -sf -X POST "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/issues" \ + -u "$TESTBOT_AUTH" \ + -H "Content-Type: application/json" \ + -d "{\"title\": \"Test issue $i from integration tests\"}" +done + +# Phase 3b: Independent operations on test-repo (parallel) +echo "Setting up test-repo content..." + +# Comment on issue #1 +curl -sf -X POST "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/issues/1/comments" \ + -u "$TESTBOT_AUTH" \ + -H "Content-Type: application/json" \ + -d '{"body": "This is a test comment from testbot"}' & + +# Add body text + close issue #3 in one PATCH +curl -sf -X PATCH "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/issues/3" \ + -u "$ADMIN_AUTH" \ + -H "Content-Type: application/json" \ + -d '{"body": "This is the **markdown** body for issue 3.\n\nIt has multiple paragraphs.", "state": "closed"}' & + +# Body for issue #1 +curl -sf -X PATCH "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/issues/1" \ + -u "$ADMIN_AUTH" \ + -H "Content-Type: application/json" \ + -d '{"body": "This is the **markdown** body for issue 1.\n\nIt has multiple paragraphs."}' & + +# Body for issue #2 +curl -sf -X PATCH "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/issues/2" \ + -u "$ADMIN_AUTH" \ + -H "Content-Type: application/json" \ + -d '{"body": "This is the **markdown** body for issue 2.\n\nIt has multiple paragraphs."}' & + +# Code files (sequential — both commit to same branch) +curl -sf -X POST "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/contents/hello.py" \ + -u "$ADMIN_AUTH" \ + -H "Content-Type: application/json" \ + -d "{\"content\": \"$(echo -n 'print(\"Hello from Forgejo!\")' | base64)\", \"message\": \"Add hello.py\"}" + +curl -sf -X POST "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/contents/src/main.py" \ + -u "$ADMIN_AUTH" \ + -H "Content-Type: application/json" \ + -d "{\"content\": \"$(echo -n 'def main():\n print(\"Main module\")' | base64)\", \"message\": \"Add src/main.py\"}" + +# Add testbot as collaborator +curl -sf -X PUT "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/collaborators/testbot" \ + -u "$ADMIN_AUTH" \ + -H "Content-Type: application/json" \ + -d '{"permission": "write"}' & + +# Add readonlyuser as read-only collaborator +curl -sf -X PUT "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/collaborators/readonlyuser" \ + -u "$ADMIN_AUTH" \ + -H "Content-Type: application/json" \ + -d '{"permission": "read"}' & + +# Create label +curl -sf -X POST "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/labels" \ + -u "$ADMIN_AUTH" \ + -H "Content-Type: application/json" \ + -d '{"name": "bug", "color": "#ee0701"}' & + +# Create milestone +curl -sf -X POST "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/milestones" \ + -u "$ADMIN_AUTH" \ + -H "Content-Type: application/json" \ + -d '{"title": "v1.0", "description": "First release milestone", "due_on": "2026-03-01T00:00:00Z"}' & + +wait +echo "Phase 3 done." + +# --- Phase 3c: Metadata that depends on label/milestone existing --- +# Label ID 1 and milestone ID 1 are deterministic because they're the first +# created on a fresh Forgejo instance (auto-increment starts at 1). +echo "Setting issue metadata..." + +# Add label to issue #1 (label ID 1 = "bug", created in Phase 3b) +curl -sf -X POST "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/issues/1/labels" \ + -u "$ADMIN_AUTH" \ + -H "Content-Type: application/json" \ + -d '{"labels": [1]}' & + +# Set milestone + assignee on issue #1 (milestone ID 1 = "v1.0", created in Phase 3b) +curl -sf -X PATCH "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/issues/1" \ + -u "$ADMIN_AUTH" \ + -H "Content-Type: application/json" \ + -d '{"milestone": 1, "assignees": ["testadmin"]}' & + +wait + +# --- Phase 4: Create branches + PRs (sequential — numbers must be deterministic #4, #5) --- +echo "Creating feature branch..." +curl -sf -X POST "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/contents/feature.txt" \ + -u "$TESTBOT_AUTH" \ + -H "Content-Type: application/json" \ + -d "{\"content\": \"$(echo -n 'This file was added in a feature branch' | base64)\", \"message\": \"Add feature.txt\", \"new_branch\": \"feature-branch\"}" + +echo "Creating pull request #4..." +curl -sf -X POST "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/pulls" \ + -u "$TESTBOT_AUTH" \ + -H "Content-Type: application/json" \ + -d '{"title": "Add feature file", "head": "feature-branch", "base": "main", "body": "This PR adds a new feature file."}' + +echo "Creating merge branch..." +curl -sf -X POST "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/contents/merge-test.txt" \ + -u "$TESTBOT_AUTH" \ + -H "Content-Type: application/json" \ + -d "{\"content\": \"$(echo -n 'This file is for merge testing' | base64)\", \"message\": \"Add merge-test.txt\", \"new_branch\": \"merge-branch\"}" + +echo "Creating pull request #5..." +curl -sf -X POST "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/pulls" \ + -u "$TESTBOT_AUTH" \ + -H "Content-Type: application/json" \ + -d '{"title": "Merge test PR", "head": "merge-branch", "base": "main", "body": "This PR is for testing the merge flow."}' + +# --- Phase 5: Post-PR metadata (parallel) --- +echo "Setting PR metadata..." + +# Comment on PR #4 +curl -sf -X POST "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/issues/4/comments" \ + -u "$TESTBOT_AUTH" \ + -H "Content-Type: application/json" \ + -d '{"body": "Please review this PR when you get a chance."}' & + +# Review on PR #4 +curl -sf -X POST "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/pulls/4/reviews" \ + -u "$ADMIN_AUTH" \ + -H "Content-Type: application/json" \ + -d '{"event": "COMMENT", "body": "Looks good so far, just a few comments.", "comments": [{"path": "feature.txt", "body": "Consider a more descriptive filename.", "new_position": 1}]}' & + +# Milestone + assignee on PR #4 +curl -sf -X PATCH "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/issues/4" \ + -u "$ADMIN_AUTH" \ + -H "Content-Type: application/json" \ + -d '{"milestone": 1, "assignees": ["testadmin"]}' & + +wait + +echo "" +echo "Seed data created successfully." diff --git a/justfile b/justfile new file mode 100644 index 0000000..aaaf138 --- /dev/null +++ b/justfile @@ -0,0 +1,257 @@ +set shell := ["bash", "-eo", "pipefail", "-c"] + +default_destination := "platform=iOS Simulator,name=iPhone 17 Pro" +default_destination_b := "platform=iOS Simulator,name=iPhone Air" +compose_file := "integration/docker-compose.yml" +base_url_1 := "http://localhost:13001" +base_url_2 := "http://localhost:13002" +readonly_classes := "LoginUITests CommitHistoryUITests PaginationUITests RepositoryUITests IssueUITests PullRequestUITests OverviewCreateUITests" +mutating_classes := "RepositoryMutatingUITests IssueMutatingUITests PullRequestMutatingUITests OverviewCreateMutatingUITests HomeScreenUITests NotificationsUITests PermissionUITests" + +# List all available tasks +default: + @just --list + +# Build the app +build destination=default_destination: + xcodebuild -project Forji/Forji.xcodeproj -scheme Forji -destination '{{destination}}' build 2>&1 | xcbeautify + +# Run app unit tests +test destination=default_destination: + xcodebuild -project Forji/Forji.xcodeproj -scheme Forji -destination '{{destination}}' test -only-testing:ForjiTests 2>&1 | xcbeautify + +# Lint Swift code +lint: + swiftlint lint Forji/Forji + +# Format Swift code +format: + swiftformat Forji/Forji + +# Clean build artifacts +clean: + xcodebuild -project Forji/Forji.xcodeproj -scheme Forji clean 2>&1 | xcbeautify + +# Build, install, and launch in simulator +run destination=default_destination: + #!/usr/bin/env bash + set -eo pipefail + SIM_NAME="$(echo '{{destination}}' | sed -n 's/.*name=\([^,]*\).*/\1/p')" + xcrun simctl boot "$SIM_NAME" 2>/dev/null || true + open -a Simulator + xcodebuild -project Forji/Forji.xcodeproj -scheme Forji -destination '{{destination}}' build 2>&1 | xcbeautify + BUILT_APP="$(xcodebuild -project Forji/Forji.xcodeproj -scheme Forji -destination '{{destination}}' -showBuildSettings 2>/dev/null | grep ' BUILT_PRODUCTS_DIR' | awk '{print $3}')/Forji.app" + xcrun simctl install "$SIM_NAME" "$BUILT_APP" + xcrun simctl launch "$SIM_NAME" "$(defaults read "$BUILT_APP/Info.plist" CFBundleIdentifier)" + +# List all UI integration tests +test-list: + @grep -rh 'func test.*()' Forji/ForjiUITests/*.swift \ + | grep -v 'override\|private\|ForgejoUITestBase' \ + | sed 's/.*func //' | sed 's/().*//' \ + | while read -r method; do \ + file=$(grep -rl "func $method()" Forji/ForjiUITests/*.swift | head -1); \ + class=$(grep 'class.*:' "$file" | head -1 | sed 's/.*class //' | sed 's/[: ].*//' ); \ + printf " %s/%s\n" "$class" "$method"; \ + done | sort + +# Start Forgejo Docker containers +docker-up: + docker compose -f {{compose_file}} up -d --wait --wait-timeout 120 + +# Stop Forgejo Docker containers and clean up +docker-down: + docker compose -f {{compose_file}} down -v 2>/dev/null || true + rm -f /tmp/forgejo_test_url.txt /tmp/forgejo_test_url_*.txt + +# Seed test data into Forgejo instances (with snapshot caching) +seed: + #!/usr/bin/env bash + set -eo pipefail + COMPOSE_FILE="{{compose_file}}" + BASE_URL_1="{{base_url_1}}" + BASE_URL_2="{{base_url_2}}" + SNAPSHOT_FILE="integration/.forgejo-seed-snapshot.tar.gz" + HASH_FILE="integration/.forgejo-seed-hash" + SETUP_HASH=$(shasum -a 256 integration/setup.sh | awk '{print $1}') + wait_for_instance() { + local url="$1" elapsed=0 + until curl -sf "$url/api/v1/version" > /dev/null 2>&1; do + if [ "$elapsed" -ge 60 ]; then + echo "Timed out waiting for $url after 60s" + exit 1 + fi + sleep 1 + elapsed=$((elapsed + 1)) + done + echo " $url is ready (${elapsed}s)" + } + if [ -f "$SNAPSHOT_FILE" ] && [ -f "$HASH_FILE" ] && [ "$(cat "$HASH_FILE")" = "$SETUP_HASH" ]; then + echo "Restoring from seed snapshot..." + CONTAINER_1=$(docker compose -f "$COMPOSE_FILE" ps -q forgejo-1) + CONTAINER_2=$(docker compose -f "$COMPOSE_FILE" ps -q forgejo-2) + docker cp "$SNAPSHOT_FILE" "$CONTAINER_1:/tmp/snapshot.tar.gz" & + docker cp "$SNAPSHOT_FILE" "$CONTAINER_2:/tmp/snapshot.tar.gz" & + wait + docker exec "$CONTAINER_1" sh -c "cd / && tar xzf /tmp/snapshot.tar.gz" & + docker exec "$CONTAINER_2" sh -c "cd / && tar xzf /tmp/snapshot.tar.gz" & + wait + docker compose -f "$COMPOSE_FILE" restart forgejo-1 forgejo-2 + wait_for_instance "$BASE_URL_1" & + wait_for_instance "$BASE_URL_2" & + wait + echo "Snapshot restored to both instances." + else + echo "Seeding test data..." + bash integration/setup.sh "$BASE_URL_1" forgejo-1 + echo "Snapshotting seed data..." + CONTAINER_1=$(docker compose -f "$COMPOSE_FILE" ps -q forgejo-1) + docker exec "$CONTAINER_1" sh -c "cd / && tar czf /tmp/snapshot.tar.gz data" + docker cp "$CONTAINER_1:/tmp/snapshot.tar.gz" "$SNAPSHOT_FILE" + echo "$SETUP_HASH" > "$HASH_FILE" + echo "Restoring snapshot to instance 2..." + CONTAINER_2=$(docker compose -f "$COMPOSE_FILE" ps -q forgejo-2) + docker cp "$SNAPSHOT_FILE" "$CONTAINER_2:/tmp/snapshot.tar.gz" + docker exec "$CONTAINER_2" sh -c "cd / && tar xzf /tmp/snapshot.tar.gz" + docker compose -f "$COMPOSE_FILE" restart forgejo-2 + wait_for_instance "$BASE_URL_2" + echo "Instance 2 seeded from snapshot." + fi + +# Run read-only UI tests (assumes build + seed + URL files are set up) +test-readonly destination=default_destination: + #!/usr/bin/env bash + set -eo pipefail + ARGS="" + for cls in {{readonly_classes}}; do + ARGS="$ARGS -only-testing:ForjiUITests/$cls" + done + xcodebuild test-without-building \ + -project Forji/Forji.xcodeproj \ + -scheme Forji \ + -destination '{{destination}}' \ + -parallel-testing-enabled NO \ + $ARGS 2>&1 | xcbeautify + +# Run mutating UI tests (assumes build + seed + URL files are set up) +test-mutating destination=default_destination_b: + #!/usr/bin/env bash + set -eo pipefail + ARGS="" + for cls in {{mutating_classes}}; do + ARGS="$ARGS -only-testing:ForjiUITests/$cls" + done + xcodebuild test-without-building \ + -project Forji/Forji.xcodeproj \ + -scheme Forji \ + -destination '{{destination}}' \ + -parallel-testing-enabled NO \ + $ARGS 2>&1 | xcbeautify + +# Run full UI integration test suite (requires Docker) +test-ui destination_a=default_destination destination_b=default_destination_b: + #!/usr/bin/env bash + set -eo pipefail + cleanup() { + just docker-down + } + trap cleanup EXIT + just sim-boot '{{destination_a}}' '{{destination_b}}' & + just docker-up & + just build-for-testing '{{destination_a}}' '{{destination_b}}' & + wait + just seed + just _write-url-files '{{destination_a}}' '{{destination_b}}' + LOG_A="/tmp/forji_test_group_a.log" + LOG_B="/tmp/forji_test_group_b.log" + EXIT_A=0 + EXIT_B=0 + echo "Starting read-only tests..." + just test-readonly '{{destination_a}}' > "$LOG_A" 2>&1 & + PID_A=$! + echo "Starting mutating tests..." + just test-mutating '{{destination_b}}' > "$LOG_B" 2>&1 & + PID_B=$! + wait $PID_A || EXIT_A=$? + wait $PID_B || EXIT_B=$? + echo "=== Read-only tests output ===" + cat "$LOG_A" + echo "" + echo "=== Mutating tests output ===" + cat "$LOG_B" + rm -f "$LOG_A" "$LOG_B" + if [ "$EXIT_A" -ne 0 ]; then + echo "Read-only tests failed (exit code $EXIT_A)." + fi + if [ "$EXIT_B" -ne 0 ]; then + echo "Mutating tests failed (exit code $EXIT_B)." + fi + if [ "$EXIT_A" -ne 0 ] || [ "$EXIT_B" -ne 0 ]; then + echo "Integration tests FAILED." + exit 1 + fi + echo "" + echo "All integration tests passed." + +# Run a single UI test (requires Docker). Use `just test-list` to see available tests. +test-one filter="" destination=default_destination: + #!/usr/bin/env bash + set -eo pipefail + FILTER="{{filter}}" + if [ -z "$FILTER" ]; then + echo "Usage: just test-one " + echo "" + echo "Run 'just test-list' to see available tests." + exit 1 + fi + cleanup() { + just docker-down + } + trap cleanup EXIT + SIM_NAME="$(echo '{{destination}}' | sed -n 's/.*name=\([^,]*\).*/\1/p')" + xcrun simctl boot "$SIM_NAME" 2>/dev/null || true + just docker-up & + xcodebuild build-for-testing \ + -project Forji/Forji.xcodeproj \ + -scheme Forji \ + -destination '{{destination}}' 2>&1 | xcbeautify & + wait + just seed + SIM_SAFE="${SIM_NAME// /_}" + echo "{{base_url_1}}" > "/tmp/forgejo_test_url_${SIM_SAFE}.txt" + echo "{{base_url_1}}" > /tmp/forgejo_test_url.txt + xcodebuild test-without-building \ + -project Forji/Forji.xcodeproj \ + -scheme Forji \ + -destination '{{destination}}' \ + -parallel-testing-enabled NO \ + -only-testing:"ForjiUITests/$FILTER" 2>&1 | xcbeautify + +[private] +sim-boot destination_a=default_destination destination_b=default_destination_b: + #!/usr/bin/env bash + set -eo pipefail + SIM_A="$(echo '{{destination_a}}' | sed -n 's/.*name=\([^,]*\).*/\1/p')" + SIM_B="$(echo '{{destination_b}}' | sed -n 's/.*name=\([^,]*\).*/\1/p')" + xcrun simctl boot "$SIM_A" 2>/dev/null || true + xcrun simctl boot "$SIM_B" 2>/dev/null || true + +[private] +build-for-testing destination_a=default_destination destination_b=default_destination_b: + xcodebuild build-for-testing \ + -project Forji/Forji.xcodeproj \ + -scheme Forji \ + -destination '{{destination_a}}' \ + -destination '{{destination_b}}' 2>&1 | xcbeautify + +[private] +_write-url-files destination_a=default_destination destination_b=default_destination_b: + #!/usr/bin/env bash + set -eo pipefail + SIM_A="$(echo '{{destination_a}}' | sed -n 's/.*name=\([^,]*\).*/\1/p')" + SIM_B="$(echo '{{destination_b}}' | sed -n 's/.*name=\([^,]*\).*/\1/p')" + SIM_A_SAFE="${SIM_A// /_}" + SIM_B_SAFE="${SIM_B// /_}" + echo "{{base_url_1}}" > "/tmp/forgejo_test_url_${SIM_A_SAFE}.txt" + echo "{{base_url_2}}" > "/tmp/forgejo_test_url_${SIM_B_SAFE}.txt" + echo "{{base_url_1}}" > /tmp/forgejo_test_url.txt diff --git a/screenshots/01_repositories.png b/screenshots/01_repositories.png new file mode 100644 index 0000000..adb08a0 Binary files /dev/null and b/screenshots/01_repositories.png differ diff --git a/screenshots/02_code_browser.png b/screenshots/02_code_browser.png new file mode 100644 index 0000000..8e52c7c Binary files /dev/null and b/screenshots/02_code_browser.png differ diff --git a/screenshots/03_pull_request.png b/screenshots/03_pull_request.png new file mode 100644 index 0000000..f59be4e Binary files /dev/null and b/screenshots/03_pull_request.png differ diff --git a/screenshots/04_notifications.png b/screenshots/04_notifications.png new file mode 100644 index 0000000..0f63887 Binary files /dev/null and b/screenshots/04_notifications.png differ diff --git a/screenshots/app_icon.png b/screenshots/app_icon.png new file mode 100644 index 0000000..296ef37 Binary files /dev/null and b/screenshots/app_icon.png differ