feat: inital commit

Forji is an iOS app to interact with a Forgejo instance
This commit is contained in:
Stefan Hausotte 2026-02-28 21:08:13 +01:00
commit 5adc0102eb
105 changed files with 13091 additions and 0 deletions

View file

@ -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 = "<group>";
};
DEC49F332F3CE05400E7DD54 /* ForjiTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = ForjiTests;
sourceTree = "<group>";
};
DEC49F3D2F3CE05400E7DD54 /* ForjiUITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = ForjiUITests;
sourceTree = "<group>";
};
/* 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 = "<group>";
};
DEC49F222F3CE05200E7DD54 /* Products */ = {
isa = PBXGroup;
children = (
DEC49F212F3CE05200E7DD54 /* Forji.app */,
DEC49F302F3CE05400E7DD54 /* ForjiTests.xctest */,
DEC49F3A2F3CE05400E7DD54 /* ForjiUITests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
DEC49F6C2F3D023400E7DD54 /* Frameworks */ = {
isa = PBXGroup;
children = (
DE00000000000007000000BB /* AppIntents.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
/* 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 */;
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View file

@ -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
}

View file

@ -0,0 +1,102 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "DEC49F202F3CE05200E7DD54"
BuildableName = "Forji.app"
BlueprintName = "Forji"
ReferencedContainer = "container:Forji.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "DEC49F2F2F3CE05400E7DD54"
BuildableName = "ForjiTests.xctest"
BlueprintName = "ForjiTests"
ReferencedContainer = "container:Forji.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "DEC49F392F3CE05400E7DD54"
BuildableName = "ForjiUITests.xctest"
BlueprintName = "ForjiUITests"
ReferencedContainer = "container:Forji.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "DEC49F202F3CE05200E7DD54"
BuildableName = "Forji.app"
BlueprintName = "Forji"
ReferencedContainer = "container:Forji.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "DEC49F202F3CE05200E7DD54"
BuildableName = "Forji.app"
BlueprintName = "Forji"
ReferencedContainer = "container:Forji.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>Forji.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict>
<key>DEC49F202F3CE05200E7DD54</key>
<dict>
<key>primary</key>
<true/>
</dict>
</dict>
</dict>
</plist>

View file

@ -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<ForgejoInstance> { $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<ForgejoInstance>(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)
}

View file

@ -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)
}
}

View file

@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View file

@ -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
}
}

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,32 @@
import SwiftUI
struct DebouncedSearchModifier: ViewModifier {
@Binding var searchText: String
@Binding var searchTask: Task<Void, Never>?
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<String>,
task: Binding<Task<Void, Never>?>,
action: @escaping () async -> Void,
) -> some View {
modifier(DebouncedSearchModifier(searchText: text, searchTask: task, action: action))
}
}

View file

@ -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
}
}
}

View file

@ -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)
}

View file

@ -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
}
}

View file

@ -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 ?? [],
)
}

View file

@ -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"
}
}
}
}

View file

@ -0,0 +1,92 @@
import SwiftUI
@MainActor
@Observable
final class PaginationState<Item> {
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<Void, Never>?
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<Void, Never> {
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
}
}

View file

@ -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

View file

@ -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
}
}

View file

@ -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"
}
}
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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 }
}

View file

@ -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,
)
}
}

View file

@ -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,
)
}
}

View file

@ -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<Commit>()
@State private var branches: [Branch] = []
@State private var showBranchPicker = false
private let repositoryService: RepositoryService?
init(repository: Repository, branch: Binding<String>, 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<Void, Never> {
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,
)
}
}

View file

@ -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())
}
}

View file

@ -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])
}
}

View file

@ -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<String?>, isPresented: Binding<Bool>) -> some View {
modifier(ErrorAlertModifier(errorMessage: message, isPresented: isPresented, title: title))
}
}

View file

@ -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",
)
}
}

View file

@ -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<Content: View>: 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)
}
}

View file

@ -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))
}
}

View file

@ -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)
}

View file

@ -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
}
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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<Int> = []
@State private var selectedMilestoneID: Int?
@State private var selectedAssigneeLogins: Set<String> = []
@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],
)
}
}

View file

@ -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],
)
}
}

View file

@ -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<Int>
@State private var selectedMilestoneID: Int?
@State private var selectedAssigneeLogins: Set<String>
@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],
)
}

View file

@ -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()
}

View file

@ -0,0 +1,199 @@
import ForgejoKit
import SwiftUI
struct IssueListView: View {
let repository: Repository
@State private var authService: AuthenticationService
@State private var pagination = PaginationState<Issue>()
@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<Void, Never> {
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)
}
}

View file

@ -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],
)
}
}

View file

@ -0,0 +1,47 @@
import SwiftUI
struct SegmentedPickerSection<Selection: Hashable>: 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)
}
}
}

View file

@ -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()
}

View file

@ -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[..<atIndex]) + "@\(username) "
mentionQuery = nil
}
}
#Preview {
@Previewable @State var text = "Hello @"
@Previewable @State var tab: EditPreviewTab = .edit
MentionableEditorField(
text: $text,
selectedTab: $tab,
users: [.preview, .previewBot],
)
.padding()
}

View file

@ -0,0 +1,132 @@
import SwiftUI
import WebKit
struct MermaidWebView: UIViewRepresentable {
let code: String
@Binding var height: CGFloat
@Environment(\.colorScheme) private var colorScheme
func makeCoordinator() -> 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: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
.replacingOccurrences(of: "'", with: "&#39;")
// 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 = """
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="\(csp)">
<style>
body {
margin: 0;
padding: 8px;
background: transparent;
display: flex;
justify-content: center;
}
.mermaid {
overflow-x: auto;
}
.mermaid svg {
max-width: 100%;
height: auto;
}
</style>
</head>
<body>
<pre class="mermaid">
\(safeCode)
</pre>
<script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
mermaid.initialize({
startOnLoad: true,
theme: '\(theme)',
securityLevel: 'strict'
});
// Report height after rendering
const observer = new MutationObserver(() => {
const svg = document.querySelector('.mermaid svg');
if (svg) {
observer.disconnect();
setTimeout(() => {
const height = document.body.scrollHeight;
window.webkit.messageHandlers.heightReporter.postMessage(height);
}, 100);
}
});
observer.observe(document.body, { childList: true, subtree: true });
// Fallback timeout
setTimeout(() => {
const height = document.body.scrollHeight;
window.webkit.messageHandlers.heightReporter.postMessage(height);
}, 3000);
</script>
</body>
</html>
"""
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)
}
}
}
}

View file

@ -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<Item: Identifiable, RowContent: View>: 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<Int>
@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<String>
@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<Int> = [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<String> = []
NavigationStack {
List {
UserPickerSection(
title: "Assignees",
availableUsers: [.preview, .previewBot],
selectedLogins: $selected,
)
}
}
}

View file

@ -0,0 +1,272 @@
import ForgejoKit
import SwiftUI
struct NotificationsOverviewView: View {
@State private var authService: AuthenticationService
@State private var pagination = PaginationState<NotificationThread>()
@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<Void, Never> {
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,
)
}
}

View file

@ -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<Int> = []
@State private var selectedMilestoneID: Int?
@State private var selectedAssigneeLogins: Set<String> = []
@State private var selectedReviewerLogins: Set<String> = []
@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],
)
}
}

View file

@ -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],
)
}
}

View file

@ -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<Int>
@State private var selectedMilestoneID: Int?
@State private var selectedAssigneeLogins: Set<String>
@State private var selectedReviewerLogins: Set<String>
@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],
)
}

View file

@ -0,0 +1,206 @@
import ForgejoKit
import SwiftUI
struct PullRequestListView: View {
let repository: Repository
@State private var authService: AuthenticationService
@State private var pagination = PaginationState<PullRequest>()
@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<Void, Never> {
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)
}
}

View file

@ -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,
) {}
}

View file

@ -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,
)
}

View file

@ -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],
)
}
}

View file

@ -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<Void, Never>?
@State private var contentLoadTask: Task<Void, Never>?
private let repositoryService: RepositoryService?
var onFileNavigation: ((String, String) -> Void)?
init(
repository: Repository,
authService: AuthenticationService,
selectedBranch: Binding<String>,
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)
}
}

View file

@ -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<Int> = []
@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<Void, Never>?
@State private var hasMore = true
@State private var currentPage = 1
@State private var starringInFlight: Set<Int> = []
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)
}

View file

@ -0,0 +1,183 @@
import ForgejoKit
import SwiftUI
struct RepositoryPickerView<Destination: View>: 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<Void, Never>?
@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)
}
}

View file

@ -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],
)
}
}

View file

@ -0,0 +1,316 @@
import ForgejoKit
import SwiftUI
// swiftlint:disable:next type_body_length
struct SearchableOverviewView<Row: View, Detail: View, CreateView: View>: View {
@State private var authService: AuthenticationService
@State private var pagination = PaginationState<Issue>()
@State private var stateFilter: IssueFilterState = .open
@State private var searchText = ""
@State private var searchTask: Task<Void, Never>?
@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<Void, Never> {
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
}

View file

@ -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))
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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")
}
}

View file

@ -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
}
}

View file

@ -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")
}
}

View file

@ -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") }
}
}

View file

@ -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<String>(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<String>(pageSize: 3)
await pagination.reload { _, _ in ["a", "b", "c"] }.value
#expect(pagination.hasMore) // 3 >= pageSize 3
}
@Test @MainActor func reloadReplacesExistingItems() async {
let pagination = PaginationState<String>(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<String>(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<String>(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<String>(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<String>(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<String>(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<String>(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<String>(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<String>(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<String>(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<String>(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<String>(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<String>(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<String>(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<String>(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<String>(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)
}
}

View file

@ -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)
}
}

View file

@ -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")
}
}

View file

@ -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()
}
}

View file

@ -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()
}
}

View file

@ -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)
}
}

View file

@ -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))
}
}

View file

@ -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))
}
}

View file

@ -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)")
}
}

View file

@ -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))
}
}

View file

@ -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()
}
}
}
}

View file

@ -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"
)
}
}

View file

@ -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"
)
}
}

View file

@ -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")
}
}

View file

@ -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")
}
}

View file

@ -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"
)
}
}

View file

@ -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()
}
}

View file

@ -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))
}
}

View file

@ -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")
}
}

View file

@ -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()
}
}
}

11
Forji/Info.plist Normal file
View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict>
</plist>

716
LICENSE Normal file
View file

@ -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 <https://www.gnu.org/licenses/>.
## 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.
<https://fsf.org/>
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.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
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 <https://www.gnu.org/licenses/>.
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:
<program> Copyright (C) <year> <name of author>
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 <https://www.gnu.org/licenses/>.
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 <https://www.gnu.org/licenses/why-not-lgpl.html>.

115
README.md Normal file
View file

@ -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).

61
flake.lock Normal file
View file

@ -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
}

24
flake.nix Normal file
View file

@ -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
];
};
});
}

View file

@ -0,0 +1 @@
11b89bd63fe2a77529f2f8124efafaadcf4deccb435ae43957501aab0795fffd

Binary file not shown.

View file

@ -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:

207
integration/setup.sh Executable file
View file

@ -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."

257
justfile Normal file
View file

@ -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 <Class/method>"
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

Some files were not shown because too many files have changed in this diff Show more