mirror of
https://codeberg.org/secana/Forji.git
synced 2026-06-16 05:13:55 -07:00
feat: inital commit
Forji is an iOS app to interact with a Forgejo instance
This commit is contained in:
commit
5adc0102eb
105 changed files with 13091 additions and 0 deletions
683
Forji/Forji.xcodeproj/project.pbxproj
Normal file
683
Forji/Forji.xcodeproj/project.pbxproj
Normal 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 */;
|
||||
}
|
||||
7
Forji/Forji.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
Forji/Forji.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
|
@ -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
|
||||
}
|
||||
BIN
Forji/Forji.xcodeproj/project.xcworkspace/xcuserdata/hausi.xcuserdatad/UserInterfaceState.xcuserstate
generated
Normal file
BIN
Forji/Forji.xcodeproj/project.xcworkspace/xcuserdata/hausi.xcuserdatad/UserInterfaceState.xcuserstate
generated
Normal file
Binary file not shown.
102
Forji/Forji.xcodeproj/xcshareddata/xcschemes/Forji.xcscheme
Normal file
102
Forji/Forji.xcodeproj/xcshareddata/xcschemes/Forji.xcscheme
Normal 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>
|
||||
|
|
@ -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>
|
||||
100
Forji/Forji/App/ContentView.swift
Normal file
100
Forji/Forji/App/ContentView.swift
Normal 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)
|
||||
}
|
||||
25
Forji/Forji/App/ForjiApp.swift
Normal file
25
Forji/Forji/App/ForjiApp.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Forji/Forji/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
BIN
Forji/Forji/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
36
Forji/Forji/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
36
Forji/Forji/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal 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
|
||||
}
|
||||
}
|
||||
6
Forji/Forji/Assets.xcassets/Contents.json
Normal file
6
Forji/Forji/Assets.xcassets/Contents.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
32
Forji/Forji/Helpers/DebouncedSearch.swift
Normal file
32
Forji/Forji/Helpers/DebouncedSearch.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
22
Forji/Forji/Helpers/DiffParserSwiftUI.swift
Normal file
22
Forji/Forji/Helpers/DiffParserSwiftUI.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
10
Forji/Forji/Helpers/LanguageColor.swift
Normal file
10
Forji/Forji/Helpers/LanguageColor.swift
Normal 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)
|
||||
}
|
||||
60
Forji/Forji/Helpers/MermaidParser.swift
Normal file
60
Forji/Forji/Helpers/MermaidParser.swift
Normal 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
|
||||
}
|
||||
}
|
||||
18
Forji/Forji/Helpers/MetadataLoader.swift
Normal file
18
Forji/Forji/Helpers/MetadataLoader.swift
Normal 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 ?? [],
|
||||
)
|
||||
}
|
||||
33
Forji/Forji/Helpers/PRStatusStyle.swift
Normal file
33
Forji/Forji/Helpers/PRStatusStyle.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
92
Forji/Forji/Helpers/PaginationState.swift
Normal file
92
Forji/Forji/Helpers/PaginationState.swift
Normal 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
|
||||
}
|
||||
}
|
||||
350
Forji/Forji/Helpers/PreviewData.swift
Normal file
350
Forji/Forji/Helpers/PreviewData.swift
Normal 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
|
||||
24
Forji/Forji/Models/ForgejoInstance.swift
Normal file
24
Forji/Forji/Models/ForgejoInstance.swift
Normal 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
|
||||
}
|
||||
}
|
||||
53
Forji/Forji/Models/ReviewState.swift
Normal file
53
Forji/Forji/Models/ReviewState.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
62
Forji/Forji/Models/State.swift
Normal file
62
Forji/Forji/Models/State.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
131
Forji/Forji/Services/AuthenticationService.swift
Normal file
131
Forji/Forji/Services/AuthenticationService.swift
Normal 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
|
||||
}
|
||||
114
Forji/Forji/Services/KeychainManager.swift
Normal file
114
Forji/Forji/Services/KeychainManager.swift
Normal 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
|
||||
}
|
||||
71
Forji/Forji/Views/CommentSheet.swift
Normal file
71
Forji/Forji/Views/CommentSheet.swift
Normal 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 }
|
||||
}
|
||||
118
Forji/Forji/Views/CommentView.swift
Normal file
118
Forji/Forji/Views/CommentView.swift
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
104
Forji/Forji/Views/CommitDetailView.swift
Normal file
104
Forji/Forji/Views/CommitDetailView.swift
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
220
Forji/Forji/Views/CommitHistoryView.swift
Normal file
220
Forji/Forji/Views/CommitHistoryView.swift
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
151
Forji/Forji/Views/DiffView.swift
Normal file
151
Forji/Forji/Views/DiffView.swift
Normal 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())
|
||||
}
|
||||
}
|
||||
58
Forji/Forji/Views/DisplaySections.swift
Normal file
58
Forji/Forji/Views/DisplaySections.swift
Normal 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])
|
||||
}
|
||||
}
|
||||
22
Forji/Forji/Views/ErrorAlert.swift
Normal file
22
Forji/Forji/Views/ErrorAlert.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
337
Forji/Forji/Views/FileViewerView.swift
Normal file
337
Forji/Forji/Views/FileViewerView.swift
Normal 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",
|
||||
)
|
||||
}
|
||||
}
|
||||
45
Forji/Forji/Views/FloatingButtons.swift
Normal file
45
Forji/Forji/Views/FloatingButtons.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
46
Forji/Forji/Views/FlowLayout.swift
Normal file
46
Forji/Forji/Views/FlowLayout.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
180
Forji/Forji/Views/HomeView.swift
Normal file
180
Forji/Forji/Views/HomeView.swift
Normal 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)
|
||||
}
|
||||
139
Forji/Forji/Views/InlineCommentSheet.swift
Normal file
139
Forji/Forji/Views/InlineCommentSheet.swift
Normal 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
|
||||
}
|
||||
}
|
||||
267
Forji/Forji/Views/InstanceFormView.swift
Normal file
267
Forji/Forji/Views/InstanceFormView.swift
Normal 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)
|
||||
}
|
||||
181
Forji/Forji/Views/InstanceListView.swift
Normal file
181
Forji/Forji/Views/InstanceListView.swift
Normal 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)
|
||||
}
|
||||
160
Forji/Forji/Views/IssueCreateView.swift
Normal file
160
Forji/Forji/Views/IssueCreateView.swift
Normal 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],
|
||||
)
|
||||
}
|
||||
}
|
||||
300
Forji/Forji/Views/IssueDetailView.swift
Normal file
300
Forji/Forji/Views/IssueDetailView.swift
Normal 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],
|
||||
)
|
||||
}
|
||||
}
|
||||
169
Forji/Forji/Views/IssueEditView.swift
Normal file
169
Forji/Forji/Views/IssueEditView.swift
Normal 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],
|
||||
)
|
||||
}
|
||||
59
Forji/Forji/Views/IssueLabelView.swift
Normal file
59
Forji/Forji/Views/IssueLabelView.swift
Normal 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()
|
||||
}
|
||||
199
Forji/Forji/Views/IssueListView.swift
Normal file
199
Forji/Forji/Views/IssueListView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
59
Forji/Forji/Views/IssuesOverviewView.swift
Normal file
59
Forji/Forji/Views/IssuesOverviewView.swift
Normal 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],
|
||||
)
|
||||
}
|
||||
}
|
||||
47
Forji/Forji/Views/ListHelpers.swift
Normal file
47
Forji/Forji/Views/ListHelpers.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
218
Forji/Forji/Views/MarkdownComponents.swift
Normal file
218
Forji/Forji/Views/MarkdownComponents.swift
Normal 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()
|
||||
}
|
||||
88
Forji/Forji/Views/MentionableEditorField.swift
Normal file
88
Forji/Forji/Views/MentionableEditorField.swift
Normal 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()
|
||||
}
|
||||
132
Forji/Forji/Views/MermaidWebView.swift
Normal file
132
Forji/Forji/Views/MermaidWebView.swift
Normal 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: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
.replacingOccurrences(of: "\"", with: """)
|
||||
.replacingOccurrences(of: "'", with: "'")
|
||||
|
||||
// swiftlint:disable:next line_length
|
||||
let csp = "default-src 'none'; script-src https://cdn.jsdelivr.net 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:;"
|
||||
let html = """
|
||||
<!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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
330
Forji/Forji/Views/MetadataPickers.swift
Normal file
330
Forji/Forji/Views/MetadataPickers.swift
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
272
Forji/Forji/Views/NotificationsOverviewView.swift
Normal file
272
Forji/Forji/Views/NotificationsOverviewView.swift
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
250
Forji/Forji/Views/PullRequestCreateView.swift
Normal file
250
Forji/Forji/Views/PullRequestCreateView.swift
Normal 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],
|
||||
)
|
||||
}
|
||||
}
|
||||
605
Forji/Forji/Views/PullRequestDetailView.swift
Normal file
605
Forji/Forji/Views/PullRequestDetailView.swift
Normal 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],
|
||||
)
|
||||
}
|
||||
}
|
||||
216
Forji/Forji/Views/PullRequestEditView.swift
Normal file
216
Forji/Forji/Views/PullRequestEditView.swift
Normal 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],
|
||||
)
|
||||
}
|
||||
206
Forji/Forji/Views/PullRequestListView.swift
Normal file
206
Forji/Forji/Views/PullRequestListView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
111
Forji/Forji/Views/PullRequestMergeView.swift
Normal file
111
Forji/Forji/Views/PullRequestMergeView.swift
Normal 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,
|
||||
) {}
|
||||
}
|
||||
246
Forji/Forji/Views/PullRequestReviewSheet.swift
Normal file
246
Forji/Forji/Views/PullRequestReviewSheet.swift
Normal 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,
|
||||
)
|
||||
}
|
||||
107
Forji/Forji/Views/PullRequestsOverviewView.swift
Normal file
107
Forji/Forji/Views/PullRequestsOverviewView.swift
Normal 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],
|
||||
)
|
||||
}
|
||||
}
|
||||
566
Forji/Forji/Views/RepositoryDetailView.swift
Normal file
566
Forji/Forji/Views/RepositoryDetailView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
334
Forji/Forji/Views/RepositoryListView.swift
Normal file
334
Forji/Forji/Views/RepositoryListView.swift
Normal 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)
|
||||
}
|
||||
183
Forji/Forji/Views/RepositoryPickerView.swift
Normal file
183
Forji/Forji/Views/RepositoryPickerView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
85
Forji/Forji/Views/ReviewSummaryView.swift
Normal file
85
Forji/Forji/Views/ReviewSummaryView.swift
Normal 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],
|
||||
)
|
||||
}
|
||||
}
|
||||
316
Forji/Forji/Views/SearchableOverviewView.swift
Normal file
316
Forji/Forji/Views/SearchableOverviewView.swift
Normal 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
|
||||
}
|
||||
22
Forji/Forji/Views/StateAccent.swift
Normal file
22
Forji/Forji/Views/StateAccent.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
36
Forji/ForjiTests/CommentSheetTests.swift
Normal file
36
Forji/ForjiTests/CommentSheetTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
26
Forji/ForjiTests/ForjiTests.swift
Normal file
26
Forji/ForjiTests/ForjiTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
136
Forji/ForjiTests/KeychainManagerTests.swift
Normal file
136
Forji/ForjiTests/KeychainManagerTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
48
Forji/ForjiTests/LanguageColorTests.swift
Normal file
48
Forji/ForjiTests/LanguageColorTests.swift
Normal 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
|
||||
}
|
||||
}
|
||||
32
Forji/ForjiTests/MarkdownComponentsTests.swift
Normal file
32
Forji/ForjiTests/MarkdownComponentsTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
188
Forji/ForjiTests/MermaidParserTests.swift
Normal file
188
Forji/ForjiTests/MermaidParserTests.swift
Normal 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") }
|
||||
}
|
||||
}
|
||||
387
Forji/ForjiTests/PaginationStateTests.swift
Normal file
387
Forji/ForjiTests/PaginationStateTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
136
Forji/ForjiTests/StateTests.swift
Normal file
136
Forji/ForjiTests/StateTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
47
Forji/ForjiUITests/CommitHistoryUITests.swift
Normal file
47
Forji/ForjiUITests/CommitHistoryUITests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
123
Forji/ForjiUITests/ForgejoReadOnlyUITestBase.swift
Normal file
123
Forji/ForjiUITests/ForgejoReadOnlyUITestBase.swift
Normal 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()
|
||||
}
|
||||
|
||||
}
|
||||
62
Forji/ForjiUITests/ForgejoUITestBase.swift
Normal file
62
Forji/ForjiUITests/ForgejoUITestBase.swift
Normal 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()
|
||||
}
|
||||
|
||||
}
|
||||
31
Forji/ForjiUITests/ForjiUITestsLaunchTests.swift
Normal file
31
Forji/ForjiUITests/ForjiUITestsLaunchTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
34
Forji/ForjiUITests/HomeScreenUITests.swift
Normal file
34
Forji/ForjiUITests/HomeScreenUITests.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
88
Forji/ForjiUITests/IssueMutatingUITests.swift
Normal file
88
Forji/ForjiUITests/IssueMutatingUITests.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
123
Forji/ForjiUITests/IssueUITests.swift
Normal file
123
Forji/ForjiUITests/IssueUITests.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
33
Forji/ForjiUITests/LoginUITests.swift
Normal file
33
Forji/ForjiUITests/LoginUITests.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
52
Forji/ForjiUITests/NotificationsUITests.swift
Normal file
52
Forji/ForjiUITests/NotificationsUITests.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
49
Forji/ForjiUITests/OverviewCreateMutatingUITests.swift
Normal file
49
Forji/ForjiUITests/OverviewCreateMutatingUITests.swift
Normal 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"
|
||||
)
|
||||
}
|
||||
}
|
||||
75
Forji/ForjiUITests/OverviewCreateUITests.swift
Normal file
75
Forji/ForjiUITests/OverviewCreateUITests.swift
Normal 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"
|
||||
)
|
||||
}
|
||||
}
|
||||
95
Forji/ForjiUITests/PaginationUITests.swift
Normal file
95
Forji/ForjiUITests/PaginationUITests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
80
Forji/ForjiUITests/PermissionUITests.swift
Normal file
80
Forji/ForjiUITests/PermissionUITests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
139
Forji/ForjiUITests/PullRequestMutatingUITests.swift
Normal file
139
Forji/ForjiUITests/PullRequestMutatingUITests.swift
Normal 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"
|
||||
)
|
||||
}
|
||||
}
|
||||
153
Forji/ForjiUITests/PullRequestUITests.swift
Normal file
153
Forji/ForjiUITests/PullRequestUITests.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
73
Forji/ForjiUITests/RepositoryMutatingUITests.swift
Normal file
73
Forji/ForjiUITests/RepositoryMutatingUITests.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
86
Forji/ForjiUITests/RepositoryUITests.swift
Normal file
86
Forji/ForjiUITests/RepositoryUITests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
66
Forji/ForjiUITests/UITestNavigating.swift
Normal file
66
Forji/ForjiUITests/UITestNavigating.swift
Normal 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
11
Forji/Info.plist
Normal 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
716
LICENSE
Normal 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
115
README.md
Normal 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
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## 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
61
flake.lock
Normal 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
24
flake.nix
Normal 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
|
||||
];
|
||||
};
|
||||
});
|
||||
}
|
||||
1
integration/.forgejo-seed-hash
Normal file
1
integration/.forgejo-seed-hash
Normal file
|
|
@ -0,0 +1 @@
|
|||
11b89bd63fe2a77529f2f8124efafaadcf4deccb435ae43957501aab0795fffd
|
||||
BIN
integration/.forgejo-seed-snapshot.tar.gz
Normal file
BIN
integration/.forgejo-seed-snapshot.tar.gz
Normal file
Binary file not shown.
38
integration/docker-compose.yml
Normal file
38
integration/docker-compose.yml
Normal 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
207
integration/setup.sh
Executable 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
257
justfile
Normal 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
Loading…
Reference in a new issue