Compare commits
81 Commits
73b6882b47
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49d9fdcc9a | ||
|
|
ac6de7d4f2 | ||
|
|
59bcddfadf | ||
|
|
f84ab774e6 | ||
|
|
6d271e7402 | ||
|
|
e5131f663c | ||
|
|
bac15ab34a | ||
|
|
ced28ba1e6 | ||
|
|
69d1709523 | ||
|
|
2f5269a6e9 | ||
|
|
8e2feac18e | ||
|
|
efdf5ee4c3 | ||
|
|
fd79939751 | ||
|
|
8ffb20a0d7 | ||
|
|
2b4a7673f7 | ||
|
|
c1cfaa3d23 | ||
|
|
61e96cc6cf | ||
|
|
aafde7e7e8 | ||
|
|
ccaa9204f0 | ||
|
|
9d7ce83cb1 | ||
|
|
1a43bf1ba8 | ||
|
|
c7c44676fb | ||
|
|
95526f8956 | ||
|
|
c67d274a95 | ||
|
|
2534010d1d | ||
|
|
976742e173 | ||
|
|
9a0b179a0e | ||
|
|
0f1d109af7 | ||
|
|
7376e6cee3 | ||
|
|
d47b21d6a6 | ||
|
|
46cca8fdd2 | ||
|
|
1c4a6efd13 | ||
|
|
d3bc65dee5 | ||
|
|
c0b03f26ac | ||
|
|
5a48456333 | ||
|
|
d0ec533da6 | ||
|
|
047f0ab0dc | ||
|
|
3a69b70395 | ||
|
|
5396a805b0 | ||
|
|
16ecf68bf8 | ||
|
|
a3962449a2 | ||
|
|
af392209e8 | ||
|
|
2c77f4eef2 | ||
|
|
128be97e20 | ||
|
|
acf2c839ad | ||
|
|
0a38dbe9f2 | ||
|
|
9c7cef3869 | ||
|
|
de49db0910 | ||
|
|
eadbadd3cb | ||
|
|
dba6359d30 | ||
|
|
822cf1f769 | ||
|
|
5b29e18942 | ||
|
|
e39695fc60 | ||
|
|
f38c3c4939 | ||
|
|
b468a2670c | ||
|
|
0583d05170 | ||
|
|
c4e64b5c0c | ||
|
|
da0a3aecb4 | ||
|
|
33f9eb809c | ||
|
|
228879da15 | ||
|
|
86200b4668 | ||
|
|
c5ecb8f87d | ||
|
|
a2615d5d83 | ||
|
|
6eb27537e8 | ||
|
|
6374636b04 | ||
|
|
c44e5f19c7 | ||
|
|
5e00c108a4 | ||
|
|
9fc9e3f13c | ||
|
|
50bb8f94c2 | ||
|
|
178094fd0f | ||
|
|
f85f955a41 | ||
|
|
7b2b431dfd | ||
|
|
4e4dfb0db9 | ||
|
|
82fadda983 | ||
|
|
201f32f125 | ||
|
|
f201814d54 | ||
|
|
8f9a4dfc00 | ||
|
|
1e3241bf65 | ||
|
|
d8a63c00f5 | ||
|
|
8c383e3672 | ||
|
|
c7de716b8e |
194
.gitea/scripts/release.py
Normal file
194
.gitea/scripts/release.py
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Create and publish complete tagged Gotcha CLI/TUI releases."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from urllib.error import HTTPError
|
||||||
|
from urllib.parse import quote
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
|
||||||
|
PLATFORMS = (
|
||||||
|
"darwin-arm64",
|
||||||
|
"darwin-x64",
|
||||||
|
"linux-arm64",
|
||||||
|
"linux-x64",
|
||||||
|
"windows-x64",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def issue_numbers(message: str) -> list[int]:
|
||||||
|
return sorted({int(number) for number in re.findall(r"#(\d+)", message)})
|
||||||
|
|
||||||
|
|
||||||
|
def safe_version(tag: str) -> str:
|
||||||
|
return re.sub(r"[^A-Za-z0-9._-]", "-", tag)
|
||||||
|
|
||||||
|
|
||||||
|
def asset_names(tag: str) -> list[str]:
|
||||||
|
version = safe_version(tag)
|
||||||
|
return [f"gotcha-{version}-{platform}.tar.gz" for platform in PLATFORMS]
|
||||||
|
|
||||||
|
|
||||||
|
def markdown(text: str) -> str:
|
||||||
|
return text.replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]")
|
||||||
|
|
||||||
|
|
||||||
|
def release_body(issues: list[dict[str, object]], server: str, repository: str, sha: str) -> str:
|
||||||
|
groups = {"Fixes": [], "Improvements": [], "Other changes": []}
|
||||||
|
for issue in issues:
|
||||||
|
labels = {label["name"] for label in issue.get("labels", [])} # type: ignore[index]
|
||||||
|
group = "Fixes" if "bug" in labels else "Improvements" if "enhancement" in labels else "Other changes"
|
||||||
|
title = " ".join(str(issue["title"]).split())
|
||||||
|
groups[group].append(f"- [#{issue['number']} {markdown(title)}]({issue['html_url']})")
|
||||||
|
|
||||||
|
sections = [f"## {name}\n" + "\n".join(items) for name, items in groups.items() if items]
|
||||||
|
if not sections:
|
||||||
|
sections.append("No closed issues were linked from commits in this release.")
|
||||||
|
sections.append(f"Built from [{sha[:12]}]({server}/{repository}/commit/{sha}).")
|
||||||
|
return "\n\n".join(sections)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_assets(assets: list[dict[str, object]], tag: str) -> None:
|
||||||
|
expected = asset_names(tag)
|
||||||
|
actual = sorted(str(asset["name"]) for asset in assets)
|
||||||
|
if actual != expected:
|
||||||
|
raise RuntimeError(f"release assets differ: expected {expected}, got {actual}")
|
||||||
|
empty = [str(asset["name"]) for asset in assets if int(asset.get("size", 0)) <= 0]
|
||||||
|
if empty:
|
||||||
|
raise RuntimeError(f"release assets are empty: {empty}")
|
||||||
|
|
||||||
|
|
||||||
|
class Gitea:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.api_url = os.environ["GITEA_API_URL"].rstrip("/")
|
||||||
|
self.repository = os.environ["GITEA_REPOSITORY"]
|
||||||
|
self.token = os.environ["GITEA_TOKEN"]
|
||||||
|
|
||||||
|
def request(self, method: str, path: str, payload: object | None = None) -> object:
|
||||||
|
data = None if payload is None else json.dumps(payload).encode()
|
||||||
|
request = Request(
|
||||||
|
f"{self.api_url}{path}",
|
||||||
|
data=data,
|
||||||
|
method=method,
|
||||||
|
headers={"Authorization": f"token {self.token}", "Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
with urlopen(request) as response:
|
||||||
|
content = response.read()
|
||||||
|
return None if not content else json.loads(content)
|
||||||
|
|
||||||
|
def issue(self, number: int) -> dict[str, object]:
|
||||||
|
return self.request("GET", f"/repos/{self.repository}/issues/{number}") # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
def commit_messages(sha: str) -> str:
|
||||||
|
try:
|
||||||
|
previous = subprocess.check_output(
|
||||||
|
["git", "describe", "--tags", "--abbrev=0", f"{sha}^"],
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
text=True,
|
||||||
|
).strip()
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
previous = ""
|
||||||
|
revision = f"{previous}..{sha}" if previous else sha
|
||||||
|
return subprocess.check_output(["git", "log", "--format=%s%n%b", revision], text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def prepare() -> None:
|
||||||
|
gitea = Gitea()
|
||||||
|
tag = os.environ["GITEA_REF_NAME"]
|
||||||
|
sha = os.environ["GITEA_SHA"]
|
||||||
|
server = os.environ["GITEA_SERVER_URL"].rstrip("/")
|
||||||
|
|
||||||
|
issues = [gitea.issue(number) for number in issue_numbers(commit_messages(sha))]
|
||||||
|
closed = [issue for issue in issues if issue["state"] == "closed"]
|
||||||
|
payload = {
|
||||||
|
"tag_name": tag,
|
||||||
|
"target_commitish": sha,
|
||||||
|
"name": tag,
|
||||||
|
"body": release_body(closed, server, gitea.repository, sha),
|
||||||
|
"draft": True,
|
||||||
|
"prerelease": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
release = gitea.request("GET", f"/repos/{gitea.repository}/releases/tags/{quote(tag, safe='')}")
|
||||||
|
except HTTPError as error:
|
||||||
|
if error.code != 404:
|
||||||
|
raise
|
||||||
|
release = gitea.request("POST", f"/repos/{gitea.repository}/releases", payload)
|
||||||
|
else:
|
||||||
|
if not release["draft"]: # type: ignore[index]
|
||||||
|
raise RuntimeError(f"release {tag} is already published")
|
||||||
|
release = gitea.request("PATCH", f"/repos/{gitea.repository}/releases/{release['id']}", payload) # type: ignore[index]
|
||||||
|
for asset in release["assets"] or []: # type: ignore[index]
|
||||||
|
gitea.request(
|
||||||
|
"DELETE",
|
||||||
|
f"/repos/{gitea.repository}/releases/{release['id']}/assets/{asset['id']}", # type: ignore[index]
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
|
||||||
|
output.write(f"release_id={release['id']}\n") # type: ignore[index]
|
||||||
|
|
||||||
|
|
||||||
|
def publish() -> None:
|
||||||
|
gitea = Gitea()
|
||||||
|
release_id = int(os.environ["RELEASE_ID"])
|
||||||
|
release = gitea.request("GET", f"/repos/{gitea.repository}/releases/{release_id}")
|
||||||
|
validate_assets(release["assets"] or [], os.environ["GITEA_REF_NAME"]) # type: ignore[index]
|
||||||
|
gitea.request("PATCH", f"/repos/{gitea.repository}/releases/{release_id}", {"draft": False})
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseTests(unittest.TestCase):
|
||||||
|
def test_notes_are_unique_sorted_grouped_and_linked(self) -> None:
|
||||||
|
self.assertEqual(issue_numbers("Fix #12, refs #3 and #12"), [3, 12])
|
||||||
|
body = release_body(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"number": 12,
|
||||||
|
"title": "Fix [unlock]",
|
||||||
|
"html_url": "https://example.test/issues/12",
|
||||||
|
"labels": [{"name": "bug"}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"number": 3,
|
||||||
|
"title": "Add search",
|
||||||
|
"html_url": "https://example.test/issues/3",
|
||||||
|
"labels": [{"name": "enhancement"}],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"https://example.test",
|
||||||
|
"owner/repo",
|
||||||
|
"1234567890abcdef",
|
||||||
|
)
|
||||||
|
self.assertIn("## Fixes\n- [#12 Fix \\[unlock\\]](https://example.test/issues/12)", body)
|
||||||
|
self.assertIn("## Improvements\n- [#3 Add search](https://example.test/issues/3)", body)
|
||||||
|
self.assertIn("[1234567890ab](https://example.test/owner/repo/commit/1234567890abcdef)", body)
|
||||||
|
|
||||||
|
def test_manifest_is_exact_and_non_empty(self) -> None:
|
||||||
|
names = asset_names("v1.0.0/rc1")
|
||||||
|
assets = [{"name": name, "size": 1} for name in names]
|
||||||
|
validate_assets(assets, "v1.0.0/rc1")
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
validate_assets(assets[:-1], "v1.0.0/rc1")
|
||||||
|
assets[0]["size"] = 0
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
validate_assets(assets, "v1.0.0/rc1")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
command = sys.argv[1] if len(sys.argv) == 2 else ""
|
||||||
|
if command == "prepare":
|
||||||
|
prepare()
|
||||||
|
elif command == "publish":
|
||||||
|
publish()
|
||||||
|
elif command == "test":
|
||||||
|
unittest.main(argv=[sys.argv[0]])
|
||||||
|
else:
|
||||||
|
raise SystemExit("usage: release.py prepare|publish|test")
|
||||||
21
.gitea/workflows/dependency-audit.yml
Normal file
21
.gitea/workflows/dependency-audit.yml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
name: Weekly OSV dependency audit
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# Runs every Monday. Change the minute to stagger repositories.
|
||||||
|
- cron: "17 3 * * 1"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
dependency-audit:
|
||||||
|
runs-on: linux-arm64
|
||||||
|
timeout-minutes: 20
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Scan dependencies for known vulnerabilities
|
||||||
|
uses: docker://ghcr.io/google/osv-scanner:v2
|
||||||
|
with:
|
||||||
|
args: scan source --recursive .
|
||||||
179
.gitea/workflows/release.yml
Normal file
179
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
name: Tagged release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "*"
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
prepare-release:
|
||||||
|
runs-on: linux-arm64
|
||||||
|
outputs:
|
||||||
|
release_id: ${{ steps.release.outputs.release_id }}
|
||||||
|
steps:
|
||||||
|
- name: Check out the tag
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Test release handling
|
||||||
|
run: python3 .gitea/scripts/release.py test
|
||||||
|
|
||||||
|
- name: Create draft release
|
||||||
|
id: release
|
||||||
|
env:
|
||||||
|
GITEA_API_URL: ${{ gitea.server_url }}/api/v1
|
||||||
|
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||||
|
GITEA_REF_NAME: ${{ gitea.ref_name }}
|
||||||
|
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
||||||
|
GITEA_SHA: ${{ gitea.sha }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: python3 .gitea/scripts/release.py prepare
|
||||||
|
|
||||||
|
build-unix:
|
||||||
|
needs: prepare-release
|
||||||
|
runs-on: linux-arm64
|
||||||
|
container: ghcr.io/rust-cross/cargo-zigbuild@sha256:82af75c41958c2af2787e8bedd912da7678a9438937e223e9d83d006d747b38b
|
||||||
|
steps:
|
||||||
|
- name: Check out the exact tag
|
||||||
|
env:
|
||||||
|
GITEA_REF_NAME: ${{ gitea.ref_name }}
|
||||||
|
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||||
|
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
git init
|
||||||
|
git -c http.extraHeader="Authorization: token $GITEA_TOKEN" fetch --depth=1 \
|
||||||
|
"$GITEA_SERVER_URL/$GITEA_REPOSITORY.git" "refs/tags/$GITEA_REF_NAME"
|
||||||
|
git checkout --detach FETCH_HEAD
|
||||||
|
|
||||||
|
- name: Build, verify, package, and upload Darwin and Linux binaries
|
||||||
|
env:
|
||||||
|
GITEA_API_URL: ${{ gitea.server_url }}/api/v1
|
||||||
|
GITEA_REF_NAME: ${{ gitea.ref_name }}
|
||||||
|
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
RELEASE_ID: ${{ needs.prepare-release.outputs.release_id }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
version=$(printf '%s' "$GITEA_REF_NAME" | sed 's/[^A-Za-z0-9._-]/-/g')
|
||||||
|
for build in \
|
||||||
|
'aarch64-apple-darwin|aarch64-apple-darwin|darwin-arm64|cffaedfe' \
|
||||||
|
'x86_64-apple-darwin|x86_64-apple-darwin|darwin-x64|cffaedfe' \
|
||||||
|
'aarch64-unknown-linux-gnu|aarch64-unknown-linux-gnu.2.28|linux-arm64|7f454c46' \
|
||||||
|
'x86_64-unknown-linux-gnu|x86_64-unknown-linux-gnu.2.28|linux-x64|7f454c46'
|
||||||
|
do
|
||||||
|
target=${build%%|*}
|
||||||
|
rest=${build#*|}
|
||||||
|
build_target=${rest%%|*}
|
||||||
|
rest=${rest#*|}
|
||||||
|
platform=${rest%%|*}
|
||||||
|
magic=${rest#*|}
|
||||||
|
cargo zigbuild --release --locked --target "$build_target" \
|
||||||
|
--package gotcha-cli --package gotcha-tui
|
||||||
|
|
||||||
|
release="target/$target/release"
|
||||||
|
for binary in gotcha gotcha-tui; do
|
||||||
|
test -s "$release/$binary"
|
||||||
|
actual=$(od -An -tx1 -N4 "$release/$binary" | tr -d ' \n')
|
||||||
|
test "$actual" = "$magic"
|
||||||
|
done
|
||||||
|
|
||||||
|
package="gotcha-${version}-${platform}"
|
||||||
|
mkdir -p "dist/$package"
|
||||||
|
cp "$release/gotcha" "$release/gotcha-tui" "dist/$package/"
|
||||||
|
tar -C dist -czf "dist/${package}.tar.gz" "$package"
|
||||||
|
test "$(tar -tzf "dist/${package}.tar.gz" | wc -l)" -eq 3
|
||||||
|
rm -r "dist/$package"
|
||||||
|
|
||||||
|
asset="dist/${package}.tar.gz"
|
||||||
|
name=$(basename "$asset")
|
||||||
|
curl --fail --silent --show-error --retry 3 \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary "@$asset" \
|
||||||
|
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/releases/$RELEASE_ID/assets?name=$name"
|
||||||
|
done
|
||||||
|
|
||||||
|
build-windows:
|
||||||
|
needs:
|
||||||
|
- prepare-release
|
||||||
|
- build-unix
|
||||||
|
runs-on: linux-arm64
|
||||||
|
container: messense/cargo-xwin@sha256:4696dd4e79edf8569fa99c4b06bd99273e0501c7adc983aa61d57945f795bef0
|
||||||
|
steps:
|
||||||
|
- name: Check out the exact tag
|
||||||
|
env:
|
||||||
|
GITEA_REF_NAME: ${{ gitea.ref_name }}
|
||||||
|
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||||
|
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
git init
|
||||||
|
git -c http.extraHeader="Authorization: token $GITEA_TOKEN" fetch --depth=1 \
|
||||||
|
"$GITEA_SERVER_URL/$GITEA_REPOSITORY.git" "refs/tags/$GITEA_REF_NAME"
|
||||||
|
git checkout --detach FETCH_HEAD
|
||||||
|
|
||||||
|
- name: Install the repository Rust toolchain
|
||||||
|
run: rustup toolchain install 1.92.0 --profile minimal --target x86_64-pc-windows-msvc
|
||||||
|
|
||||||
|
- name: Cross-build release binaries
|
||||||
|
run: >-
|
||||||
|
cargo +1.92.0 xwin build --release --locked
|
||||||
|
--target x86_64-pc-windows-msvc
|
||||||
|
--package gotcha-cli --package gotcha-tui
|
||||||
|
|
||||||
|
- name: Verify, package, and upload Windows binaries
|
||||||
|
env:
|
||||||
|
GITEA_API_URL: ${{ gitea.server_url }}/api/v1
|
||||||
|
GITEA_REF_NAME: ${{ gitea.ref_name }}
|
||||||
|
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
RELEASE_ID: ${{ needs.prepare-release.outputs.release_id }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
version=$(printf '%s' "$GITEA_REF_NAME" | sed 's/[^A-Za-z0-9._-]/-/g')
|
||||||
|
release=target/x86_64-pc-windows-msvc/release
|
||||||
|
for binary in gotcha.exe gotcha-tui.exe; do
|
||||||
|
test -s "$release/$binary"
|
||||||
|
test "$(od -An -tx1 -N2 "$release/$binary" | tr -d ' \n')" = "4d5a"
|
||||||
|
done
|
||||||
|
|
||||||
|
package="gotcha-${version}-windows-x64"
|
||||||
|
mkdir -p "dist/$package"
|
||||||
|
cp "$release/gotcha.exe" "$release/gotcha-tui.exe" "dist/$package/"
|
||||||
|
tar -C dist -czf "dist/${package}.tar.gz" "$package"
|
||||||
|
test "$(tar -tzf "dist/${package}.tar.gz" | wc -l)" -eq 3
|
||||||
|
rm -r "dist/$package"
|
||||||
|
|
||||||
|
asset="dist/${package}.tar.gz"
|
||||||
|
name=$(basename "$asset")
|
||||||
|
curl --fail --silent --show-error --retry 3 \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary "@$asset" \
|
||||||
|
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/releases/$RELEASE_ID/assets?name=$name"
|
||||||
|
|
||||||
|
publish-release:
|
||||||
|
needs:
|
||||||
|
- prepare-release
|
||||||
|
- build-unix
|
||||||
|
- build-windows
|
||||||
|
runs-on: linux-arm64
|
||||||
|
steps:
|
||||||
|
- name: Check out the tag
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Verify the asset manifest and publish the release
|
||||||
|
env:
|
||||||
|
GITEA_API_URL: ${{ gitea.server_url }}/api/v1
|
||||||
|
GITEA_REF_NAME: ${{ gitea.ref_name }}
|
||||||
|
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
RELEASE_ID: ${{ needs.prepare-release.outputs.release_id }}
|
||||||
|
run: python3 .gitea/scripts/release.py publish
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,3 +1,5 @@
|
|||||||
/target
|
/target
|
||||||
.env
|
.env
|
||||||
|
xcuserdata/
|
||||||
|
*.xcuserstate
|
||||||
|
/distribution/
|
||||||
|
|||||||
109
AGENTS.md
109
AGENTS.md
@@ -1,5 +1,90 @@
|
|||||||
# Repository instructions
|
# Repository instructions
|
||||||
|
|
||||||
|
## Gitea issue workflow
|
||||||
|
|
||||||
|
Use this repository's `gotcha` CLI to list, inspect, create, update, comment on,
|
||||||
|
and close Gitea issues. Run it from the repository root so the configured Git
|
||||||
|
remote selects the server and repository:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -q -p gotcha-cli -- issue list
|
||||||
|
cargo run -q -p gotcha-cli -- issue list --milestones "first feature complete release"
|
||||||
|
cargo run -q -p gotcha-cli -- issue show 7
|
||||||
|
cargo run -q -p gotcha-cli -- issue comment 7 < comment.txt
|
||||||
|
cargo run -q -p gotcha-cli -- issue close 7
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `gotcha issue list --help` for state, kind, keyword, label, milestone,
|
||||||
|
author, assignee, mention, date, and pagination filters. Grant network access
|
||||||
|
before invoking commands that contact Gitea in a sandboxed runner.
|
||||||
|
|
||||||
|
## Required pre-commit gates
|
||||||
|
|
||||||
|
Run every gate below from the repository root before committing. Every command
|
||||||
|
must succeed; do not commit code that is unformatted, fails Clippy, or compiles
|
||||||
|
with warnings.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
RUSTFLAGS="-D warnings" cargo check --workspace --all-targets
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not weaken or skip these gates to make a commit pass. Fix the underlying
|
||||||
|
warning, lint, formatting issue, or test failure.
|
||||||
|
|
||||||
|
## Gitea, app, CLI, and Swift ownership boundary
|
||||||
|
|
||||||
|
`gotcha_gitea` owns Gitea networking and authentication plus the behavior of
|
||||||
|
Gitea objects: validation, pagination, name-to-ID resolution, mutations,
|
||||||
|
ownership checks, object relationships, activity targets, commit graphs, and
|
||||||
|
diff parsing. The app and CLI must not import generated Gitea API modules or
|
||||||
|
build generated-client configurations. Add a concrete `Client` operation to
|
||||||
|
`gotcha_gitea` instead. Generated response models may cross the boundary when a
|
||||||
|
frontend only needs to present their fields; the CLI's explicit `api request`
|
||||||
|
command is the sole low-level escape hatch.
|
||||||
|
|
||||||
|
`crates/app` owns application state, persistence, preferences, favorites,
|
||||||
|
content classification, navigation targets, and view-ready UniFFI records. The
|
||||||
|
CLI owns argument and YAML parsing plus terminal formatting. Do not duplicate
|
||||||
|
Gitea object transformations or relationship handling in either frontend.
|
||||||
|
|
||||||
|
Swift is the native iOS presentation layer. Limit `ios/Sources` to UIKit and
|
||||||
|
SwiftUI lifecycle, view-controller navigation, native controls, layout, drawing,
|
||||||
|
fonts, colors, symbols, accessibility, task cancellation tied to view lifetime,
|
||||||
|
and Apple presentation integrations such as Quick Look. Swift may map
|
||||||
|
Rust-provided states to visual treatments and maintain transient control state;
|
||||||
|
it must not infer domain state from display strings, duplicate Rust
|
||||||
|
transformations, classify content, or implement persistence and API behavior.
|
||||||
|
|
||||||
|
When changing iOS functionality:
|
||||||
|
|
||||||
|
- Trace the complete flow before editing and implement Gitea behavior in
|
||||||
|
`gotcha_gitea` and app behavior in `crates/app` first.
|
||||||
|
- Prefer view-ready Rust records over exposing raw Gitea models or rebuilding
|
||||||
|
titles, summaries, selections, progress, and categories in Swift.
|
||||||
|
- Treat a pure Swift helper that does not require an Apple UI framework as a
|
||||||
|
boundary warning; move it to Rust unless it only calculates view geometry.
|
||||||
|
- Regenerate and commit the UniFFI Swift and C bindings whenever the exported
|
||||||
|
Rust interface changes.
|
||||||
|
- During review, inspect both sides of the bridge and reject new business logic
|
||||||
|
added to Swift merely because its caller is a view controller.
|
||||||
|
|
||||||
|
## UI verification conventions
|
||||||
|
|
||||||
|
`TESTING.md` is the source of truth for iOS visual verification and the release
|
||||||
|
regression suite.
|
||||||
|
|
||||||
|
- Update `TESTING.md` in the same commit whenever UI appearance, behavior,
|
||||||
|
navigation, or interaction changes so its scenarios remain current.
|
||||||
|
- For each ordinary commit, run every `TESTING.md` scenario relevant to the
|
||||||
|
changed UI in addition to the required pre-commit gates. Report which visual
|
||||||
|
scenarios were exercised.
|
||||||
|
- When asked to perform a release test, run the complete `TESTING.md` checklist.
|
||||||
|
Every scenario must succeed before reporting release sign-off; record failures
|
||||||
|
and resolve release blockers instead of skipping them.
|
||||||
|
|
||||||
## iOS simulator build and deployment
|
## iOS simulator build and deployment
|
||||||
|
|
||||||
This Apple Silicon project builds the simulator app for `arm64`. Do not disable
|
This Apple Silicon project builds the simulator app for `arm64`. Do not disable
|
||||||
@@ -45,6 +130,30 @@ iPhone (currently `xcrun simctl boot "iPhone 17 Pro"`) and open Simulator.
|
|||||||
CoreSimulator access may require running `xcodebuild` and `simctl` outside the
|
CoreSimulator access may require running `xcodebuild` and `simctl` outside the
|
||||||
workspace sandbox.
|
workspace sandbox.
|
||||||
|
|
||||||
|
## Post-issue release handling
|
||||||
|
|
||||||
|
After an issue is verified, committed, pushed, commented on, and closed, handle
|
||||||
|
the completed version according to the code it changes:
|
||||||
|
|
||||||
|
- For CLI changes, build `gotcha-cli` in release mode and install the resulting
|
||||||
|
`gotcha` executable in `~/.local/bin/` so the command is available on `PATH`.
|
||||||
|
- For iOS app changes, validate the completed app in the simulator. Never install
|
||||||
|
an Xcode-run or otherwise development-signed build on a physical iPhone; this
|
||||||
|
would replace the correctly signed AltStore PAL installation.
|
||||||
|
- Physical iPhone installs and updates must use the complete AltStore PAL release
|
||||||
|
process: create and validate a distribution-signed Release archive, submit it
|
||||||
|
for notarization, publish the accepted Alternative Distribution Package and
|
||||||
|
source update, then install or update Gotcha through AltStore PAL.
|
||||||
|
- Publishing an AltStore PAL release is not an automatic per-issue step. Perform
|
||||||
|
it only when the user explicitly requests and authorizes release publication.
|
||||||
|
- The Gitea crate is shared by the CLI and iOS app, so changes to that crate also
|
||||||
|
require simulator validation and inclusion in the next authorized AltStore PAL
|
||||||
|
release.
|
||||||
|
|
||||||
|
A purely CLI change does not require iOS validation. If an issue changes both
|
||||||
|
release surfaces, install the CLI release and validate the iOS app in the
|
||||||
|
simulator.
|
||||||
|
|
||||||
## Generated build data
|
## Generated build data
|
||||||
|
|
||||||
Use Xcode's default DerivedData location; do not pass `-derivedDataPath`.
|
Use Xcode's default DerivedData location; do not pass `-derivedDataPath`.
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
# Gitea API coverage
|
# Gitea-compatible API coverage
|
||||||
|
|
||||||
Source: the OpenAPI contract served by the configured Gitea 1.25.4 instance.
|
Source: the OpenAPI contract served by the configured Gitea 1.25.4 instance.
|
||||||
It contains 299 paths, 467 operations, 243 read/query operations, and 216 schema
|
It contains 299 paths, 467 operations, 243 read/query operations, and 216 schema
|
||||||
definitions.
|
definitions.
|
||||||
|
|
||||||
`gotcha_gitea` exposes the complete typed Gitea 1.25 client through
|
`gotcha_gitea` encapsulates the generated Gitea 1.25 client. Its concrete
|
||||||
`gotcha_gitea::apis` and every generated request/response model through
|
`Client` methods own the supported Gitea and Forgejo workflows and
|
||||||
`gotcha_gitea::models`. `Client::configuration()` supplies the selected base URL
|
relationships, while
|
||||||
and token to those generated functions.
|
`gotcha_gitea::models` exposes generated response records for presentation.
|
||||||
|
Generated API modules and client configuration are deliberately private so the
|
||||||
|
CLI and app cannot duplicate Gitea behavior.
|
||||||
|
|
||||||
| Domain | Queries | Other actions | API crate | CLI queries | CLI writes | Capabilities |
|
Forgejo uses operations from its advertised Gitea 1.22-compatible `/api/v1`
|
||||||
|
surface. Gotcha must not assume that newer operations from its generated Gitea
|
||||||
|
1.25 client exist on Forgejo: provider-specific endpoints and any operation
|
||||||
|
outside the shared contract are routed inside `gotcha_gitea::Client`.
|
||||||
|
|
||||||
|
| Domain | Queries | Other actions | Generated client | CLI queries | CLI writes | Capabilities |
|
||||||
| --- | ---: | ---: | --- | ---: | ---: | --- |
|
| --- | ---: | ---: | --- | ---: | ---: | --- |
|
||||||
| activitypub | 1 | 1 | Complete | 0 | 0 | Person actors and inbox federation |
|
| activitypub | 1 | 1 | Complete | 0 | 0 | Person actors and inbox federation |
|
||||||
| admin | 14 | 18 | Complete | 0 | 0 | Users, organizations, email, hooks, cron, unadopted repositories, Actions runners/jobs/runs |
|
| admin | 14 | 18 | Complete | 0 | 0 | Users, organizations, email, hooks, cron, unadopted repositories, Actions runners/jobs/runs |
|
||||||
@@ -31,11 +38,14 @@ Typed text commands currently implemented include:
|
|||||||
- `user show` → `userGetCurrent`
|
- `user show` → `userGetCurrent`
|
||||||
- `repo list` → `userCurrentListRepos`
|
- `repo list` → `userCurrentListRepos`
|
||||||
- `repo show` → `repoGet`
|
- `repo show` → `repoGet`
|
||||||
- issue list/show/create/edit/delete/comments/comment
|
- issue list with state, kind, keyword, label, milestone, author, assignee,
|
||||||
|
mention, date, and pagination filters; show/create/edit/close/reopen/delete;
|
||||||
|
comments/comment
|
||||||
- milestone list/show/create/edit/delete
|
- milestone list/show/create/edit/delete
|
||||||
- pull list/show/create/edit/merge/commits/files/reviews
|
- pull list/show/create/edit/merge/commits/files/reviews
|
||||||
|
|
||||||
The remaining 229 query operations and 214 mutation operations are available to
|
The remaining 229 query operations and 214 mutation operations are represented
|
||||||
Rust callers through the typed API modules but do not yet have purpose-built CLI
|
by the private generated client but do not yet have shared domain methods or
|
||||||
commands or text/input views. `api request` remains an explicit JSON escape hatch;
|
purpose-built CLI commands. Add new workflows to `gotcha_gitea::Client` before
|
||||||
it is not counted as typed CLI coverage.
|
using them in either frontend. `api request` remains an explicit JSON escape
|
||||||
|
hatch; it is not counted as typed CLI coverage.
|
||||||
|
|||||||
4464
Cargo.lock
generated
4464
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = ["crates/gitea", "crates/cli", "crates/app"]
|
members = ["crates/gitea", "crates/cli", "crates/app", "crates/tui"]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
@@ -11,7 +11,6 @@ rust-version = "1.92"
|
|||||||
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] }
|
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync"] }
|
||||||
rpassword = "7"
|
rpassword = "7"
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
slint = { version = "=1.17.1", default-features = false, features = ["std", "backend-winit", "renderer-skia", "compat-1-2"] }
|
|
||||||
|
|||||||
119
README.md
119
README.md
@@ -1,16 +1,30 @@
|
|||||||
# Gotcha
|
# Gotcha
|
||||||
|
|
||||||
Gotcha is a lightweight Gitea client built in Rust. Development starts with a
|
Gotcha is a lightweight Gitea and Forgejo client with a reusable Rust core, a
|
||||||
reusable API crate and a CLI test bed; the same crate will back the Slint iOS
|
CLI, a Ratatui terminal interface, and a native iOS application. The iOS
|
||||||
application.
|
interface is UIKit/Swift; UniFFI exposes the Rust application logic to Swift.
|
||||||
|
|
||||||
|
On the icon: git in a tea cup. it is obvious, isn't it?
|
||||||
|
|
||||||
|
On the name: well, tea, we established that. there is something tea like called
|
||||||
|
matcha. and this is git. so gotcha, because gitcha sounds dumb. Stop rolling
|
||||||
|
your eyes.
|
||||||
|
|
||||||
|
In general, this app is mainly meant for self-hosters who run their own small
|
||||||
|
installation of gitea and want to pay attention to what their AI agents push to
|
||||||
|
their projects. decentralisation is the game, so this is for that use case. this is
|
||||||
|
not necessarily the app to use when you manage or frequent a large instance.
|
||||||
|
|
||||||
## Workspace
|
## Workspace
|
||||||
|
|
||||||
- `gotcha_gitea`: reusable asynchronous Gitea API client with the complete
|
- `gotcha_gitea`: reusable asynchronous Gitea/Forgejo API client with the
|
||||||
typed Gitea 1.25 API and model surface
|
complete typed Gitea 1.25-compatible API and model surface
|
||||||
- `gotcha`: CLI for typed common operations and arbitrary API requests
|
- `gotcha`: CLI for typed common operations and arbitrary API requests
|
||||||
- `gotcha-app`: Slint iOS client for servers, owned repositories, favorites,
|
- `gotcha-tui`: paned, keyboard-and-mouse terminal application
|
||||||
open issues, and issue details; bundle identifier `de.rfc1437.gotcha`
|
- `gotcha-app`: Rust application core and UniFFI API used by iOS
|
||||||
|
- `ios`: native UIKit application for activity, repositories, favorites,
|
||||||
|
issues, milestones, pull requests, commit history, changed files, and diffs; bundle
|
||||||
|
identifier `de.rfc1437.gotcha`
|
||||||
|
|
||||||
The generated API modules and models cover every Gitea 1.25 operation. The
|
The generated API modules and models cover every Gitea 1.25 operation. The
|
||||||
low-level request API remains available for newer instance-specific endpoints;
|
low-level request API remains available for newer instance-specific endpoints;
|
||||||
@@ -21,31 +35,58 @@ separate API-crate and CLI coverage status.
|
|||||||
|
|
||||||
## CLI
|
## CLI
|
||||||
|
|
||||||
|
Build the workspace with `cargo build`; the CLI executable is written to
|
||||||
|
`target/debug/gotcha`. To build only the CLI, use `cargo build -p gotcha-cli`.
|
||||||
|
|
||||||
|
Create an optimized release build of the CLI with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo build --release -p gotcha-cli
|
||||||
|
```
|
||||||
|
|
||||||
|
The executable is written to `target/release/gotcha`.
|
||||||
|
|
||||||
Store each server profile in `~/.config/gotcha/config`:
|
Store each server profile in `~/.config/gotcha/config`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo run -p gotcha-cli -- auth login gitea.example.com
|
cargo run -p gotcha-cli -- auth login gitea.example.com
|
||||||
|
cargo run -p gotcha-cli -- auth login forgejo.example.com --provider forgejo
|
||||||
|
|
||||||
cargo run -p gotcha-cli -- server version
|
cargo run -p gotcha-cli -- server version
|
||||||
cargo run -p gotcha-cli -- user show
|
cargo run -p gotcha-cli -- user show
|
||||||
cargo run -p gotcha-cli -- repo list
|
cargo run -p gotcha-cli -- repo list
|
||||||
cargo run -p gotcha-cli -- repo show
|
cargo run -p gotcha-cli -- repo show
|
||||||
cargo run -p gotcha-cli -- issue list
|
cargo run -p gotcha-cli -- issue list
|
||||||
|
cargo run -p gotcha-cli -- issue list --milestones "Version 1.0" --state open
|
||||||
|
cargo run -p gotcha-cli -- issue show 7
|
||||||
|
cargo run -p gotcha-cli -- issue close 7
|
||||||
cargo run -p gotcha-cli -- milestone list
|
cargo run -p gotcha-cli -- milestone list
|
||||||
cargo run -p gotcha-cli -- pull list
|
cargo run -p gotcha-cli -- pull list
|
||||||
|
cargo run -p gotcha-cli -- action workflow list
|
||||||
|
cargo run -p gotcha-cli -- action workflow dispatch dependency-audit.yml main
|
||||||
|
cargo run -p gotcha-cli -- action run list
|
||||||
|
cargo run -p gotcha-cli -- action run show 95
|
||||||
|
cargo run -p gotcha-cli -- action run logs 95
|
||||||
cargo run -p gotcha-cli -- api request GET repos/owner/project/issues
|
cargo run -p gotcha-cli -- api request GET repos/owner/project/issues
|
||||||
cargo run -p gotcha-cli -- api request POST user/repos '{"name":"demo"}'
|
cargo run -p gotcha-cli -- api request POST user/repos '{"name":"demo"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
`auth login` derives `https://gitea.example.com` from the server name and reads
|
`auth login` derives `https://gitea.example.com` from the server name, discovers
|
||||||
the token from standard input with echo disabled. The resulting plain YAML file
|
Gitea or Forgejo, and reads the token from standard input with echo disabled.
|
||||||
at `~/.config/gotcha/config` has mode `0600` and one entry per server:
|
The optional provider requires the selected API when automatic discovery is
|
||||||
|
not sufficient. The resulting plain YAML file at `~/.config/gotcha/config` has
|
||||||
|
mode `0600` and one entry per server:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
servers:
|
servers:
|
||||||
gitea.example.com:
|
gitea.example.com:
|
||||||
url: https://gitea.example.com
|
url: https://gitea.example.com
|
||||||
token: your-token
|
token: your-token
|
||||||
|
provider: gitea
|
||||||
|
forgejo.example.com:
|
||||||
|
url: https://forgejo.example.com
|
||||||
|
token: your-token
|
||||||
|
provider: forgejo
|
||||||
```
|
```
|
||||||
|
|
||||||
Inside a Git repository, Gotcha matches its remotes to these server URLs and
|
Inside a Git repository, Gotcha matches its remotes to these server URLs and
|
||||||
@@ -53,19 +94,56 @@ derives the `owner/repository` scope. Use `--server gitea.example.com` when sele
|
|||||||
ambiguous, or `--url`/`GITEA_URL` for an unconfigured server. Tokens are never
|
ambiguous, or `--url`/`GITEA_URL` for an unconfigured server. Tokens are never
|
||||||
accepted as command-line arguments or environment variables.
|
accepted as command-line arguments or environment variables.
|
||||||
|
|
||||||
## Direction
|
## Terminal UI
|
||||||
|
|
||||||
1. Exercise and type API areas in the CLI: repositories, issues and pull
|
Build and run the standalone TUI with an existing CLI server profile:
|
||||||
requests, Actions, notifications, organizations, packages, administration.
|
|
||||||
2. Add the Slint shell and move proven read workflows into touch-first screens.
|
```sh
|
||||||
3. Add write workflows and iOS integrations such as sharing, notifications,
|
cargo run -p gotcha-tui
|
||||||
and background refresh.
|
cargo run -p gotcha-tui -- --server gitea.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
The five numbered panes mirror the iPhone app: Home, Issues, Repositories,
|
||||||
|
pull requests, and Milestones. Use `j`/`k` or the arrow keys to select rows,
|
||||||
|
Enter to open, Backspace to return, `/` for list filters, and `a`, `e`, `c`,
|
||||||
|
`x`, and `d` for mutations. Use `n`/`p` for API result pages; scrolling the
|
||||||
|
mouse wheel past a page boundary does the same. Repository lists support `*` favorites, commit
|
||||||
|
lists support `b` branch switching, and Home supports `v` activity filters.
|
||||||
|
Editors use Tab between fields and Ctrl-S to save. Mouse selection,
|
||||||
|
double-click, and wheel scrolling work in ordinary terminals and Herdr.
|
||||||
|
|
||||||
|
Overview panes refresh every five seconds by default. Press `,` to change the
|
||||||
|
interval or set it to zero. Refresh waits while an editor or confirmation is
|
||||||
|
open and after keyboard or mouse activity so it does not move the current
|
||||||
|
selection during interaction.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
`gotcha_gitea` owns Gitea/Forgejo access, authentication, validation,
|
||||||
|
mutations, relationships between server objects, and shared CLI/TUI server
|
||||||
|
configuration. The CLI and TUI are terminal presentation layers that invoke
|
||||||
|
shared client operations. `gotcha-app` owns application state, preferences, favorites,
|
||||||
|
Keychain-backed credentials, and view-ready UniFFI records. UIKit owns native
|
||||||
|
navigation, controls, layout, and other platform presentation behavior.
|
||||||
|
|
||||||
|
New server workflows belong in `gotcha_gitea::Client` first, then receive CLI or
|
||||||
|
app presentation as needed. Future work includes iOS integrations such as
|
||||||
|
sharing, notifications, and background refresh.
|
||||||
|
|
||||||
## iOS app
|
## iOS app
|
||||||
|
|
||||||
Server tokens are kept in the Apple Keychain; the JSON preferences contain only
|
Prebuilt iPhone releases will be distributed through the
|
||||||
server metadata and favorites. The app requires Xcode, XcodeGen, an installed
|
[rfc1437 Apps AltStore PAL source](https://rfc1437.de/apps/). The source page
|
||||||
iOS Simulator runtime, and Rust's
|
explains how to add it to AltStore PAL and install available apps.
|
||||||
|
|
||||||
|
Development builds are validated in the iOS Simulator. Physical iPhones must
|
||||||
|
install and update Gotcha only through the signed and notarized AltStore PAL
|
||||||
|
release process; do not replace the marketplace installation with an Xcode-run
|
||||||
|
or otherwise development-signed build.
|
||||||
|
|
||||||
|
Server tokens are kept in the Apple Keychain; JSON preferences contain only
|
||||||
|
server metadata, settings, and favorites. The app requires Xcode, XcodeGen, an
|
||||||
|
installed iOS Simulator runtime, and Rust's
|
||||||
`aarch64-apple-ios` and `aarch64-apple-ios-sim` targets. Generate the project
|
`aarch64-apple-ios` and `aarch64-apple-ios-sim` targets. Generate the project
|
||||||
after installing those prerequisites:
|
after installing those prerequisites:
|
||||||
|
|
||||||
@@ -76,3 +154,6 @@ open Gotcha.xcodeproj
|
|||||||
```
|
```
|
||||||
|
|
||||||
For a fast host-side check, run `cargo test -p gotcha-app`.
|
For a fast host-side check, run `cargo test -p gotcha-app`.
|
||||||
|
|
||||||
|
See [TESTING.md](TESTING.md) for the complete build, simulator, gesture, and
|
||||||
|
release regression checklist.
|
||||||
|
|||||||
625
TESTING.md
Normal file
625
TESTING.md
Normal file
@@ -0,0 +1,625 @@
|
|||||||
|
# Testing Gotcha
|
||||||
|
|
||||||
|
This document is the source of truth for iOS and terminal UI verification.
|
||||||
|
Every UI change must update its relevant scenarios in the same commit. For
|
||||||
|
ordinary commits, run the build gates and every scenario affected by the
|
||||||
|
change. A release test means completing this entire checklist successfully on
|
||||||
|
the stated terminals, simulators, and devices; record failures as open issues
|
||||||
|
and do not sign off until every scenario passes.
|
||||||
|
|
||||||
|
## Test record
|
||||||
|
|
||||||
|
- Release/build:
|
||||||
|
- Commit:
|
||||||
|
- Date and tester:
|
||||||
|
- Xcode and iOS versions:
|
||||||
|
- Simulator/device:
|
||||||
|
- Gitea server version:
|
||||||
|
- Result and open issues:
|
||||||
|
|
||||||
|
Use a real test account with repositories, open and closed issues, open and
|
||||||
|
closed pull requests, multiple branches, commits, comments, and file changes.
|
||||||
|
Never paste a production token into logs, screenshots, source files, or this
|
||||||
|
document. The app stores entered tokens in the Apple Keychain.
|
||||||
|
|
||||||
|
Run both of these passes:
|
||||||
|
|
||||||
|
- Fresh install on a disposable simulator, including Add Server.
|
||||||
|
- Upgrade/reinstall over the previous build, preserving app data and Keychain.
|
||||||
|
|
||||||
|
## Build gates
|
||||||
|
|
||||||
|
From the repository root, all commands must pass without warnings:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
RUSTFLAGS="-D warnings" cargo check --workspace --all-targets
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
## Terminal UI
|
||||||
|
|
||||||
|
Run `cargo run -p gotcha-tui -- --server NAME` in a normal terminal and in a
|
||||||
|
Herdr pane. Use a non-production account with at least two server profiles and
|
||||||
|
representative repositories.
|
||||||
|
|
||||||
|
- [ ] The six numbered panes open Home, Issues, Repos, PRs, Milestones, and
|
||||||
|
Actions;
|
||||||
|
the selected pane and row remain visibly distinct at narrow and wide
|
||||||
|
terminal sizes.
|
||||||
|
- [ ] `j`/`k`, arrows, `g`/`G`, Page Up/Down, Enter, and Backspace provide
|
||||||
|
mutt-style list and history navigation; `q` exits immediately from every
|
||||||
|
non-editor depth. `n`/`p` and scrolling past a boundary traverse
|
||||||
|
paginated API results without losing history.
|
||||||
|
- [ ] Clicking a pane switches it, clicking a list row selects it,
|
||||||
|
double-clicking opens it, and the mouse wheel scrolls the list.
|
||||||
|
- [ ] Home activity filters cycle with `v`; activity targets route to their
|
||||||
|
repository, issue, pull request, or commit.
|
||||||
|
- [ ] A commit activity preview shows its commit count, short hashes, messages,
|
||||||
|
authors, and timestamps as a readable list rather than raw JSON; opening
|
||||||
|
it shows the head commit and its changed files.
|
||||||
|
- [ ] Repository favorites toggle with `*`, sort first per pane, and persist
|
||||||
|
after relaunch. Commits switch branches with `b`; file trees, text and
|
||||||
|
binary files, commit changes, pull changes, and per-file diffs open.
|
||||||
|
- [ ] Source files use grammar-based syntax colors selected by filename or
|
||||||
|
shebang, with unknown text remaining legible. Commit and pull diffs show
|
||||||
|
metadata, hunks, additions, and deletions in distinct syntax colors.
|
||||||
|
- [ ] Home activity content plus issue, pull request, milestone, and comment
|
||||||
|
Markdown pseudo-renders headings, emphasis, strong text, lists, links,
|
||||||
|
inline code, and fenced code while preserving metadata line breaks.
|
||||||
|
- [ ] Issue and pull filters apply. Issues can be created, edited,
|
||||||
|
closed/reopened, and deleted after confirmation; issue comments can be
|
||||||
|
added and the current user's comments edited.
|
||||||
|
- [ ] Milestones can be created, edited, closed/reopened, and deleted after
|
||||||
|
confirmation; milestone details route to their issues and pulls.
|
||||||
|
- [ ] Actions selects a repository, lists workflows and paginated runs, and
|
||||||
|
preserves selection while active runs refresh. Dispatch a workflow with a
|
||||||
|
branch or tag plus multiple `KEY=VALUE` inputs; open the resulting run,
|
||||||
|
inspect every job, open its grouped setup/task/completion entries, and
|
||||||
|
open each group's complete scrollable log text. Verify a long multi-job
|
||||||
|
workflow such as RuDS release run 30 stays navigable without flattening
|
||||||
|
all jobs and logs into one view.
|
||||||
|
Queued, running, successful, failed, and cancelled states remain distinct.
|
||||||
|
- [ ] Server profiles can be added, authenticated, selected, edited, renamed,
|
||||||
|
and deleted. Blank token on edit preserves the existing token; removing
|
||||||
|
the last server leaves the server manager available.
|
||||||
|
- [ ] Select a different server, quit, and relaunch without `--server`; the TUI
|
||||||
|
restores that server. Relaunch with `--server NAME`; the explicit server
|
||||||
|
wins and becomes the restored server after a clean exit. Renaming or
|
||||||
|
deleting the remembered profile leaves a valid selection.
|
||||||
|
- [ ] Editors move between fields with Tab/Shift-Tab, support cursor movement,
|
||||||
|
Unicode insertion, Delete, and Backspace without navigating back, mask
|
||||||
|
tokens, save with Ctrl-S, and cancel with Esc.
|
||||||
|
- [ ] The default overview refresh interval is five seconds. Changing it with
|
||||||
|
`,` persists after relaunch; refresh preserves selection and pauses while
|
||||||
|
editing, confirming, scrolling, or otherwise interacting.
|
||||||
|
- [ ] `cargo build --release -p gotcha-tui` produces
|
||||||
|
`target/release/gotcha-tui`, and the installed executable starts from
|
||||||
|
`PATH`.
|
||||||
|
|
||||||
|
## Actions CLI
|
||||||
|
|
||||||
|
- [ ] `gotcha action workflow list OWNER/REPOSITORY` lists the repository's
|
||||||
|
workflows without using `api request`.
|
||||||
|
- [ ] `gotcha action workflow dispatch OWNER/REPOSITORY WORKFLOW --ref REF`
|
||||||
|
accepts repeated `--input KEY=VALUE` values, rejects malformed or
|
||||||
|
duplicate inputs, and creates a run with the requested ref and inputs.
|
||||||
|
- [ ] `gotcha action run list OWNER/REPOSITORY` supports page/limit plus event,
|
||||||
|
branch, status, actor, and SHA filters. `run show` returns every job and
|
||||||
|
step, and `run logs` returns every job's complete log.
|
||||||
|
|
||||||
|
Build and install the simulator app:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd ios
|
||||||
|
xcodegen generate
|
||||||
|
|
||||||
|
xcodebuild \
|
||||||
|
-project Gotcha.xcodeproj \
|
||||||
|
-scheme Gotcha \
|
||||||
|
-configuration Debug \
|
||||||
|
-sdk iphonesimulator \
|
||||||
|
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
|
||||||
|
ARCHS=arm64 \
|
||||||
|
ONLY_ACTIVE_ARCH=YES \
|
||||||
|
build
|
||||||
|
|
||||||
|
gotcha_build_dir="$(
|
||||||
|
xcodebuild \
|
||||||
|
-project Gotcha.xcodeproj \
|
||||||
|
-scheme Gotcha \
|
||||||
|
-configuration Debug \
|
||||||
|
-sdk iphonesimulator \
|
||||||
|
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
|
||||||
|
-showBuildSettings -json \
|
||||||
|
ARCHS=arm64 \
|
||||||
|
ONLY_ACTIVE_ARCH=YES |
|
||||||
|
plutil -extract 0.buildSettings.TARGET_BUILD_DIR raw -o - -
|
||||||
|
)"
|
||||||
|
xcrun simctl install booted "$gotcha_build_dir/Gotcha.app"
|
||||||
|
xcrun simctl launch booted de.rfc1437.gotcha
|
||||||
|
```
|
||||||
|
|
||||||
|
Before AltStore PAL release publication, create a signed Release archive and
|
||||||
|
inspect the packaged application rather than relying on Debug build settings:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd ios
|
||||||
|
xcodebuild \
|
||||||
|
-project Gotcha.xcodeproj \
|
||||||
|
-scheme Gotcha \
|
||||||
|
-configuration Release \
|
||||||
|
-destination 'generic/platform=iOS' \
|
||||||
|
-archivePath /private/tmp/Gotcha.xcarchive \
|
||||||
|
archive
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify the archived app reports version `1.1` and a positive build number, is
|
||||||
|
signed for distribution with the expected bundle identifier and entitlements,
|
||||||
|
contains `PrivacyInfo.xcprivacy`, and declares the correct export-compliance
|
||||||
|
answer before uploading it to App Store Connect for notarization.
|
||||||
|
|
||||||
|
Use the simulator for all development validation. Never install an Xcode-run or
|
||||||
|
otherwise development-signed build on a physical iPhone because that would
|
||||||
|
replace the correctly signed AltStore PAL installation. Physical-device release
|
||||||
|
testing begins only after the accepted Alternative Distribution Package and
|
||||||
|
source update are published, and installs or updates Gotcha through AltStore PAL.
|
||||||
|
|
||||||
|
## Add Server and native text editing
|
||||||
|
|
||||||
|
- [ ] With no configured server, Issues and Repos show the Servers screen and
|
||||||
|
its empty state.
|
||||||
|
- [ ] The add button presents Add Server; Cancel dismisses it.
|
||||||
|
- [ ] API provider is a native single-selection menu with Gitea selected by
|
||||||
|
default and Forgejo available; selecting either updates the displayed
|
||||||
|
value, VoiceOver value, and URL example.
|
||||||
|
- [ ] Name, Server URL, and Access token use native text fields and suitable
|
||||||
|
keyboards; Next advances between fields and Done submits.
|
||||||
|
- [ ] Long-press in every field shows the native loupe, insertion point, and
|
||||||
|
selection handles.
|
||||||
|
- [ ] The standard edit menu offers Cut, Copy, Paste, Select, and Select All as
|
||||||
|
applicable. There is no app-specific editing toolbar.
|
||||||
|
- [ ] Copy text from one field and paste it into another; verify the exact text.
|
||||||
|
- [ ] The token is obscured, remains editable, and does not trigger a password
|
||||||
|
save prompt.
|
||||||
|
- [ ] Empty or invalid values show an error without adding a server.
|
||||||
|
- [ ] Valid Gitea credentials add and select the server; all data tabs load.
|
||||||
|
- [ ] Valid Forgejo credentials add and select the server; repositories,
|
||||||
|
issues, milestones, pulls, activity, commits, files, and mutations use
|
||||||
|
the same native screens and load successfully.
|
||||||
|
- [ ] Leave Gitea selected while adding a modern Forgejo server; provider
|
||||||
|
discovery identifies Forgejo and the server works after relaunch.
|
||||||
|
- [ ] Terminate and relaunch the app; the selected server and Keychain token
|
||||||
|
still work without re-entry.
|
||||||
|
- [ ] Open the server picker from a repository list and switch between at least
|
||||||
|
two configured servers; every data tab changes to the selected server.
|
||||||
|
- [ ] On Home, the leading server button opens the same Servers screen as the
|
||||||
|
other data tabs. Select a different server and verify Home reloads its
|
||||||
|
title, contribution summary, filters, and activity for that server.
|
||||||
|
- [ ] With no selected server, Home still shows its server button and its empty
|
||||||
|
state directs the user to that button.
|
||||||
|
- [ ] Swipe a server row from the trailing edge; Delete and Edit use native
|
||||||
|
contextual actions with symbols. A full swipe invokes Delete, and both a
|
||||||
|
full swipe and a Delete tap still require the same destructive alert.
|
||||||
|
- [ ] Cancel the delete alert; the row, selected server, saved token, favorites,
|
||||||
|
and filters remain unchanged.
|
||||||
|
- [ ] Tap Edit; API provider, name, and URL show the saved values. The secure
|
||||||
|
token field is empty and says Leave unchanged so the stored token is not
|
||||||
|
exposed to the UI.
|
||||||
|
- [ ] Save edits with the token field empty; the existing token still works.
|
||||||
|
Then change the name, URL, provider, and token and save again; the server
|
||||||
|
is revalidated, provider discovery is applied, URL-scoped favorites and
|
||||||
|
filters follow the server, and all data tabs use the edited connection.
|
||||||
|
- [ ] Enter invalid edited credentials or an invalid URL; the editor stays open,
|
||||||
|
shows the error, and preserves the previously working configuration.
|
||||||
|
- [ ] Confirm Delete; the row and Keychain token are removed. If it was selected,
|
||||||
|
the next server is selected (or the previous last row); deleting the last
|
||||||
|
server returns server-dependent tabs to their empty Servers screen.
|
||||||
|
- [ ] Terminate and relaunch after editing and deleting; edited details persist,
|
||||||
|
deleted servers do not return, and remaining servers still authenticate.
|
||||||
|
|
||||||
|
## Native interaction and navigation
|
||||||
|
|
||||||
|
- [ ] Every list scrolls with normal drag, momentum, bounce, and scroll-bar
|
||||||
|
behavior. Rows are flat, use system separators, and have no card-shaped
|
||||||
|
rounded backgrounds.
|
||||||
|
- [ ] Pull past the top of Home, repository lists, issue lists, pull lists,
|
||||||
|
commit history, changed files, issue details, pull details, and diffs.
|
||||||
|
The native refresh control appears, reloads data, and disappears.
|
||||||
|
- [ ] On accounts/repositories with more than 30 results, Home activity,
|
||||||
|
repositories, issues, pull requests, milestones, milestone contents,
|
||||||
|
Action runs, commit history, and pull-request files initially show one
|
||||||
|
page plus a
|
||||||
|
**Pull up or tap to load more**
|
||||||
|
footer. Pull upward past the bottom and verify the footer changes to a
|
||||||
|
native loading indicator, the next page appends without duplicates, and
|
||||||
|
the footer disappears after the final page.
|
||||||
|
- [ ] Tap the load-more footer as an accessibility alternative and repeat while
|
||||||
|
VoiceOver is running. It announces loading, ignores repeated activation
|
||||||
|
while a request is active, preserves the current scroll position, and
|
||||||
|
allows retry after an error.
|
||||||
|
- [ ] Pull down to refresh after loading multiple pages. The content resets to
|
||||||
|
the new first page, filtering/branch changes also reset pagination, and
|
||||||
|
switching History/Files never shows a stale load-more footer.
|
||||||
|
- [ ] Tap a row, scroll its detail, and use both the navigation-bar Back button
|
||||||
|
and the left-edge interactive swipe to return.
|
||||||
|
- [ ] From a list scrolled well away from the top, open a detail and go Back.
|
||||||
|
The same rows and scroll offset remain visible.
|
||||||
|
- [ ] Switch tabs while a tab has a pushed detail and a scrolled list; returning
|
||||||
|
to the tab preserves its navigation stack and position.
|
||||||
|
- [ ] Rapidly change tabs, filters, and branches while content loads. The app
|
||||||
|
neither crashes nor replaces a newer result with a cancelled request.
|
||||||
|
- [ ] Loading and error states remain dismissible and do not leave a refresh
|
||||||
|
indicator or navigation-bar spinner running.
|
||||||
|
|
||||||
|
## Home
|
||||||
|
|
||||||
|
- [ ] Home shows the selected server name, contribution total, nine labeled
|
||||||
|
months with gaps between them, and recent activity.
|
||||||
|
- [ ] The nine-month activity strip is horizontally centered beneath its title
|
||||||
|
without clipping either edge.
|
||||||
|
- [ ] Activity rows have the correct icon, repository, summary, and date.
|
||||||
|
- [ ] The compact icon row below the activity graph sits flat on the normal
|
||||||
|
background with no border or pill. **Your activity** is selected by
|
||||||
|
default with a filled accent-colored icon. Switch to **All users** and
|
||||||
|
**Notifications**; only the current timeline uses the accent color and
|
||||||
|
selected accessibility trait, and returning to **Your activity** restores
|
||||||
|
the user's unfiltered feed. Rapid switching never replaces the selected
|
||||||
|
timeline with a cancelled result.
|
||||||
|
- [ ] The timeline mini-icons come first, followed immediately by every
|
||||||
|
non-timeline destination omitted from the tab bar. Each destination opens
|
||||||
|
its native screen and remains reachable when the row must scroll
|
||||||
|
horizontally at large Dynamic Type sizes. A pushed destination
|
||||||
|
keeps the native Back button and returns to the selected Home timeline.
|
||||||
|
- [ ] The **All users** timeline combines activity from every user visible to
|
||||||
|
the selected server account in newest-first order, identifies each actor,
|
||||||
|
supports pull-to-refresh and pagination without duplicates, and uses a
|
||||||
|
native empty state when no activity is visible.
|
||||||
|
Tap another user's repository, issue, pull request, and commit events;
|
||||||
|
each opens the corresponding native detail screen and Back returns to the
|
||||||
|
selected Home timeline.
|
||||||
|
- [ ] The **Notifications** timeline shows open notification rows with unread
|
||||||
|
state, supports pull-to-refresh and pagination, marks a selected unread
|
||||||
|
row read before routing, and keeps non-linkable rows non-tappable.
|
||||||
|
- [ ] Pull-request activity includes older creation and close events beyond the
|
||||||
|
first activity-feed page without requiring a manual pull-up; the empty
|
||||||
|
state appears only after every available page has been checked.
|
||||||
|
- [ ] Tap repository, issue, pull-request, and commit activity. Each opens the
|
||||||
|
matching destination on the Home navigation stack. The navigation-bar
|
||||||
|
Back button and left-edge interactive swipe return directly to Home.
|
||||||
|
- [ ] Non-linkable server activity does not navigate or appear tappable.
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
|
||||||
|
- [ ] Open Actions, select a repository, and switch repeatedly between native
|
||||||
|
Workflows and Runs segments. Pull to refresh both lists; load multiple run
|
||||||
|
pages without duplicate or stale rows and verify active runs refresh
|
||||||
|
automatically without disrupting navigation.
|
||||||
|
- [ ] Open a workflow. The native grouped dispatch form starts with the
|
||||||
|
repository's default branch, requires a non-empty reference, accepts
|
||||||
|
multiline `KEY=VALUE` inputs, keeps standard iOS text selection/editing,
|
||||||
|
reports invalid or duplicate inputs, and dismisses only after the server
|
||||||
|
accepts the dispatch.
|
||||||
|
- [ ] Open queued, running, successful, failed, and cancelled runs. Their
|
||||||
|
symbols, labels, progress, and jobs match Gitea and update while active.
|
||||||
|
Drill down through Run, Job, and grouped setup/task/completion lists;
|
||||||
|
each higher level shows its outcome and a disclosure indicator. Success
|
||||||
|
uses a green filled checkmark, failure a red filled X, and waiting,
|
||||||
|
queued, running, skipped, and cancelled remain visually distinct.
|
||||||
|
Finished jobs and tasks show elapsed time instead of a log-line count. Open a
|
||||||
|
group to verify its complete monospaced log text is selectable, readable,
|
||||||
|
and accessible at large Dynamic Type sizes. Use a long multi-job workflow
|
||||||
|
such as RuDS release run 30 to verify the hierarchy remains manageable.
|
||||||
|
- [ ] Exercise empty repositories, a network error, rapid mode changes, Back,
|
||||||
|
interactive swipe, pull-to-refresh, background/foreground, and request
|
||||||
|
cancellation. No stale result replaces newer content and no loading or
|
||||||
|
refresh indicator remains stuck.
|
||||||
|
|
||||||
|
## Notifications
|
||||||
|
|
||||||
|
- [ ] Launch a fresh install. Gotcha does not request notification permission
|
||||||
|
at launch. Open Settings and turn on **Background notifications**; only
|
||||||
|
then does the standard iOS authorization sheet appear. Deny once and
|
||||||
|
confirm Gotcha keeps the switch off, explains that notifications are
|
||||||
|
disabled, and offers **Open Settings**. The **Notification Settings** row
|
||||||
|
opens Gotcha's page in iOS Settings. Opening Settings and waiting for the
|
||||||
|
authorization status to load must leave scrolling, tabs, and the switch
|
||||||
|
responsive without repeated cell layout or a frozen app.
|
||||||
|
- [ ] Allow notifications. Settings reports the current system authorization,
|
||||||
|
the app switch persists across relaunches, and iOS Settings remains the
|
||||||
|
source of truth for alerts, sounds, Focus, and scheduled summaries.
|
||||||
|
Turning the app switch off cancels pending alerts and background refresh.
|
||||||
|
- [ ] Select the Home **Notifications** timeline. The inline Open/Closed control
|
||||||
|
shows matching server notification threads with a type icon, title,
|
||||||
|
repository, date, Dynamic Type layout, and accessible Open/Closed value.
|
||||||
|
Pull to refresh, switch status repeatedly, and load a list longer than one
|
||||||
|
page without duplicate or stale rows.
|
||||||
|
- [ ] Tap open and closed issue, pull-request, commit, and repository
|
||||||
|
notifications. Each opens the native destination on the Home navigation
|
||||||
|
stack; opening an unread thread marks it read, Back returns to the
|
||||||
|
selected Home timeline, and the next refresh moves it from Open to Closed.
|
||||||
|
- [ ] After the first poll establishes a cursor, create or receive another
|
||||||
|
server notification. Launch the Debug simulator build with
|
||||||
|
`xcrun simctl launch booted de.rfc1437.gotcha --validate-background-notifications`,
|
||||||
|
then background Gotcha. This explicit Debug-only mode grants the poll the
|
||||||
|
same finite background execution time as an ordinary app transition;
|
||||||
|
production builds continue to rely exclusively on `BGAppRefreshTask`.
|
||||||
|
Confirm one ordinary-priority local notification is delivered with no
|
||||||
|
private issue title or repository name on the Lock Screen. A repeated
|
||||||
|
update replaces the same thread alert rather than stacking duplicates;
|
||||||
|
tapping it selects the correct configured server, opens the originating
|
||||||
|
item, and marks the Gitea thread read.
|
||||||
|
- [ ] Bring Gotcha to the foreground while a poll finds an update. It refreshes
|
||||||
|
notification data without displaying a banner over the active app. Leave
|
||||||
|
it backgrounded and confirm iOS, not an in-app timer, chooses subsequent
|
||||||
|
refresh timing.
|
||||||
|
|
||||||
|
## Milestone navigation
|
||||||
|
|
||||||
|
- [ ] Open a milestone, then open one of its issues and one of its pull
|
||||||
|
requests. For each detail, the navigation-bar Back button and left-edge
|
||||||
|
interactive swipe return directly to that milestone, preserving its
|
||||||
|
rows and scroll position.
|
||||||
|
|
||||||
|
## Home-screen widgets
|
||||||
|
|
||||||
|
- [ ] Upgrade over a build with an existing server. Launch Gotcha once, then
|
||||||
|
add both **Recent Activity** and **Open Pull Requests** from the system
|
||||||
|
widget gallery; the saved server and Keychain token work without re-entry.
|
||||||
|
- [ ] Both widgets are offered in medium and large system sizes and use native
|
||||||
|
widget margins, typography, tint, relative update time, and light/dark
|
||||||
|
appearances without clipping at the largest accessibility text size.
|
||||||
|
- [ ] In medium size, Recent Activity shows up to three latest activity rows
|
||||||
|
and Open Pull Requests shows up to two open pull requests. In large size,
|
||||||
|
they show up to ten and five rows respectively. Confirm both the widget
|
||||||
|
gallery previews and live configured timelines use those family-specific
|
||||||
|
row counts. Loading, empty, missing-server, and API-error states remain
|
||||||
|
legible and do not expose account data while the device is locked.
|
||||||
|
- [ ] Add a second server profile, long-press each widget, choose **Edit
|
||||||
|
Widget**, and assign a different server to each. The displayed server
|
||||||
|
name and rows update independently; deleting a selected server changes
|
||||||
|
that widget to its missing-server state instead of showing another
|
||||||
|
server's data.
|
||||||
|
- [ ] Tap Recent Activity; Gotcha opens or foregrounds at the Home root. Tap
|
||||||
|
Open Pull Requests; Gotcha opens or foregrounds at the PR root. Both
|
||||||
|
routes discard a stale detail stack in the destination tab.
|
||||||
|
- [ ] Add, edit, select, and delete server profiles in Gotcha, then return to
|
||||||
|
the Home Screen. Widget configuration choices and timelines refresh to
|
||||||
|
match the persisted server list.
|
||||||
|
|
||||||
|
## Issues
|
||||||
|
|
||||||
|
- [ ] The repository list shows name, description, language, open count, update
|
||||||
|
date, and current favorite state.
|
||||||
|
- [ ] Toggle a favorite and confirm it remains after refresh and relaunch but
|
||||||
|
does not change the same repository's favorite in Repos or Milestones.
|
||||||
|
- [ ] Open a repository; the issue list defaults to the saved Open/Closed filter.
|
||||||
|
- [ ] Repository issue rows and issue rows inside a milestone show a green open
|
||||||
|
or purple closed icon beside the title; mixed milestone results use the
|
||||||
|
correct icon per row and VoiceOver announces each state.
|
||||||
|
- [ ] Change the native filter menu between Open and Closed; the checkmark,
|
||||||
|
rows, and persisted selection update.
|
||||||
|
- [ ] Select one and then multiple labels in the issue filter. Checkmarks and
|
||||||
|
rows update without dismissing the menu; clearing every label restores
|
||||||
|
the unfiltered label result.
|
||||||
|
- [ ] Open **Search Text** in the issue filter, enter a term, and verify the
|
||||||
|
server-filtered rows and menu subtitle update. Combine it with Closed and
|
||||||
|
one or more labels and verify every filter applies together.
|
||||||
|
- [ ] Select a milestone and then All Milestones; the checkmark and issue rows
|
||||||
|
update. Relaunch and revisit repositories to verify label and milestone
|
||||||
|
selections persist independently per repository and status persists.
|
||||||
|
- [ ] The issue-filter icon uses a neutral color for Open + All Milestones + no
|
||||||
|
labels, and the app accent color whenever any non-default filter is set.
|
||||||
|
- [ ] In the issue filter panel, the Search Text, Status, Milestone, and Labels
|
||||||
|
icons use the app accent color independently when their filter is
|
||||||
|
non-default. Search text remains while navigating during the current app
|
||||||
|
session but is empty after relaunch. **Clear Filters** restores empty
|
||||||
|
search + Open + All Milestones + no labels, refreshes the rows, and
|
||||||
|
returns every filter icon to neutral.
|
||||||
|
- [ ] Tap the add button. **New Issue** appears as a native modal with Cancel
|
||||||
|
and Save, title and Markdown body fields, multi-select labels, a
|
||||||
|
single-select optional milestone, a **Closed** switch that defaults off,
|
||||||
|
and an optional inline due-date picker.
|
||||||
|
- [ ] In the new-issue body, enter headings, emphasis, a list, a link, and a
|
||||||
|
fenced code block. **Write** syntax-highlights the Markdown and **Preview**
|
||||||
|
renders it; switching repeatedly preserves the exact source text.
|
||||||
|
- [ ] Save is disabled for an empty or whitespace-only title. Create issues with
|
||||||
|
no labels/milestone/due date and with multiple labels, a milestone, and a
|
||||||
|
due date; each new issue appears in the list and opens its detail.
|
||||||
|
- [ ] Tap the pencil button on an issue. Change its title and Markdown body, replace and
|
||||||
|
clear labels, select and clear its milestone, and add, change, and remove
|
||||||
|
its due date. Turn **Closed** on and save, then edit it again and turn
|
||||||
|
**Closed** off. Save, refresh, and relaunch to verify every change persists.
|
||||||
|
- [ ] In both the repository issue list and the issue section below a milestone,
|
||||||
|
partially swipe an open issue to reveal **Close** and **Delete**. Cancel
|
||||||
|
the destructive Delete confirmation and verify nothing changes. Full-swipe
|
||||||
|
the same row to close it, then full-swipe the closed row to reopen it; each
|
||||||
|
list refreshes only after the server mutation succeeds.
|
||||||
|
- [ ] Cancel both new and edited issues, including with the keyboard and date
|
||||||
|
picker visible. Nothing is saved, the modal dismisses normally, and the
|
||||||
|
issue list/detail remains usable with Dynamic Type and VoiceOver labels.
|
||||||
|
- [ ] Open both an open and a closed issue. A green open or purple closed icon
|
||||||
|
appears beside the title and VoiceOver announces the state; author
|
||||||
|
metadata, every colored label, Markdown body, and comments remain correct.
|
||||||
|
Multiple labels wrap without clipping at large Dynamic Type sizes and
|
||||||
|
VoiceOver announces each label. Links and selectable text use normal iOS
|
||||||
|
interaction.
|
||||||
|
- [ ] Open issue #50 and verify its body renders separate paragraphs and a
|
||||||
|
two-item unordered list. Add or edit a comment containing two paragraphs,
|
||||||
|
an unordered list, emphasis, and a link; verify every Markdown block and
|
||||||
|
inline style renders correctly after saving and after pull-to-refresh.
|
||||||
|
- [ ] Tap the **Add comment** icon on an issue. Enter headings, emphasis, a
|
||||||
|
list, link, and fenced code block; **Write** syntax-highlights the
|
||||||
|
Markdown, **Preview** renders it, and switching modes preserves the exact
|
||||||
|
source. Save and verify the rendered comment appears after the detail
|
||||||
|
refresh; Cancel leaves the issue unchanged.
|
||||||
|
- [ ] Each comment authored by the signed-in account has its own pencil button
|
||||||
|
and comments by other accounts do not. Edit an owned comment in
|
||||||
|
Write and Preview modes, save it, refresh and relaunch, and verify the
|
||||||
|
Markdown change persists. Check Add/Edit/Cancel/Save with Dynamic Type
|
||||||
|
and VoiceOver, including the empty-comment Save state.
|
||||||
|
|
||||||
|
## Repositories and commits
|
||||||
|
|
||||||
|
- [ ] The repository list and favorite behavior match the Issues tab. Toggle a
|
||||||
|
favorite and confirm it persists without affecting Issues or Milestones.
|
||||||
|
- [ ] Open a repository; commit history initially selects **All**, visibly shows
|
||||||
|
All in the navigation bar, and includes commits from multiple branches.
|
||||||
|
- [ ] Open the branch menu; All is checked. Select a branch and verify the label,
|
||||||
|
checkmark, commits, and graph update, then return to All.
|
||||||
|
- [ ] Commit graph lanes align with their rows while scrolling; branch-out and
|
||||||
|
merge-back connections use smooth, rounded curves that meet the correct
|
||||||
|
lane and commit node without angular horizontal bars or overlaps.
|
||||||
|
- [ ] In **All** history, branch names use the accent color both at branch tips
|
||||||
|
and at the first commit made on a pull-request branch, including a merged
|
||||||
|
branch whose tip was deleted.
|
||||||
|
- [ ] Open a commit and verify its header shows the full title and description,
|
||||||
|
full hash, author, committer, date, known branch/ref, and signature status
|
||||||
|
above the Changed Files paths and statuses, with a thin system separator
|
||||||
|
between the header and first file. Unsigned commits say **Unsigned**.
|
||||||
|
Verify paragraphs, lists, links, and inline styles in a multi-line commit
|
||||||
|
description use the repository file Preview Markdown presentation. Check
|
||||||
|
that long values wrap and remain readable at the largest Dynamic Type
|
||||||
|
accessibility size.
|
||||||
|
- [ ] Open a changed file and verify the diff title, old/new line numbers,
|
||||||
|
monospaced text, addition/removal/hunk colors, vertical scrolling, and
|
||||||
|
source-style horizontal scrolling for long lines without word wrapping,
|
||||||
|
clipped or jumping text, or content hidden beneath the navigation bar.
|
||||||
|
- [ ] Long-press diff text and verify normal selection and copying.
|
||||||
|
|
||||||
|
## Repository files
|
||||||
|
|
||||||
|
- [ ] Switch a repository between **History** and **Files**. Each mode displays
|
||||||
|
the expected content and switching back preserves normal navigation.
|
||||||
|
- [ ] In Files, traverse several nested folders using rows, including a folder
|
||||||
|
and file whose names contain spaces (for example below Chezmoi's
|
||||||
|
`private_Library`), the navigation-bar Back button, and the left-edge
|
||||||
|
swipe. Folder contents, files, and titles match the repository hierarchy.
|
||||||
|
- [ ] In the repository root and in several nested folders, switch between
|
||||||
|
**Files** and **History**. Each folder history contains only commits that
|
||||||
|
affect that folder or its descendants. Switch back to Files and verify the
|
||||||
|
same folder and navigation stack are preserved. Open a history commit and
|
||||||
|
verify its changed-files detail opens normally.
|
||||||
|
- [ ] Open representative source files in several languages, including Rust,
|
||||||
|
Swift, and a scripting or markup language. Keywords, strings, comments,
|
||||||
|
types, and punctuation use plausible language-specific highlighting.
|
||||||
|
- [ ] Open a source file containing a line wider than the screen. It does not
|
||||||
|
word-wrap, its first line starts below the navigation bar, and horizontal
|
||||||
|
dragging pans smoothly across the complete line without blank, black,
|
||||||
|
clipped, delayed, or jumping text. Vertical scrolling, selection, and
|
||||||
|
copying still work.
|
||||||
|
- [ ] Open Markdown files using the supported extensions (`.md`, `.markdown`,
|
||||||
|
`.mdown`, and `.mkd`). **Preview** is selected by default and renders
|
||||||
|
headings, paragraphs, emphasis, links, lists, task lists, blockquotes,
|
||||||
|
fenced code blocks, tables, and thematic rules as structured content.
|
||||||
|
- [ ] Switch a Markdown file from **Preview** to **Source**. The literal Markdown
|
||||||
|
is syntax-highlighted, selectable, does not word-wrap, and scrolls
|
||||||
|
horizontally for long lines. Switch back and verify the rendered preview
|
||||||
|
is restored without stale or overlapping content.
|
||||||
|
- [ ] Switch Markdown files among **Preview**, **Source**, and **History** and
|
||||||
|
switch other source and native-preview files between **Content** and
|
||||||
|
**History**. Each history contains only commits that affect that exact
|
||||||
|
file. Returning to content preserves the file path and restores its native
|
||||||
|
preview or source presentation without stale or overlapping views.
|
||||||
|
- [ ] Open representative image, PDF, audio, and video files. Each uses the
|
||||||
|
native preview appropriate to its media type and returns cleanly to Files.
|
||||||
|
|
||||||
|
## Pull requests
|
||||||
|
|
||||||
|
- [ ] The list defaults to the saved Open/Closed filter.
|
||||||
|
- [ ] Change the native filter menu between Open and Closed; the checkmark,
|
||||||
|
rows, and persisted selection update.
|
||||||
|
- [ ] Open **Search Text** in the pull-request filter, enter a term, and verify
|
||||||
|
the server-filtered rows and menu subtitle update. Combine it with Closed
|
||||||
|
and a milestone and verify every filter applies together.
|
||||||
|
- [ ] Select a milestone and then All Milestones; the checkmark and pull-request
|
||||||
|
rows update, and the milestone selection persists independently per
|
||||||
|
server after relaunch.
|
||||||
|
- [ ] The pull-request filter icon uses a neutral color for Open + All
|
||||||
|
Milestones and the app accent color whenever either filter is non-default.
|
||||||
|
- [ ] In the pull-request filter panel, Search Text, Status, and Milestone icons
|
||||||
|
use the app accent color independently when non-default. Search text
|
||||||
|
remains while navigating during the current app session but is empty
|
||||||
|
after relaunch. **Clear Filters** restores empty search + Open + All
|
||||||
|
Milestones, refreshes the rows, and returns every icon to neutral.
|
||||||
|
- [ ] Each row shows repository/number, title, author/update metadata, comment
|
||||||
|
count, and draft/merged state where applicable. Open rows show a green
|
||||||
|
icon, closed rows show a purple icon, and VoiceOver announces the state.
|
||||||
|
- [ ] Open a pull request and verify title, metadata, Markdown body, comments,
|
||||||
|
changed-file reference, and matching open/closed state icon with a
|
||||||
|
VoiceOver state announcement. Its file paths, statuses, left alignment,
|
||||||
|
separators, and disclosure indicators match the commit changed-file list.
|
||||||
|
- [ ] Open a changed file and run the same diff checks as for a commit.
|
||||||
|
|
||||||
|
## Milestones
|
||||||
|
|
||||||
|
- [ ] Milestones uses the server → repository → milestone flow and includes
|
||||||
|
both open and closed milestones.
|
||||||
|
- [ ] Toggle a repository favorite and confirm it persists after refresh and
|
||||||
|
relaunch without affecting the same repository in Issues or Repos.
|
||||||
|
- [ ] Each milestone shows its title, description, state, due date, issue
|
||||||
|
counts, and a green/amber closed/open progress bar. Lists and details show
|
||||||
|
a green open or purple closed icon and VoiceOver announces the state.
|
||||||
|
- [ ] A milestone description containing multiple paragraphs, a list, emphasis,
|
||||||
|
and a link uses the repository file Preview Markdown presentation in both
|
||||||
|
the milestone list and detail.
|
||||||
|
- [ ] Open a milestone and verify every assigned issue and pull request is
|
||||||
|
listed; tapping either opens its normal detail.
|
||||||
|
- [ ] Partially swipe an open milestone to reveal **Close** and **Delete**;
|
||||||
|
full-swipe closes it as the primary action, and full-swiping a closed
|
||||||
|
milestone reopens it. Delete an empty milestone after confirming. Attempt
|
||||||
|
to delete a milestone with assigned issues or pull requests and verify an
|
||||||
|
informational dialog explains that the assignments must be removed first.
|
||||||
|
- [ ] Tap the add button. **New Milestone** has native Cancel and Save controls,
|
||||||
|
title and multiline description fields, a **Closed** switch that defaults
|
||||||
|
off, and an optional inline date picker. Save is disabled for an empty or
|
||||||
|
whitespace-only title. Create milestones both without and with a due date
|
||||||
|
and verify each appears open.
|
||||||
|
- [ ] Tap the pencil button on a milestone. Change its title and description, add and
|
||||||
|
change its due date, then turn Due Date off and save to clear it. Turn
|
||||||
|
**Closed** on and save; verify the list and detail show the closed state.
|
||||||
|
Edit it again, turn **Closed** off, and verify it reopens. Refresh and
|
||||||
|
relaunch after each save to verify persistence; Cancel leaves every value
|
||||||
|
unchanged. Exercise the form with the keyboard, Dynamic Type, and
|
||||||
|
VoiceOver labels.
|
||||||
|
- [ ] Repository issue lists and issue details show the assigned milestone with
|
||||||
|
a flag icon instead of a text label.
|
||||||
|
- [ ] Repositories without milestones show the native empty state.
|
||||||
|
|
||||||
|
## Settings, accessibility, and lifecycle
|
||||||
|
|
||||||
|
- [ ] Settings opens from the gear button on Home and is not a tab.
|
||||||
|
- [ ] Home, Issues, Repositories, Pull Requests, Milestones, and Actions use the
|
||||||
|
destination name as the primary navigation title and the selected server
|
||||||
|
as the smaller subtitle; changing servers updates both.
|
||||||
|
- [ ] Primary Navigation always keeps Home first and allows up to four ordered,
|
||||||
|
unique choices from Issues, Repos, Pulls, Milestones, and Actions. Server
|
||||||
|
Activity remains only the **All users** Home timeline. Outside Edit mode,
|
||||||
|
rows cannot add, remove, or reorder destinations. In Edit mode, native
|
||||||
|
insert, delete, and reorder controls perform all three operations; tab
|
||||||
|
order updates immediately, omitted destinations remain on Home, and the
|
||||||
|
configuration survives relaunch and server changes. Existing saved
|
||||||
|
Server Activity tabs are removed during migration. An upgrade from the
|
||||||
|
previous build otherwise preserves its destination order.
|
||||||
|
- [ ] Appearance changes between Auto, Light, and Dark immediately; Auto follows
|
||||||
|
the simulator system appearance.
|
||||||
|
- [ ] Icon and appearance settings remain selected after relaunch.
|
||||||
|
- [ ] Test Light and Dark appearances for readable text, separators, graph lines,
|
||||||
|
diff colors, menus, selection, loading, empty, and error states.
|
||||||
|
- [ ] Test at the default and at least one larger Dynamic Type size. Text remains
|
||||||
|
readable without hiding required controls.
|
||||||
|
- [ ] VoiceOver announces tabs, navigation controls, filters, favorites, rows,
|
||||||
|
fields, and changed-file statuses meaningfully and in a usable order.
|
||||||
|
- [ ] Rotate, background/foreground, terminate/relaunch, and temporarily disable
|
||||||
|
networking. The app recovers without losing preferences or credentials.
|
||||||
|
|
||||||
|
## Release sign-off
|
||||||
|
|
||||||
|
- [ ] All build gates passed.
|
||||||
|
- [ ] A signed Release archive passed the version, identity, entitlement,
|
||||||
|
privacy-manifest, and export-compliance checks above.
|
||||||
|
- [ ] Fresh-install and upgrade passes completed through AltStore PAL on an
|
||||||
|
eligible physical iPhone.
|
||||||
|
- [ ] Every supported iOS version and required device class completed.
|
||||||
|
- [ ] Every scenario passed; any discovered failures were resolved and retested.
|
||||||
@@ -1,19 +1,20 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "gotcha-app"
|
name = "gotcha-app"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
description = "Lightweight Slint client for Gitea on iOS"
|
description = "Rust application core for the Gotcha iOS client"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
|
|
||||||
[[bin]]
|
[lib]
|
||||||
name = "gotcha-app"
|
name = "gotcha_core"
|
||||||
path = "src/main.rs"
|
crate-type = ["lib", "staticlib"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
gotcha_gitea = { path = "../gitea" }
|
gotcha_gitea = { path = "../gitea" }
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
security-framework = "3"
|
security-framework = "3"
|
||||||
slint.workspace = true
|
thiserror = "2"
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
uniffi = { version = "0.32", features = ["tokio"] }
|
||||||
|
|||||||
573
crates/app/src/api.rs
Normal file
573
crates/app/src/api.rs
Normal file
@@ -0,0 +1,573 @@
|
|||||||
|
use gotcha_gitea::{
|
||||||
|
ActionJobDetails, ActionRunDetails, ActionRunQuery, Client, DEFAULT_PAGE_SIZE as PAGE_SIZE,
|
||||||
|
IssueQuery, Page as GiteaPage, RepositoryId, api_date, diff, models, parse_action_inputs,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn load_action_workflows(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
) -> Result<Vec<models::ActionWorkflow>, String> {
|
||||||
|
client(server)?
|
||||||
|
.action_workflows(&scope(owner, repository)?)
|
||||||
|
.await
|
||||||
|
.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn dispatch_action_workflow(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
workflow: &str,
|
||||||
|
reference: &str,
|
||||||
|
inputs: &[String],
|
||||||
|
) -> Result<(), String> {
|
||||||
|
client(server)?
|
||||||
|
.dispatch_action_workflow(
|
||||||
|
&scope(owner, repository)?,
|
||||||
|
workflow,
|
||||||
|
reference,
|
||||||
|
&parse_action_inputs(inputs).map_err(message)?,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_action_runs(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
page: i32,
|
||||||
|
) -> Result<GiteaPage<models::ActionWorkflowRun>, String> {
|
||||||
|
client(server)?
|
||||||
|
.action_runs(
|
||||||
|
&scope(owner, repository)?,
|
||||||
|
&ActionRunQuery {
|
||||||
|
page,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_action_run(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
run: i64,
|
||||||
|
) -> Result<ActionRunDetails, String> {
|
||||||
|
client(server)?
|
||||||
|
.action_run_details(&scope(owner, repository)?, run)
|
||||||
|
.await
|
||||||
|
.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_action_job_details(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
run: i64,
|
||||||
|
job: i64,
|
||||||
|
) -> Result<ActionJobDetails, String> {
|
||||||
|
client(server)?
|
||||||
|
.action_job_details(&scope(owner, repository)?, run, job)
|
||||||
|
.await
|
||||||
|
.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
domain::{
|
||||||
|
HistoryCommit, HomeData, IssueDetails, IssueDraft, IssueEditorData, IssueFilter,
|
||||||
|
MilestoneDetails, MilestoneDraft, Page, PullDetails, RepositoryData, Server,
|
||||||
|
},
|
||||||
|
presentation::compact_date,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn load_repositories(server: &Server, page: i32) -> Result<Page<RepositoryData>, String> {
|
||||||
|
let page = client(server)?
|
||||||
|
.owned_repositories_page(page, PAGE_SIZE)
|
||||||
|
.await
|
||||||
|
.map_err(message)?;
|
||||||
|
Ok(Page {
|
||||||
|
has_more: page.has_more,
|
||||||
|
items: page
|
||||||
|
.items
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|repository| {
|
||||||
|
Some(RepositoryData {
|
||||||
|
owner: repository.owner?.login?,
|
||||||
|
name: repository.name?,
|
||||||
|
description: repository
|
||||||
|
.description
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
|
.unwrap_or_else(|| "No description".into()),
|
||||||
|
language: repository
|
||||||
|
.language
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
|
.unwrap_or_else(|| "Unknown language".into()),
|
||||||
|
open_issues: repository.open_issues_count.unwrap_or_default(),
|
||||||
|
updated: compact_date(repository.updated_at.as_deref()),
|
||||||
|
default_branch: repository.default_branch.unwrap_or_else(|| "main".into()),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_notifications(
|
||||||
|
server: &Server,
|
||||||
|
unread: bool,
|
||||||
|
page: i32,
|
||||||
|
) -> Result<Page<models::NotificationThread>, String> {
|
||||||
|
client(server)?
|
||||||
|
.notifications(unread, page, PAGE_SIZE)
|
||||||
|
.await
|
||||||
|
.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_notification_updates(
|
||||||
|
server: &Server,
|
||||||
|
since: Option<&str>,
|
||||||
|
) -> Result<Vec<models::NotificationThread>, String> {
|
||||||
|
client(server)?
|
||||||
|
.notification_updates(since)
|
||||||
|
.await
|
||||||
|
.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn mark_notification_read(server: &Server, id: i64) -> Result<(), String> {
|
||||||
|
client(server)?
|
||||||
|
.mark_notification_read(id)
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_issues(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
status: &str,
|
||||||
|
filter: &IssueFilter,
|
||||||
|
page: i32,
|
||||||
|
) -> Result<Page<models::Issue>, String> {
|
||||||
|
client(server)?
|
||||||
|
.issues(
|
||||||
|
&scope(owner, repository)?,
|
||||||
|
&IssueQuery {
|
||||||
|
state: status.into(),
|
||||||
|
labels: (!filter.labels.is_empty())
|
||||||
|
.then(|| filter.labels.iter().cloned().collect::<Vec<_>>().join(",")),
|
||||||
|
milestones: (!filter.milestone.is_empty()).then(|| filter.milestone.clone()),
|
||||||
|
keyword: (!filter.search_text.is_empty()).then(|| filter.search_text.clone()),
|
||||||
|
page,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_milestones_page(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
page: i32,
|
||||||
|
) -> Result<Page<models::Milestone>, String> {
|
||||||
|
client(server)?
|
||||||
|
.milestones_page(&scope(owner, repository)?, page, PAGE_SIZE)
|
||||||
|
.await
|
||||||
|
.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_labels(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
) -> Result<Vec<models::Label>, String> {
|
||||||
|
client(server)?
|
||||||
|
.labels(&scope(owner, repository)?)
|
||||||
|
.await
|
||||||
|
.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_milestones(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
) -> Result<Vec<models::Milestone>, String> {
|
||||||
|
client(server)?
|
||||||
|
.milestones(&scope(owner, repository)?)
|
||||||
|
.await
|
||||||
|
.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_milestone(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
id: i64,
|
||||||
|
page: i32,
|
||||||
|
) -> Result<MilestoneDetails, String> {
|
||||||
|
client(server)?
|
||||||
|
.milestone_details(&scope(owner, repository)?, id, page)
|
||||||
|
.await
|
||||||
|
.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_milestone_editor(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
id: i64,
|
||||||
|
) -> Result<models::Milestone, String> {
|
||||||
|
client(server)?
|
||||||
|
.milestone(&scope(owner, repository)?, id)
|
||||||
|
.await
|
||||||
|
.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save_milestone(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
id: Option<i64>,
|
||||||
|
draft: MilestoneDraft,
|
||||||
|
) -> Result<models::Milestone, String> {
|
||||||
|
client(server)?
|
||||||
|
.save_milestone(
|
||||||
|
&scope(owner, repository)?,
|
||||||
|
id,
|
||||||
|
gotcha_gitea::MilestoneDraft {
|
||||||
|
title: draft.title,
|
||||||
|
description: draft.description,
|
||||||
|
due_on: draft.due_date.map(api_date),
|
||||||
|
state: if draft.closed { "closed" } else { "open" }.into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_milestone_closed(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
id: i64,
|
||||||
|
closed: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
client(server)?
|
||||||
|
.set_milestone_closed(&scope(owner, repository)?, id, closed)
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_milestone(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
id: i64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
client(server)?
|
||||||
|
.delete_milestone(&scope(owner, repository)?, &id.to_string())
|
||||||
|
.await
|
||||||
|
.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_pulls(
|
||||||
|
server: &Server,
|
||||||
|
status: &str,
|
||||||
|
milestone: &str,
|
||||||
|
search_text: &str,
|
||||||
|
page: i32,
|
||||||
|
) -> Result<Page<models::Issue>, String> {
|
||||||
|
client(server)?
|
||||||
|
.search_pulls(
|
||||||
|
status,
|
||||||
|
(!milestone.is_empty()).then_some(milestone),
|
||||||
|
(!search_text.is_empty()).then_some(search_text),
|
||||||
|
page,
|
||||||
|
PAGE_SIZE,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_pull_milestones(server: &Server) -> Result<Vec<String>, String> {
|
||||||
|
client(server)?.pull_milestones().await.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_branches(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
default_branch: &str,
|
||||||
|
) -> Result<Vec<String>, String> {
|
||||||
|
client(server)?
|
||||||
|
.branches(&scope(owner, repository)?, default_branch)
|
||||||
|
.await
|
||||||
|
.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_repository_contents(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<Vec<models::ContentsResponse>, String> {
|
||||||
|
client(server)?
|
||||||
|
.repository_contents(&scope(owner, repository)?, path)
|
||||||
|
.await
|
||||||
|
.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_repository_file(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<Vec<u8>, String> {
|
||||||
|
client(server)?
|
||||||
|
.repository_file(&scope(owner, repository)?, path)
|
||||||
|
.await
|
||||||
|
.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_branch_commits(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
branch: &str,
|
||||||
|
path: Option<&str>,
|
||||||
|
pages: u32,
|
||||||
|
) -> Result<Page<HistoryCommit>, String> {
|
||||||
|
client(server)?
|
||||||
|
.branch_history(&scope(owner, repository)?, branch, path, pages)
|
||||||
|
.await
|
||||||
|
.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_all_commits(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
branches: &[String],
|
||||||
|
path: Option<&str>,
|
||||||
|
pages: u32,
|
||||||
|
) -> Result<Page<HistoryCommit>, String> {
|
||||||
|
client(server)?
|
||||||
|
.all_history(&scope(owner, repository)?, branches, path, pages)
|
||||||
|
.await
|
||||||
|
.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_issue(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
number: i64,
|
||||||
|
_page: i32,
|
||||||
|
) -> Result<IssueDetails, String> {
|
||||||
|
client(server)?
|
||||||
|
.issue_details(&scope(owner, repository)?, number)
|
||||||
|
.await
|
||||||
|
.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save_issue_comment(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
number: i64,
|
||||||
|
comment_id: Option<i64>,
|
||||||
|
body: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
client(server)?
|
||||||
|
.save_issue_comment(&scope(owner, repository)?, number, comment_id, body)
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_issue_editor(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
number: Option<i64>,
|
||||||
|
) -> Result<IssueEditorData, String> {
|
||||||
|
client(server)?
|
||||||
|
.issue_editor(&scope(owner, repository)?, number)
|
||||||
|
.await
|
||||||
|
.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_issue(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
draft: IssueDraft,
|
||||||
|
) -> Result<models::Issue, String> {
|
||||||
|
client(server)?
|
||||||
|
.create_issue_draft(&scope(owner, repository)?, shared_issue_draft(draft))
|
||||||
|
.await
|
||||||
|
.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn edit_issue(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
number: i64,
|
||||||
|
draft: IssueDraft,
|
||||||
|
) -> Result<models::Issue, String> {
|
||||||
|
client(server)?
|
||||||
|
.edit_issue_draft(
|
||||||
|
&scope(owner, repository)?,
|
||||||
|
number,
|
||||||
|
shared_issue_draft(draft),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_issue_closed(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
number: i64,
|
||||||
|
closed: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
client(server)?
|
||||||
|
.set_issue_state(
|
||||||
|
&scope(owner, repository)?,
|
||||||
|
&[number],
|
||||||
|
if closed { "closed" } else { "open" },
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_issue(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
number: i64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
client(server)?
|
||||||
|
.delete_issue(&scope(owner, repository)?, number)
|
||||||
|
.await
|
||||||
|
.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_pull(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
number: i64,
|
||||||
|
page: i32,
|
||||||
|
) -> Result<PullDetails, String> {
|
||||||
|
client(server)?
|
||||||
|
.pull_details(&scope(owner, repository)?, number, page)
|
||||||
|
.await
|
||||||
|
.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_commit(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
sha: &str,
|
||||||
|
) -> Result<models::Commit, String> {
|
||||||
|
client(server)?
|
||||||
|
.commit(&scope(owner, repository)?, sha)
|
||||||
|
.await
|
||||||
|
.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_commit_diff(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
sha: &str,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<diff::Parsed, String> {
|
||||||
|
let client = client(server)?;
|
||||||
|
let diff = client
|
||||||
|
.commit_diff(&scope(owner, repository)?, sha)
|
||||||
|
.await
|
||||||
|
.map_err(message)?;
|
||||||
|
Ok(diff::parse_file(&diff, path))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_pull_diff(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
number: i64,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<diff::Parsed, String> {
|
||||||
|
let client = client(server)?;
|
||||||
|
let diff = client
|
||||||
|
.pull_diff(&scope(owner, repository)?, number)
|
||||||
|
.await
|
||||||
|
.map_err(message)?;
|
||||||
|
Ok(diff::parse_file(&diff, path))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_home(
|
||||||
|
server: &Server,
|
||||||
|
page: i32,
|
||||||
|
filter: gotcha_gitea::ActivityFilter,
|
||||||
|
) -> Result<HomeData, String> {
|
||||||
|
client(server)?.home(page, filter).await.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_activities(server: &Server, page: i32) -> Result<Page<models::Activity>, String> {
|
||||||
|
client(server)?
|
||||||
|
.activities(page, gotcha_gitea::ActivityFilter::All)
|
||||||
|
.await
|
||||||
|
.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_server_activities(
|
||||||
|
pager: &mut gotcha_gitea::ServerActivityPager,
|
||||||
|
) -> Result<Page<models::Activity>, String> {
|
||||||
|
pager.next_page().await.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn server_activity_pager(
|
||||||
|
server: &Server,
|
||||||
|
) -> Result<gotcha_gitea::ServerActivityPager, String> {
|
||||||
|
client(server)?
|
||||||
|
.server_activity_pager()
|
||||||
|
.await
|
||||||
|
.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client(server: &Server) -> Result<Client, String> {
|
||||||
|
Client::with_provider(&server.url, Some(&server.token), server.provider).map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scope(owner: &str, repository: &str) -> Result<RepositoryId, String> {
|
||||||
|
RepositoryId::new(owner, repository).map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shared_issue_draft(draft: IssueDraft) -> gotcha_gitea::IssueDraft {
|
||||||
|
gotcha_gitea::IssueDraft {
|
||||||
|
title: draft.title,
|
||||||
|
body: draft.body,
|
||||||
|
label_ids: draft.label_ids,
|
||||||
|
milestone_id: draft.milestone_id,
|
||||||
|
due_date: draft.due_date.map(api_date),
|
||||||
|
closed: draft.closed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn message(error: impl std::fmt::Display) -> String {
|
||||||
|
error.to_string()
|
||||||
|
}
|
||||||
75
crates/app/src/core/actions.rs
Normal file
75
crates/app/src/core/actions.rs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
use crate::*;
|
||||||
|
|
||||||
|
#[uniffi::export(async_runtime = "tokio")]
|
||||||
|
impl GotchaCore {
|
||||||
|
pub async fn action_workflows(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
) -> Result<Vec<ActionWorkflowRow>, GotchaError> {
|
||||||
|
let (owner, repository) = validate_repository(&owner, &repository)?;
|
||||||
|
Ok(action_workflow_rows(
|
||||||
|
load_action_workflows(&self.server()?, owner, repository).await?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn dispatch_action_workflow(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
workflow: String,
|
||||||
|
reference: String,
|
||||||
|
inputs: Vec<String>,
|
||||||
|
) -> Result<(), GotchaError> {
|
||||||
|
let (owner, repository) = validate_repository(&owner, &repository)?;
|
||||||
|
dispatch_action_workflow(
|
||||||
|
&self.server()?,
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
&workflow,
|
||||||
|
&reference,
|
||||||
|
&inputs,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn action_runs(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
page: u32,
|
||||||
|
) -> Result<ActionRunListPage, GotchaError> {
|
||||||
|
let (owner, repository) = validate_repository(&owner, &repository)?;
|
||||||
|
let page = load_action_runs(&self.server()?, owner, repository, valid_page(page)?).await?;
|
||||||
|
Ok(ActionRunListPage {
|
||||||
|
rows: action_run_rows(&page.items),
|
||||||
|
has_more: page.has_more,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn action_run(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
run: i64,
|
||||||
|
) -> Result<ActionRunPage, GotchaError> {
|
||||||
|
let (owner, repository) = validate_repository(&owner, &repository)?;
|
||||||
|
Ok(action_run_page(
|
||||||
|
load_action_run(&self.server()?, owner, repository, run).await?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn action_job_log(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
run: i64,
|
||||||
|
job: i64,
|
||||||
|
) -> Result<ActionJobLogPage, GotchaError> {
|
||||||
|
let (owner, repository) = validate_repository(&owner, &repository)?;
|
||||||
|
Ok(action_job_log_page(
|
||||||
|
load_action_job_details(&self.server()?, owner, repository, run, job).await?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
102
crates/app/src/core/content.rs
Normal file
102
crates/app/src/core/content.rs
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
use crate::*;
|
||||||
|
|
||||||
|
#[uniffi::export(async_runtime = "tokio")]
|
||||||
|
impl GotchaCore {
|
||||||
|
pub async fn commits(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
branch: Option<String>,
|
||||||
|
path: String,
|
||||||
|
pages: u32,
|
||||||
|
) -> Result<CommitPage, GotchaError> {
|
||||||
|
let server = self.server()?;
|
||||||
|
let default_branch = self
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.repositories
|
||||||
|
.iter()
|
||||||
|
.find(|candidate| candidate.owner == owner && candidate.name == repository)
|
||||||
|
.map(|candidate| candidate.default_branch.clone())
|
||||||
|
.unwrap_or_else(|| "main".into());
|
||||||
|
let branches = load_branches(&server, &owner, &repository, &default_branch).await?;
|
||||||
|
let path = (!path.is_empty()).then_some(path.as_str());
|
||||||
|
let commits = match branch.as_deref() {
|
||||||
|
Some(branch) => {
|
||||||
|
load_branch_commits(&server, &owner, &repository, branch, path, pages.max(1))
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
load_all_commits(&server, &owner, &repository, &branches, path, pages.max(1))
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(commit_page(
|
||||||
|
branches,
|
||||||
|
&commits.items,
|
||||||
|
branch.is_none(),
|
||||||
|
commits.has_more,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn repository_contents(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
path: String,
|
||||||
|
) -> Result<Vec<RepositoryContentRow>, GotchaError> {
|
||||||
|
Ok(repository_content_rows(
|
||||||
|
load_repository_contents(&self.server()?, &owner, &repository, &path).await?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn repository_file(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
path: String,
|
||||||
|
) -> Result<RepositoryFilePage, GotchaError> {
|
||||||
|
let data = load_repository_file(&self.server()?, &owner, &repository, &path).await?;
|
||||||
|
Ok(repository_file_page(&path, data))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn commit_details(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
sha: String,
|
||||||
|
branch: Option<String>,
|
||||||
|
) -> Result<CommitDetailsPage, GotchaError> {
|
||||||
|
Ok(commit_details_page(
|
||||||
|
load_commit(&self.server()?, &owner, &repository, &sha).await?,
|
||||||
|
branch,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn commit_diff(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
sha: String,
|
||||||
|
path: String,
|
||||||
|
) -> Result<DiffPage, GotchaError> {
|
||||||
|
Ok(diff_page(
|
||||||
|
&path,
|
||||||
|
load_commit_diff(&self.server()?, &owner, &repository, &sha, &path).await?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn pull_diff(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
number: i64,
|
||||||
|
path: String,
|
||||||
|
) -> Result<DiffPage, GotchaError> {
|
||||||
|
Ok(diff_page(
|
||||||
|
&path,
|
||||||
|
load_pull_diff(&self.server()?, &owner, &repository, number, &path).await?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
265
crates/app/src/core/issues.rs
Normal file
265
crates/app/src/core/issues.rs
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
use crate::*;
|
||||||
|
|
||||||
|
#[uniffi::export(async_runtime = "tokio")]
|
||||||
|
impl GotchaCore {
|
||||||
|
pub async fn issues(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
page: u32,
|
||||||
|
) -> Result<IssueListPage, GotchaError> {
|
||||||
|
let server = self.server()?;
|
||||||
|
let (status, filter) = {
|
||||||
|
let state = self.state.lock().unwrap();
|
||||||
|
(
|
||||||
|
state.preferences.issue_status.clone(),
|
||||||
|
state
|
||||||
|
.preferences
|
||||||
|
.issue_filters
|
||||||
|
.get(&repository_key(&server.url, &owner, &repository))
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let page = load_issues(
|
||||||
|
&server,
|
||||||
|
&owner,
|
||||||
|
&repository,
|
||||||
|
&status,
|
||||||
|
&filter,
|
||||||
|
valid_page(page)?,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(IssueListPage {
|
||||||
|
rows: issue_rows(&page.items),
|
||||||
|
has_more: page.has_more,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn issue_filters(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
) -> Result<IssueFilterOptions, GotchaError> {
|
||||||
|
let server = self.server()?;
|
||||||
|
let filter = self
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.preferences
|
||||||
|
.issue_filters
|
||||||
|
.get(&repository_key(&server.url, &owner, &repository))
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
let (labels, milestones) = tokio::try_join!(
|
||||||
|
load_labels(&server, &owner, &repository),
|
||||||
|
load_milestones(&server, &owner, &repository)
|
||||||
|
)?;
|
||||||
|
Ok(issue_filter_options(&labels, &milestones, &filter))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn issue_filters_active(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
) -> Result<bool, GotchaError> {
|
||||||
|
let server = self.server()?;
|
||||||
|
let state = self.state.lock().unwrap();
|
||||||
|
let filter = state
|
||||||
|
.preferences
|
||||||
|
.issue_filters
|
||||||
|
.get(&repository_key(
|
||||||
|
&server.url,
|
||||||
|
owner.trim(),
|
||||||
|
repository.trim(),
|
||||||
|
))
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
Ok(filter.is_active(&state.preferences.issue_status))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_issue_filters(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
milestone: String,
|
||||||
|
labels: Vec<String>,
|
||||||
|
search_text: String,
|
||||||
|
) -> Result<(), GotchaError> {
|
||||||
|
let owner = owner.trim();
|
||||||
|
let repository = repository.trim();
|
||||||
|
if owner.is_empty() || repository.is_empty() {
|
||||||
|
return Err("Select a repository first.".into());
|
||||||
|
}
|
||||||
|
let filter = IssueFilter {
|
||||||
|
milestone,
|
||||||
|
labels: labels
|
||||||
|
.into_iter()
|
||||||
|
.filter(|label| !label.is_empty())
|
||||||
|
.collect(),
|
||||||
|
search_text: search_text.trim().into(),
|
||||||
|
};
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
let server = state
|
||||||
|
.active_server
|
||||||
|
.and_then(|index| state.preferences.servers.get(index))
|
||||||
|
.ok_or("Select a server first.")?;
|
||||||
|
let key = repository_key(&server.url, owner, repository);
|
||||||
|
if filter == IssueFilter::default() {
|
||||||
|
state.preferences.issue_filters.remove(&key);
|
||||||
|
} else {
|
||||||
|
state.preferences.issue_filters.insert(key, filter);
|
||||||
|
}
|
||||||
|
save_preferences(&state.preferences).map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_issue_filters(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
) -> Result<(), GotchaError> {
|
||||||
|
let owner = owner.trim();
|
||||||
|
let repository = repository.trim();
|
||||||
|
if owner.is_empty() || repository.is_empty() {
|
||||||
|
return Err("Select a repository first.".into());
|
||||||
|
}
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
let server = state
|
||||||
|
.active_server
|
||||||
|
.and_then(|index| state.preferences.servers.get(index))
|
||||||
|
.ok_or("Select a server first.")?;
|
||||||
|
let key = repository_key(&server.url, owner, repository);
|
||||||
|
state.preferences.issue_status = "open".into();
|
||||||
|
state.preferences.issue_filters.remove(&key);
|
||||||
|
save_preferences(&state.preferences).map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn issue(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
number: i64,
|
||||||
|
page: u32,
|
||||||
|
) -> Result<IssuePage, GotchaError> {
|
||||||
|
Ok(issue_page(
|
||||||
|
load_issue(
|
||||||
|
&self.server()?,
|
||||||
|
&owner,
|
||||||
|
&repository,
|
||||||
|
number,
|
||||||
|
valid_page(page)?,
|
||||||
|
)
|
||||||
|
.await?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save_issue_comment(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
number: i64,
|
||||||
|
comment_id: Option<i64>,
|
||||||
|
body: String,
|
||||||
|
) -> Result<(), GotchaError> {
|
||||||
|
let (owner, repository) = validate_repository(&owner, &repository)?;
|
||||||
|
if number <= 0 || comment_id.is_some_and(|id| id <= 0) {
|
||||||
|
return Err("Invalid issue comment selection.".into());
|
||||||
|
}
|
||||||
|
if body.trim().is_empty() {
|
||||||
|
return Err("Enter a comment.".into());
|
||||||
|
}
|
||||||
|
save_issue_comment(&self.server()?, owner, repository, number, comment_id, body).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn issue_editor(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
number: Option<i64>,
|
||||||
|
) -> Result<IssueEditorPage, GotchaError> {
|
||||||
|
let (owner, repository) = validate_repository(&owner, &repository)?;
|
||||||
|
if number.is_some_and(|number| number <= 0) {
|
||||||
|
return Err("Invalid issue number.".into());
|
||||||
|
}
|
||||||
|
Ok(issue_editor_page(
|
||||||
|
load_issue_editor(&self.server()?, owner, repository, number).await?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub async fn save_issue(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
number: Option<i64>,
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
label_ids: Vec<i64>,
|
||||||
|
milestone_id: Option<i64>,
|
||||||
|
due_date: Option<i64>,
|
||||||
|
closed: bool,
|
||||||
|
) -> Result<i64, GotchaError> {
|
||||||
|
let (owner, repository) = validate_repository(&owner, &repository)?;
|
||||||
|
let title = title.trim();
|
||||||
|
if title.is_empty() {
|
||||||
|
return Err("Enter an issue title.".into());
|
||||||
|
}
|
||||||
|
if number.is_some_and(|number| number <= 0)
|
||||||
|
|| milestone_id.is_some_and(|id| id <= 0)
|
||||||
|
|| label_ids.iter().any(|id| *id <= 0)
|
||||||
|
{
|
||||||
|
return Err("Invalid issue editor selection.".into());
|
||||||
|
}
|
||||||
|
let label_ids: Vec<_> = label_ids
|
||||||
|
.into_iter()
|
||||||
|
.collect::<std::collections::BTreeSet<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
let draft = IssueDraft {
|
||||||
|
title: title.into(),
|
||||||
|
body,
|
||||||
|
label_ids,
|
||||||
|
milestone_id,
|
||||||
|
due_date,
|
||||||
|
closed,
|
||||||
|
};
|
||||||
|
let server = self.server()?;
|
||||||
|
let issue = match number {
|
||||||
|
Some(number) => edit_issue(&server, owner, repository, number, draft).await?,
|
||||||
|
None => create_issue(&server, owner, repository, draft).await?,
|
||||||
|
};
|
||||||
|
issue
|
||||||
|
.number
|
||||||
|
.ok_or_else(|| "The saved issue has no number.".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_issue_closed(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
number: i64,
|
||||||
|
closed: bool,
|
||||||
|
) -> Result<(), GotchaError> {
|
||||||
|
let (owner, repository) = validate_repository(&owner, &repository)?;
|
||||||
|
if number <= 0 {
|
||||||
|
return Err("Invalid issue number.".into());
|
||||||
|
}
|
||||||
|
set_issue_closed(&self.server()?, owner, repository, number, closed).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_issue(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
number: i64,
|
||||||
|
) -> Result<(), GotchaError> {
|
||||||
|
let (owner, repository) = validate_repository(&owner, &repository)?;
|
||||||
|
if number <= 0 {
|
||||||
|
return Err("Invalid issue number.".into());
|
||||||
|
}
|
||||||
|
delete_issue(&self.server()?, owner, repository, number).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
109
crates/app/src/core/milestones.rs
Normal file
109
crates/app/src/core/milestones.rs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
use crate::*;
|
||||||
|
|
||||||
|
#[uniffi::export(async_runtime = "tokio")]
|
||||||
|
impl GotchaCore {
|
||||||
|
pub async fn milestones(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
page: u32,
|
||||||
|
) -> Result<MilestoneListPage, GotchaError> {
|
||||||
|
let page =
|
||||||
|
load_milestones_page(&self.server()?, &owner, &repository, valid_page(page)?).await?;
|
||||||
|
Ok(MilestoneListPage {
|
||||||
|
rows: milestone_rows(&page.items),
|
||||||
|
has_more: page.has_more,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn milestone(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
id: i64,
|
||||||
|
page: u32,
|
||||||
|
) -> Result<MilestonePage, GotchaError> {
|
||||||
|
Ok(milestone_page(
|
||||||
|
load_milestone(&self.server()?, &owner, &repository, id, valid_page(page)?).await?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn milestone_editor(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
id: Option<i64>,
|
||||||
|
) -> Result<MilestoneEditorPage, GotchaError> {
|
||||||
|
let (owner, repository) = validate_repository(&owner, &repository)?;
|
||||||
|
if id.is_some_and(|id| id <= 0) {
|
||||||
|
return Err("Invalid milestone selection.".into());
|
||||||
|
}
|
||||||
|
let milestone = match id {
|
||||||
|
Some(id) => Some(load_milestone_editor(&self.server()?, owner, repository, id).await?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
Ok(milestone_editor_page(milestone))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save_milestone(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
id: Option<i64>,
|
||||||
|
draft: MilestoneEditorPage,
|
||||||
|
) -> Result<i64, GotchaError> {
|
||||||
|
let (owner, repository) = validate_repository(&owner, &repository)?;
|
||||||
|
let title = draft.title.trim();
|
||||||
|
if title.is_empty() {
|
||||||
|
return Err("Enter a milestone title.".into());
|
||||||
|
}
|
||||||
|
if id.is_some_and(|id| id <= 0) {
|
||||||
|
return Err("Invalid milestone selection.".into());
|
||||||
|
}
|
||||||
|
let milestone = save_milestone(
|
||||||
|
&self.server()?,
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
id,
|
||||||
|
MilestoneDraft {
|
||||||
|
title: title.into(),
|
||||||
|
description: draft.description,
|
||||||
|
due_date: draft.due_date,
|
||||||
|
closed: draft.closed,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
milestone
|
||||||
|
.id
|
||||||
|
.ok_or_else(|| "The saved milestone has no ID.".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_milestone_closed(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
id: i64,
|
||||||
|
closed: bool,
|
||||||
|
) -> Result<(), GotchaError> {
|
||||||
|
let (owner, repository) = validate_repository(&owner, &repository)?;
|
||||||
|
if id <= 0 {
|
||||||
|
return Err("Invalid milestone selection.".into());
|
||||||
|
}
|
||||||
|
set_milestone_closed(&self.server()?, owner, repository, id, closed).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_milestone(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
id: i64,
|
||||||
|
) -> Result<(), GotchaError> {
|
||||||
|
let (owner, repository) = validate_repository(&owner, &repository)?;
|
||||||
|
if id <= 0 {
|
||||||
|
return Err("Invalid milestone selection.".into());
|
||||||
|
}
|
||||||
|
delete_milestone(&self.server()?, owner, repository, id).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
9
crates/app/src/core/mod.rs
Normal file
9
crates/app/src/core/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
mod actions;
|
||||||
|
mod content;
|
||||||
|
mod issues;
|
||||||
|
mod milestones;
|
||||||
|
mod notifications;
|
||||||
|
mod pulls;
|
||||||
|
mod repositories;
|
||||||
|
mod servers;
|
||||||
|
mod widgets;
|
||||||
137
crates/app/src/core/notifications.rs
Normal file
137
crates/app/src/core/notifications.rs
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use crate::*;
|
||||||
|
|
||||||
|
#[uniffi::export(async_runtime = "tokio")]
|
||||||
|
impl GotchaCore {
|
||||||
|
pub async fn notifications(
|
||||||
|
&self,
|
||||||
|
status: NotificationStatus,
|
||||||
|
page: u32,
|
||||||
|
) -> Result<NotificationListPage, GotchaError> {
|
||||||
|
let server = self.server()?;
|
||||||
|
let result = load_notifications(
|
||||||
|
&server,
|
||||||
|
matches!(status, NotificationStatus::Open),
|
||||||
|
valid_page(page)?,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(NotificationListPage {
|
||||||
|
rows: notification_rows(&server.credential_account, result.items),
|
||||||
|
has_more: result.has_more,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn mark_notification_read(
|
||||||
|
&self,
|
||||||
|
server_id: String,
|
||||||
|
id: i64,
|
||||||
|
) -> Result<(), GotchaError> {
|
||||||
|
let server = self.server_by_id(&server_id)?;
|
||||||
|
mark_notification_read(&server, id)
|
||||||
|
.await
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn poll_notifications(&self) -> Result<Vec<NotificationRow>, GotchaError> {
|
||||||
|
let servers = {
|
||||||
|
let state = self.state.lock().unwrap();
|
||||||
|
if !state.preferences.notifications_enabled {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
state
|
||||||
|
.preferences
|
||||||
|
.servers
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(|server| {
|
||||||
|
let cursor = state
|
||||||
|
.preferences
|
||||||
|
.notification_cursors
|
||||||
|
.get(&server.credential_account)
|
||||||
|
.cloned();
|
||||||
|
(server, cursor)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
let polled_at = gotcha_gitea::api_timestamp(
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs() as i64,
|
||||||
|
);
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
let mut cursors = Vec::new();
|
||||||
|
let mut first_error = None;
|
||||||
|
for (server, cursor) in servers {
|
||||||
|
let notifications = match load_notification_updates(&server, cursor.as_deref()).await {
|
||||||
|
Ok(notifications) => notifications,
|
||||||
|
Err(error) => {
|
||||||
|
first_error.get_or_insert(error);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let next_cursor = notification_cursor(&polled_at, ¬ifications);
|
||||||
|
if cursor.is_some() {
|
||||||
|
rows.extend(notification_rows(&server.credential_account, notifications));
|
||||||
|
}
|
||||||
|
cursors.push((server.credential_account, next_cursor));
|
||||||
|
}
|
||||||
|
|
||||||
|
if cursors.is_empty()
|
||||||
|
&& let Some(error) = first_error
|
||||||
|
{
|
||||||
|
return Err(error.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
if !state.preferences.notifications_enabled {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
for (server_id, cursor) in cursors {
|
||||||
|
if state
|
||||||
|
.preferences
|
||||||
|
.servers
|
||||||
|
.iter()
|
||||||
|
.any(|server| server.credential_account == server_id)
|
||||||
|
{
|
||||||
|
state
|
||||||
|
.preferences
|
||||||
|
.notification_cursors
|
||||||
|
.insert(server_id, cursor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
save_preferences(&state.preferences)?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn notification_cursor(
|
||||||
|
polled_at: &str,
|
||||||
|
notifications: &[gotcha_gitea::models::NotificationThread],
|
||||||
|
) -> String {
|
||||||
|
notifications
|
||||||
|
.iter()
|
||||||
|
.filter_map(|notification| notification.updated_at.as_deref())
|
||||||
|
.fold(polled_at.into(), |cursor, updated| {
|
||||||
|
cursor.max(updated.into())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cursor_does_not_skip_an_update_that_arrived_during_polling() {
|
||||||
|
let notifications = vec![gotcha_gitea::models::NotificationThread {
|
||||||
|
updated_at: Some("2026-08-15T10:00:01Z".into()),
|
||||||
|
..Default::default()
|
||||||
|
}];
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
notification_cursor("2026-08-15T10:00:00Z", ¬ifications),
|
||||||
|
"2026-08-15T10:00:01Z"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
115
crates/app/src/core/pulls.rs
Normal file
115
crates/app/src/core/pulls.rs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
use crate::*;
|
||||||
|
|
||||||
|
#[uniffi::export(async_runtime = "tokio")]
|
||||||
|
impl GotchaCore {
|
||||||
|
pub async fn pull_filters(&self) -> Result<PullFilterOptions, GotchaError> {
|
||||||
|
let server = self.server()?;
|
||||||
|
let filter = self
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.preferences
|
||||||
|
.pull_filters
|
||||||
|
.get(&server.url)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
Ok(pull_filter_options(
|
||||||
|
&load_pull_milestones(&server).await?,
|
||||||
|
&filter,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pull_filters_active(&self) -> Result<bool, GotchaError> {
|
||||||
|
let server = self.server()?;
|
||||||
|
let state = self.state.lock().unwrap();
|
||||||
|
let filter = state
|
||||||
|
.preferences
|
||||||
|
.pull_filters
|
||||||
|
.get(&server.url)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
Ok(filter.is_active(&state.preferences.pull_status))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_pull_filters(
|
||||||
|
&self,
|
||||||
|
milestone: String,
|
||||||
|
search_text: String,
|
||||||
|
) -> Result<(), GotchaError> {
|
||||||
|
let filter = PullFilter {
|
||||||
|
milestone,
|
||||||
|
search_text: search_text.trim().into(),
|
||||||
|
};
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
let server = state
|
||||||
|
.active_server
|
||||||
|
.and_then(|index| state.preferences.servers.get(index))
|
||||||
|
.ok_or("Select a server first.")?;
|
||||||
|
let key = server.url.clone();
|
||||||
|
if filter == PullFilter::default() {
|
||||||
|
state.preferences.pull_filters.remove(&key);
|
||||||
|
} else {
|
||||||
|
state.preferences.pull_filters.insert(key, filter);
|
||||||
|
}
|
||||||
|
save_preferences(&state.preferences).map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_pull_filters(&self) -> Result<(), GotchaError> {
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
let server = state
|
||||||
|
.active_server
|
||||||
|
.and_then(|index| state.preferences.servers.get(index))
|
||||||
|
.ok_or("Select a server first.")?;
|
||||||
|
let key = server.url.clone();
|
||||||
|
state.preferences.pull_status = "open".into();
|
||||||
|
state.preferences.pull_filters.remove(&key);
|
||||||
|
save_preferences(&state.preferences).map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn pulls(&self, page: u32) -> Result<PullListPage, GotchaError> {
|
||||||
|
let server = self.server()?;
|
||||||
|
let (status, filter) = {
|
||||||
|
let state = self.state.lock().unwrap();
|
||||||
|
(
|
||||||
|
state.preferences.pull_status.clone(),
|
||||||
|
state
|
||||||
|
.preferences
|
||||||
|
.pull_filters
|
||||||
|
.get(&server.url)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let page = load_pulls(
|
||||||
|
&server,
|
||||||
|
&status,
|
||||||
|
&filter.milestone,
|
||||||
|
&filter.search_text,
|
||||||
|
valid_page(page)?,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(PullListPage {
|
||||||
|
rows: pull_rows(&page.items),
|
||||||
|
has_more: page.has_more,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn pull(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
number: i64,
|
||||||
|
page: u32,
|
||||||
|
) -> Result<PullPage, GotchaError> {
|
||||||
|
Ok(pull_page(
|
||||||
|
load_pull(
|
||||||
|
&self.server()?,
|
||||||
|
&owner,
|
||||||
|
&repository,
|
||||||
|
number,
|
||||||
|
valid_page(page)?,
|
||||||
|
)
|
||||||
|
.await?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
50
crates/app/src/core/repositories.rs
Normal file
50
crates/app/src/core/repositories.rs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
use crate::*;
|
||||||
|
|
||||||
|
#[uniffi::export(async_runtime = "tokio")]
|
||||||
|
impl GotchaCore {
|
||||||
|
pub async fn repositories(
|
||||||
|
&self,
|
||||||
|
page: u32,
|
||||||
|
pane: RepositoryPane,
|
||||||
|
) -> Result<RepositoryListPage, GotchaError> {
|
||||||
|
let server = self.server()?;
|
||||||
|
let page_number = valid_page(page)?;
|
||||||
|
let repositories = load_repositories(&server, page_number).await?;
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
if page_number == 1 {
|
||||||
|
state.repositories.clear();
|
||||||
|
}
|
||||||
|
for repository in repositories.items {
|
||||||
|
if let Some(existing) = state.repositories.iter_mut().find(|candidate| {
|
||||||
|
candidate.owner == repository.owner && candidate.name == repository.name
|
||||||
|
}) {
|
||||||
|
*existing = repository;
|
||||||
|
} else {
|
||||||
|
state.repositories.push(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(RepositoryListPage {
|
||||||
|
rows: self.repository_rows(&state, pane),
|
||||||
|
has_more: repositories.has_more,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_favorite(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
pane: RepositoryPane,
|
||||||
|
) -> Result<Vec<RepositoryRow>, GotchaError> {
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
let server = state
|
||||||
|
.active_server
|
||||||
|
.and_then(|index| state.preferences.servers.get(index))
|
||||||
|
.ok_or("Select a server first.")?;
|
||||||
|
let key = favorite_key(pane, &server.url, &owner, &repository);
|
||||||
|
if !state.preferences.favorites.remove(&key) {
|
||||||
|
state.preferences.favorites.insert(key);
|
||||||
|
}
|
||||||
|
save_preferences(&state.preferences)?;
|
||||||
|
Ok(self.repository_rows(&state, pane))
|
||||||
|
}
|
||||||
|
}
|
||||||
475
crates/app/src/core/servers.rs
Normal file
475
crates/app/src/core/servers.rs
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use gotcha_gitea::Client;
|
||||||
|
|
||||||
|
use crate::*;
|
||||||
|
|
||||||
|
#[uniffi::export(async_runtime = "tokio")]
|
||||||
|
impl GotchaCore {
|
||||||
|
#[uniffi::constructor]
|
||||||
|
pub fn new(storage_directory: Option<String>) -> Arc<Self> {
|
||||||
|
let (preferences, startup_error) = match load_preferences(storage_directory.as_deref()) {
|
||||||
|
Ok(preferences) => (preferences, None),
|
||||||
|
Err(error) => (Preferences::default(), Some(error)),
|
||||||
|
};
|
||||||
|
let active_server = preferences
|
||||||
|
.last_server
|
||||||
|
.filter(|index| *index < preferences.servers.len())
|
||||||
|
.or_else(|| (preferences.servers.len() == 1).then_some(0));
|
||||||
|
Arc::new(Self {
|
||||||
|
state: Mutex::new(State {
|
||||||
|
preferences,
|
||||||
|
active_server,
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
server_activity: tokio::sync::Mutex::new(None),
|
||||||
|
startup_error,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn startup_error(&self) -> Option<String> {
|
||||||
|
self.startup_error.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn servers(&self) -> Vec<ServerRow> {
|
||||||
|
self.state
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.preferences
|
||||||
|
.servers
|
||||||
|
.iter()
|
||||||
|
.map(|server| ServerRow {
|
||||||
|
id: server.credential_account.clone(),
|
||||||
|
name: server.name.clone(),
|
||||||
|
url: server.url.clone(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn active_server_index(&self) -> Option<u32> {
|
||||||
|
self.state
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.active_server
|
||||||
|
.map(|index| index as u32)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn active_server_name(&self) -> Option<String> {
|
||||||
|
let state = self.state.lock().unwrap();
|
||||||
|
state
|
||||||
|
.active_server
|
||||||
|
.and_then(|index| state.preferences.servers.get(index))
|
||||||
|
.map(|server| server.name.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn server_editor(&self, index: u32) -> Result<ServerEditor, GotchaError> {
|
||||||
|
let state = self.state.lock().unwrap();
|
||||||
|
let server = state
|
||||||
|
.preferences
|
||||||
|
.servers
|
||||||
|
.get(index as usize)
|
||||||
|
.ok_or("That server no longer exists.")?;
|
||||||
|
Ok(ServerEditor {
|
||||||
|
name: server.name.clone(),
|
||||||
|
url: server.url.clone(),
|
||||||
|
provider: server.provider.into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_server(&self, index: u32) -> Result<(), GotchaError> {
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
let index = index as usize;
|
||||||
|
if index >= state.preferences.servers.len() {
|
||||||
|
return Err("That server no longer exists.".into());
|
||||||
|
}
|
||||||
|
state.active_server = Some(index);
|
||||||
|
state.preferences.last_server = Some(index);
|
||||||
|
state.repositories.clear();
|
||||||
|
save_preferences(&state.preferences).map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_server(
|
||||||
|
&self,
|
||||||
|
name: String,
|
||||||
|
url: String,
|
||||||
|
token: String,
|
||||||
|
provider: ServerProvider,
|
||||||
|
) -> Result<u32, GotchaError> {
|
||||||
|
let mut server = validate_server(&name, &url, &token, provider.into())?;
|
||||||
|
let client = Client::discover(&server.url, Some(&server.token), server.provider)
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
client
|
||||||
|
.current_user()
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
server.provider = client.provider();
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
assign_server_credential_account(&mut server, &state.preferences.servers);
|
||||||
|
save_server_token(&server)?;
|
||||||
|
let mut preferences = state.preferences.clone();
|
||||||
|
preferences.servers.push(server.clone());
|
||||||
|
let index = preferences.servers.len() - 1;
|
||||||
|
preferences.last_server = Some(index);
|
||||||
|
if let Err(error) = save_preferences(&preferences) {
|
||||||
|
return Err(rollback_added_server_token(&server, error).into());
|
||||||
|
}
|
||||||
|
state.preferences = preferences;
|
||||||
|
state.active_server = Some(index);
|
||||||
|
Ok(index as u32)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_server(
|
||||||
|
&self,
|
||||||
|
index: u32,
|
||||||
|
name: String,
|
||||||
|
url: String,
|
||||||
|
token: String,
|
||||||
|
provider: ServerProvider,
|
||||||
|
) -> Result<(), GotchaError> {
|
||||||
|
let index = index as usize;
|
||||||
|
let old_server = self
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.preferences
|
||||||
|
.servers
|
||||||
|
.get(index)
|
||||||
|
.cloned()
|
||||||
|
.ok_or("That server no longer exists.")?;
|
||||||
|
let token = if token.trim().is_empty() {
|
||||||
|
old_server.token.clone()
|
||||||
|
} else {
|
||||||
|
token
|
||||||
|
};
|
||||||
|
let mut server = validate_server(&name, &url, &token, provider.into())?;
|
||||||
|
server.credential_account = old_server.credential_account.clone();
|
||||||
|
let client = Client::discover(&server.url, Some(&server.token), server.provider)
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
client
|
||||||
|
.current_user()
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
server.provider = client.provider();
|
||||||
|
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
let current = state
|
||||||
|
.preferences
|
||||||
|
.servers
|
||||||
|
.get(index)
|
||||||
|
.ok_or("That server no longer exists.")?;
|
||||||
|
if current.credential_account != old_server.credential_account {
|
||||||
|
return Err("That server changed while it was being verified.".into());
|
||||||
|
}
|
||||||
|
save_server_token(&server)?;
|
||||||
|
let mut preferences = state.preferences.clone();
|
||||||
|
preferences.servers[index] = server;
|
||||||
|
if old_server.url != preferences.servers[index].url
|
||||||
|
&& preferences
|
||||||
|
.servers
|
||||||
|
.iter()
|
||||||
|
.all(|saved| saved.url != old_server.url)
|
||||||
|
{
|
||||||
|
let new_url = preferences.servers[index].url.clone();
|
||||||
|
migrate_server_settings(&mut preferences, &old_server.url, &new_url);
|
||||||
|
}
|
||||||
|
if let Err(error) = save_preferences(&preferences) {
|
||||||
|
return Err(rollback_updated_server_token(&old_server, error).into());
|
||||||
|
}
|
||||||
|
state.preferences = preferences;
|
||||||
|
state.repositories.clear();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_server(&self, index: u32) -> Result<(), GotchaError> {
|
||||||
|
let index = index as usize;
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
let server = state
|
||||||
|
.preferences
|
||||||
|
.servers
|
||||||
|
.get(index)
|
||||||
|
.cloned()
|
||||||
|
.ok_or("That server no longer exists.")?;
|
||||||
|
let mut preferences = state.preferences.clone();
|
||||||
|
preferences.servers.remove(index);
|
||||||
|
preferences
|
||||||
|
.notification_cursors
|
||||||
|
.remove(&server.credential_account);
|
||||||
|
let active_server =
|
||||||
|
active_server_after_removal(state.active_server, index, preferences.servers.len());
|
||||||
|
preferences.last_server = active_server;
|
||||||
|
if preferences
|
||||||
|
.servers
|
||||||
|
.iter()
|
||||||
|
.all(|saved| saved.url != server.url)
|
||||||
|
{
|
||||||
|
remove_server_settings(&mut preferences, &server.url);
|
||||||
|
}
|
||||||
|
save_preferences(&preferences)?;
|
||||||
|
if let Err(error) = delete_server_token(&server) {
|
||||||
|
if let Err(rollback) = save_preferences(&state.preferences) {
|
||||||
|
return Err(format!(
|
||||||
|
"{error} Restoring the server configuration also failed: {rollback}"
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
return Err(error.into());
|
||||||
|
}
|
||||||
|
state.preferences = preferences;
|
||||||
|
state.active_server = active_server;
|
||||||
|
state.repositories.clear();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn settings(&self) -> Settings {
|
||||||
|
let state = self.state.lock().unwrap();
|
||||||
|
Settings {
|
||||||
|
issue_status: state.preferences.issue_status.clone(),
|
||||||
|
pull_status: state.preferences.pull_status.clone(),
|
||||||
|
appearance: state.preferences.appearance.index() as u32,
|
||||||
|
notifications_enabled: state.preferences.notifications_enabled,
|
||||||
|
primary_destinations: state.preferences.primary_destinations.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_primary_destinations(
|
||||||
|
&self,
|
||||||
|
destinations: Vec<PrimaryDestination>,
|
||||||
|
) -> Result<(), GotchaError> {
|
||||||
|
let mut normalized = destinations;
|
||||||
|
if normalize_primary_destinations(&mut normalized) {
|
||||||
|
return Err(
|
||||||
|
"Choose each navigation destination once, with no more than four selected.".into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
state.preferences.primary_destinations = normalized;
|
||||||
|
save_preferences(&state.preferences).map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_issue_status(&self, status: String) -> Result<(), GotchaError> {
|
||||||
|
if !matches!(status.as_str(), "open" | "closed") {
|
||||||
|
return Err("Unsupported issue status.".into());
|
||||||
|
}
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
state.preferences.issue_status = status;
|
||||||
|
save_preferences(&state.preferences).map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_pull_status(&self, status: String) -> Result<(), GotchaError> {
|
||||||
|
if !matches!(status.as_str(), "open" | "closed") {
|
||||||
|
return Err("Unsupported pull-request status.".into());
|
||||||
|
}
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
state.preferences.pull_status = status;
|
||||||
|
save_preferences(&state.preferences).map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_appearance(&self, index: u32) -> Result<(), GotchaError> {
|
||||||
|
let appearance =
|
||||||
|
AppearanceMode::from_index(index as i32).ok_or("Unsupported appearance.")?;
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
state.preferences.appearance = appearance;
|
||||||
|
save_preferences(&state.preferences).map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_notifications_enabled(&self, enabled: bool) -> Result<(), GotchaError> {
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
state.preferences.notifications_enabled = enabled;
|
||||||
|
save_preferences(&state.preferences).map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn home(
|
||||||
|
&self,
|
||||||
|
page: u32,
|
||||||
|
filter: HomeActivityFilter,
|
||||||
|
) -> Result<HomePage, GotchaError> {
|
||||||
|
let server = self.server()?;
|
||||||
|
let name = server.name.clone();
|
||||||
|
Ok(home_page(
|
||||||
|
name,
|
||||||
|
load_home(&server, valid_page(page)?, filter.into()).await?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn server_activity(&self, page: u32) -> Result<ServerActivityPage, GotchaError> {
|
||||||
|
valid_page(page)?;
|
||||||
|
let server = self.server()?;
|
||||||
|
let mut session = self.server_activity.lock().await;
|
||||||
|
if page == 1
|
||||||
|
|| session
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|session| session.server_id != server.credential_account)
|
||||||
|
{
|
||||||
|
*session = Some(ServerActivitySession {
|
||||||
|
server_id: server.credential_account.clone(),
|
||||||
|
next_page: 1,
|
||||||
|
pager: server_activity_pager(&server).await?,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let session = session.as_mut().unwrap();
|
||||||
|
if session.next_page != page {
|
||||||
|
return Err("Refresh server activity before loading this page.".into());
|
||||||
|
}
|
||||||
|
let result = load_server_activities(&mut session.pager).await?;
|
||||||
|
if result.has_more {
|
||||||
|
session.next_page = session
|
||||||
|
.next_page
|
||||||
|
.checked_add(1)
|
||||||
|
.ok_or("Invalid page number.")?;
|
||||||
|
}
|
||||||
|
Ok(server_activity_page(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rollback_added_server_token(server: &Server, error: String) -> String {
|
||||||
|
match delete_server_token(server) {
|
||||||
|
Ok(()) => error,
|
||||||
|
Err(rollback) => format!("{error} Removing the unused token also failed: {rollback}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rollback_updated_server_token(server: &Server, error: String) -> String {
|
||||||
|
match save_server_token(server) {
|
||||||
|
Ok(()) => error,
|
||||||
|
Err(rollback) => format!("{error} Restoring the previous token also failed: {rollback}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_server_after_removal(
|
||||||
|
active: Option<usize>,
|
||||||
|
removed: usize,
|
||||||
|
remaining: usize,
|
||||||
|
) -> Option<usize> {
|
||||||
|
match active {
|
||||||
|
_ if remaining == 0 => None,
|
||||||
|
Some(active) if active > removed => Some(active - 1),
|
||||||
|
Some(active) if active == removed => Some(removed.min(remaining - 1)),
|
||||||
|
active => active,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn migrate_server_settings(preferences: &mut Preferences, old_url: &str, new_url: &str) {
|
||||||
|
for pane in RepositoryPane::ALL {
|
||||||
|
let old_prefix = format!("{}|{old_url}|", pane.key());
|
||||||
|
let new_prefix = format!("{}|{new_url}|", pane.key());
|
||||||
|
let moved: Vec<_> = preferences
|
||||||
|
.favorites
|
||||||
|
.iter()
|
||||||
|
.filter_map(|favorite| {
|
||||||
|
favorite
|
||||||
|
.strip_prefix(&old_prefix)
|
||||||
|
.map(|suffix| (favorite.clone(), format!("{new_prefix}{suffix}")))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for (old, new) in moved {
|
||||||
|
preferences.favorites.remove(&old);
|
||||||
|
preferences.favorites.insert(new);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let old_prefix = format!("{old_url}|");
|
||||||
|
let new_prefix = format!("{new_url}|");
|
||||||
|
let issue_filters: Vec<_> = preferences
|
||||||
|
.issue_filters
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(key, filter)| {
|
||||||
|
key.strip_prefix(&old_prefix)
|
||||||
|
.map(|suffix| (key.clone(), format!("{new_prefix}{suffix}"), filter.clone()))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for (old, new, filter) in issue_filters {
|
||||||
|
preferences.issue_filters.remove(&old);
|
||||||
|
preferences.issue_filters.entry(new).or_insert(filter);
|
||||||
|
}
|
||||||
|
if let Some(filter) = preferences.pull_filters.remove(old_url) {
|
||||||
|
preferences
|
||||||
|
.pull_filters
|
||||||
|
.entry(new_url.into())
|
||||||
|
.or_insert(filter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_server_settings(preferences: &mut Preferences, url: &str) {
|
||||||
|
preferences.favorites.retain(|favorite| {
|
||||||
|
RepositoryPane::ALL
|
||||||
|
.iter()
|
||||||
|
.all(|pane| !favorite.starts_with(&format!("{}|{url}|", pane.key())))
|
||||||
|
});
|
||||||
|
let prefix = format!("{url}|");
|
||||||
|
preferences
|
||||||
|
.issue_filters
|
||||||
|
.retain(|key, _| !key.starts_with(&prefix));
|
||||||
|
preferences.pull_filters.remove(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn server(name: &str, url: &str) -> Server {
|
||||||
|
Server {
|
||||||
|
name: name.into(),
|
||||||
|
url: url.into(),
|
||||||
|
provider: gotcha_gitea::Provider::Gitea,
|
||||||
|
credential_account: name.into(),
|
||||||
|
token: "secret".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adjusts_selection_when_servers_are_removed() {
|
||||||
|
assert_eq!(active_server_after_removal(Some(0), 0, 0), None);
|
||||||
|
assert_eq!(active_server_after_removal(Some(0), 0, 2), Some(0));
|
||||||
|
assert_eq!(active_server_after_removal(Some(2), 1, 2), Some(1));
|
||||||
|
assert_eq!(active_server_after_removal(Some(0), 2, 2), Some(0));
|
||||||
|
assert_eq!(active_server_after_removal(None, 0, 1), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migrates_and_removes_server_scoped_settings() {
|
||||||
|
let old = "https://old.example.com";
|
||||||
|
let new = "https://new.example.com";
|
||||||
|
let mut preferences = Preferences {
|
||||||
|
servers: vec![server("Work", old)],
|
||||||
|
favorites: [format!("issues|{old}|octo/demo")].into_iter().collect(),
|
||||||
|
issue_filters: [(
|
||||||
|
format!("{old}|octo/demo"),
|
||||||
|
IssueFilter {
|
||||||
|
milestone: "v1".into(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)]
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
|
pull_filters: [(
|
||||||
|
old.into(),
|
||||||
|
PullFilter {
|
||||||
|
milestone: "v2".into(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)]
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
migrate_server_settings(&mut preferences, old, new);
|
||||||
|
assert!(
|
||||||
|
preferences
|
||||||
|
.favorites
|
||||||
|
.contains(&format!("issues|{new}|octo/demo"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
preferences.issue_filters[&format!("{new}|octo/demo")].milestone,
|
||||||
|
"v1"
|
||||||
|
);
|
||||||
|
assert_eq!(preferences.pull_filters[new].milestone, "v2");
|
||||||
|
|
||||||
|
remove_server_settings(&mut preferences, new);
|
||||||
|
assert!(preferences.favorites.is_empty());
|
||||||
|
assert!(preferences.issue_filters.is_empty());
|
||||||
|
assert!(preferences.pull_filters.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
55
crates/app/src/core/widgets.rs
Normal file
55
crates/app/src/core/widgets.rs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
use crate::*;
|
||||||
|
|
||||||
|
const MAX_WIDGET_ROWS: u32 = 10;
|
||||||
|
|
||||||
|
#[uniffi::export(async_runtime = "tokio")]
|
||||||
|
impl GotchaCore {
|
||||||
|
pub async fn widget_activity(
|
||||||
|
&self,
|
||||||
|
server_id: String,
|
||||||
|
limit: u32,
|
||||||
|
) -> Result<WidgetActivityPage, GotchaError> {
|
||||||
|
let server = self.server_by_id(&server_id)?;
|
||||||
|
let mut rows = activity_rows(&load_activities(&server, 1).await?.items);
|
||||||
|
rows.truncate(widget_limit(limit)?);
|
||||||
|
Ok(WidgetActivityPage {
|
||||||
|
server_name: server.name,
|
||||||
|
rows,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn widget_pulls(
|
||||||
|
&self,
|
||||||
|
server_id: String,
|
||||||
|
limit: u32,
|
||||||
|
) -> Result<WidgetPullPage, GotchaError> {
|
||||||
|
let server = self.server_by_id(&server_id)?;
|
||||||
|
let pulls = load_pulls(&server, "open", "", "", 1).await?;
|
||||||
|
let mut rows = pull_rows(&pulls.items);
|
||||||
|
rows.truncate(widget_limit(limit)?);
|
||||||
|
Ok(WidgetPullPage {
|
||||||
|
server_name: server.name,
|
||||||
|
rows,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn widget_limit(limit: u32) -> Result<usize, GotchaError> {
|
||||||
|
(1..=MAX_WIDGET_ROWS)
|
||||||
|
.contains(&limit)
|
||||||
|
.then_some(limit as usize)
|
||||||
|
.ok_or_else(|| "Widget row count must be between 1 and 10.".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validates_widget_row_limits() {
|
||||||
|
assert_eq!(widget_limit(1).unwrap(), 1);
|
||||||
|
assert_eq!(widget_limit(10).unwrap(), 10);
|
||||||
|
assert!(widget_limit(0).is_err());
|
||||||
|
assert!(widget_limit(11).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
282
crates/app/src/domain.rs
Normal file
282
crates/app/src/domain.rs
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
use std::{
|
||||||
|
collections::{BTreeMap, BTreeSet},
|
||||||
|
path::PathBuf,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub use gotcha_gitea::{
|
||||||
|
HistoryCommit, HomeData, IssueDetails, IssueEditorData, MilestoneDetails, Page, PullDetails,
|
||||||
|
civil_from_days, days_from_civil, parse_api_date, parse_api_timestamp,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum AppearanceMode {
|
||||||
|
Light,
|
||||||
|
Dark,
|
||||||
|
#[default]
|
||||||
|
#[serde(other)]
|
||||||
|
Auto,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppearanceMode {
|
||||||
|
pub fn index(self) -> i32 {
|
||||||
|
match self {
|
||||||
|
Self::Auto => 0,
|
||||||
|
Self::Light => 1,
|
||||||
|
Self::Dark => 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_index(index: i32) -> Option<Self> {
|
||||||
|
match index {
|
||||||
|
0 => Some(Self::Auto),
|
||||||
|
1 => Some(Self::Light),
|
||||||
|
2 => Some(Self::Dark),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize, Serialize)]
|
||||||
|
pub struct Server {
|
||||||
|
pub name: String,
|
||||||
|
pub url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub provider: gotcha_gitea::Provider,
|
||||||
|
#[serde(default)]
|
||||||
|
pub credential_account: String,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize, Serialize)]
|
||||||
|
pub struct Preferences {
|
||||||
|
#[serde(skip)]
|
||||||
|
pub path: PathBuf,
|
||||||
|
#[serde(default)]
|
||||||
|
pub servers: Vec<Server>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub favorites: BTreeSet<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_server: Option<usize>,
|
||||||
|
#[serde(default = "open_status")]
|
||||||
|
pub issue_status: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub issue_filters: BTreeMap<String, IssueFilter>,
|
||||||
|
#[serde(default = "open_status")]
|
||||||
|
pub pull_status: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub pull_filters: BTreeMap<String, PullFilter>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub appearance: AppearanceMode,
|
||||||
|
#[serde(default)]
|
||||||
|
pub notifications_enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub notification_cursors: BTreeMap<String, String>,
|
||||||
|
#[serde(default = "default_primary_destinations")]
|
||||||
|
pub primary_destinations: Vec<crate::PrimaryDestination>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Preferences {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
path: PathBuf::new(),
|
||||||
|
servers: Vec::new(),
|
||||||
|
favorites: BTreeSet::new(),
|
||||||
|
last_server: None,
|
||||||
|
issue_status: open_status(),
|
||||||
|
issue_filters: BTreeMap::new(),
|
||||||
|
pull_status: open_status(),
|
||||||
|
pull_filters: BTreeMap::new(),
|
||||||
|
appearance: AppearanceMode::default(),
|
||||||
|
notifications_enabled: false,
|
||||||
|
notification_cursors: BTreeMap::new(),
|
||||||
|
primary_destinations: default_primary_destinations(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_primary_destinations() -> Vec<crate::PrimaryDestination> {
|
||||||
|
vec![
|
||||||
|
crate::PrimaryDestination::Issues,
|
||||||
|
crate::PrimaryDestination::Repositories,
|
||||||
|
crate::PrimaryDestination::PullRequests,
|
||||||
|
crate::PrimaryDestination::Milestones,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize_primary_destinations(destinations: &mut Vec<crate::PrimaryDestination>) -> bool {
|
||||||
|
let original = destinations.clone();
|
||||||
|
let mut unique = Vec::with_capacity(destinations.len().min(4));
|
||||||
|
for destination in destinations.drain(..) {
|
||||||
|
if destination != crate::PrimaryDestination::ServerActivity
|
||||||
|
&& !unique.contains(&destination)
|
||||||
|
&& unique.len() < 4
|
||||||
|
{
|
||||||
|
unique.push(destination);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*destinations = unique;
|
||||||
|
*destinations != original
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct IssueFilter {
|
||||||
|
#[serde(default)]
|
||||||
|
pub milestone: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub labels: BTreeSet<String>,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub search_text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IssueFilter {
|
||||||
|
pub fn is_active(&self, status: &str) -> bool {
|
||||||
|
status != "open" || self != &Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct PullFilter {
|
||||||
|
#[serde(default)]
|
||||||
|
pub milestone: String,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub search_text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PullFilter {
|
||||||
|
pub fn is_active(&self, status: &str) -> bool {
|
||||||
|
status != "open" || self != &Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct RepositoryData {
|
||||||
|
pub owner: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub language: String,
|
||||||
|
pub open_issues: i64,
|
||||||
|
pub updated: String,
|
||||||
|
pub default_branch: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct State {
|
||||||
|
pub preferences: Preferences,
|
||||||
|
pub active_server: Option<usize>,
|
||||||
|
pub repositories: Vec<RepositoryData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct IssueDraft {
|
||||||
|
pub title: String,
|
||||||
|
pub body: String,
|
||||||
|
pub label_ids: Vec<i64>,
|
||||||
|
pub milestone_id: Option<i64>,
|
||||||
|
pub due_date: Option<i64>,
|
||||||
|
pub closed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct MilestoneDraft {
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
pub due_date: Option<i64>,
|
||||||
|
pub closed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_status() -> String {
|
||||||
|
"open".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn issue_filter_default_state_is_owned_by_rust() {
|
||||||
|
assert!(!IssueFilter::default().is_active("open"));
|
||||||
|
assert!(IssueFilter::default().is_active("closed"));
|
||||||
|
assert!(
|
||||||
|
IssueFilter {
|
||||||
|
milestone: "v1".into(),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.is_active("open")
|
||||||
|
);
|
||||||
|
assert!(!PullFilter::default().is_active("open"));
|
||||||
|
assert!(PullFilter::default().is_active("closed"));
|
||||||
|
assert!(
|
||||||
|
PullFilter {
|
||||||
|
milestone: "v1".into(),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.is_active("open")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
IssueFilter {
|
||||||
|
search_text: "needle".into(),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.is_active("open")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
PullFilter {
|
||||||
|
search_text: "needle".into(),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.is_active("open")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_text_is_session_only() {
|
||||||
|
let issue = IssueFilter {
|
||||||
|
search_text: "needle".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let pull = PullFilter {
|
||||||
|
search_text: "needle".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(!serde_json::to_string(&issue).unwrap().contains("needle"));
|
||||||
|
assert!(!serde_json::to_string(&pull).unwrap().contains("needle"));
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<IssueFilter>(r#"{"milestone":"v1"}"#)
|
||||||
|
.unwrap()
|
||||||
|
.search_text,
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn primary_destinations_are_ordered_unique_and_limited() {
|
||||||
|
let migrated: Preferences = serde_json::from_str("{}").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
migrated.primary_destinations,
|
||||||
|
default_primary_destinations()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut destinations = vec![
|
||||||
|
crate::PrimaryDestination::ServerActivity,
|
||||||
|
crate::PrimaryDestination::Actions,
|
||||||
|
crate::PrimaryDestination::Actions,
|
||||||
|
crate::PrimaryDestination::Issues,
|
||||||
|
crate::PrimaryDestination::Repositories,
|
||||||
|
crate::PrimaryDestination::PullRequests,
|
||||||
|
crate::PrimaryDestination::Milestones,
|
||||||
|
];
|
||||||
|
assert!(normalize_primary_destinations(&mut destinations));
|
||||||
|
assert_eq!(
|
||||||
|
destinations,
|
||||||
|
[
|
||||||
|
crate::PrimaryDestination::Actions,
|
||||||
|
crate::PrimaryDestination::Issues,
|
||||||
|
crate::PrimaryDestination::Repositories,
|
||||||
|
crate::PrimaryDestination::PullRequests,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert!(!destinations.contains(&crate::PrimaryDestination::ServerActivity));
|
||||||
|
}
|
||||||
|
}
|
||||||
170
crates/app/src/lib.rs
Normal file
170
crates/app/src/lib.rs
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
mod api;
|
||||||
|
mod core;
|
||||||
|
mod domain;
|
||||||
|
mod presentation;
|
||||||
|
mod storage;
|
||||||
|
|
||||||
|
use api::*;
|
||||||
|
use domain::*;
|
||||||
|
pub use presentation::*;
|
||||||
|
use storage::*;
|
||||||
|
|
||||||
|
uniffi::setup_scaffolding!();
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, uniffi::Enum)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum PrimaryDestination {
|
||||||
|
Issues,
|
||||||
|
Repositories,
|
||||||
|
PullRequests,
|
||||||
|
Milestones,
|
||||||
|
Actions,
|
||||||
|
ServerActivity,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, uniffi::Enum)]
|
||||||
|
pub enum RepositoryPane {
|
||||||
|
Issues,
|
||||||
|
Commits,
|
||||||
|
Milestones,
|
||||||
|
Actions,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, uniffi::Enum)]
|
||||||
|
pub enum ServerProvider {
|
||||||
|
Gitea,
|
||||||
|
Forgejo,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ServerProvider> for gotcha_gitea::Provider {
|
||||||
|
fn from(provider: ServerProvider) -> Self {
|
||||||
|
match provider {
|
||||||
|
ServerProvider::Gitea => Self::Gitea,
|
||||||
|
ServerProvider::Forgejo => Self::Forgejo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<gotcha_gitea::Provider> for ServerProvider {
|
||||||
|
fn from(provider: gotcha_gitea::Provider) -> Self {
|
||||||
|
match provider {
|
||||||
|
gotcha_gitea::Provider::Gitea => Self::Gitea,
|
||||||
|
gotcha_gitea::Provider::Forgejo => Self::Forgejo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RepositoryPane {
|
||||||
|
const ALL: [Self; 4] = [Self::Issues, Self::Commits, Self::Milestones, Self::Actions];
|
||||||
|
|
||||||
|
const fn key(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Issues => "issues",
|
||||||
|
Self::Commits => "commits",
|
||||||
|
Self::Milestones => "milestones",
|
||||||
|
Self::Actions => "actions",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error, uniffi::Error)]
|
||||||
|
pub enum GotchaError {
|
||||||
|
#[error("{message}")]
|
||||||
|
Message { message: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<String> for GotchaError {
|
||||||
|
fn from(message: String) -> Self {
|
||||||
|
Self::Message { message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&str> for GotchaError {
|
||||||
|
fn from(message: &str) -> Self {
|
||||||
|
message.to_string().into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct Settings {
|
||||||
|
pub issue_status: String,
|
||||||
|
pub pull_status: String,
|
||||||
|
pub appearance: u32,
|
||||||
|
pub notifications_enabled: bool,
|
||||||
|
pub primary_destinations: Vec<PrimaryDestination>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(uniffi::Object)]
|
||||||
|
pub struct GotchaCore {
|
||||||
|
state: Mutex<State>,
|
||||||
|
server_activity: tokio::sync::Mutex<Option<ServerActivitySession>>,
|
||||||
|
startup_error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ServerActivitySession {
|
||||||
|
server_id: String,
|
||||||
|
next_page: u32,
|
||||||
|
pager: gotcha_gitea::ServerActivityPager,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_repository<'a>(
|
||||||
|
owner: &'a str,
|
||||||
|
repository: &'a str,
|
||||||
|
) -> Result<(&'a str, &'a str), GotchaError> {
|
||||||
|
let owner = owner.trim();
|
||||||
|
let repository = repository.trim();
|
||||||
|
if owner.is_empty() || repository.is_empty() {
|
||||||
|
return Err("Select a repository first.".into());
|
||||||
|
}
|
||||||
|
Ok((owner, repository))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_page(page: u32) -> Result<i32, GotchaError> {
|
||||||
|
(page > 0)
|
||||||
|
.then(|| i32::try_from(page).ok())
|
||||||
|
.flatten()
|
||||||
|
.ok_or_else(|| "Invalid page number.".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GotchaCore {
|
||||||
|
fn server(&self) -> Result<Server, GotchaError> {
|
||||||
|
let state = self.state.lock().unwrap();
|
||||||
|
state
|
||||||
|
.active_server
|
||||||
|
.and_then(|index| state.preferences.servers.get(index))
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| "Select a server first.".to_string().into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn server_by_id(&self, id: &str) -> Result<Server, GotchaError> {
|
||||||
|
self.state
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.preferences
|
||||||
|
.servers
|
||||||
|
.iter()
|
||||||
|
.find(|server| server.credential_account == id)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| "That server is no longer configured.".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn repository_rows(&self, state: &State, pane: RepositoryPane) -> Vec<RepositoryRow> {
|
||||||
|
let server_url = state
|
||||||
|
.active_server
|
||||||
|
.and_then(|index| state.preferences.servers.get(index))
|
||||||
|
.map(|server| server.url.as_str())
|
||||||
|
.unwrap_or_default();
|
||||||
|
repository_rows(&state.repositories, |repository| {
|
||||||
|
state.preferences.favorites.contains(&favorite_key(
|
||||||
|
pane,
|
||||||
|
server_url,
|
||||||
|
&repository.owner,
|
||||||
|
&repository.name,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
503
crates/app/src/presentation.rs
Normal file
503
crates/app/src/presentation.rs
Normal file
@@ -0,0 +1,503 @@
|
|||||||
|
use gotcha_gitea::{
|
||||||
|
PullFileSource, activity, comment_can_edit, diff, models, pull_file_source, pull_state,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::domain::{
|
||||||
|
HistoryCommit, HomeData, IssueDetails, IssueEditorData, IssueFilter, MilestoneDetails,
|
||||||
|
PullDetails, PullFilter, RepositoryData, civil_from_days, days_from_civil, parse_api_date,
|
||||||
|
parse_api_timestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||||
|
pub enum WorkItemState {
|
||||||
|
Open,
|
||||||
|
Closed,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct ServerRow {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct ServerEditor {
|
||||||
|
pub name: String,
|
||||||
|
pub url: String,
|
||||||
|
pub provider: crate::ServerProvider,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct RepositoryRow {
|
||||||
|
pub name: String,
|
||||||
|
pub owner: String,
|
||||||
|
pub description: String,
|
||||||
|
pub meta: String,
|
||||||
|
pub favorite: bool,
|
||||||
|
pub default_branch: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||||
|
pub enum ActionState {
|
||||||
|
Queued,
|
||||||
|
Waiting,
|
||||||
|
InProgress,
|
||||||
|
Succeeded,
|
||||||
|
Failed,
|
||||||
|
Cancelled,
|
||||||
|
Skipped,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct ActionWorkflowRow {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub path: String,
|
||||||
|
pub state: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct ActionRunRow {
|
||||||
|
pub id: i64,
|
||||||
|
pub number: i64,
|
||||||
|
pub title: String,
|
||||||
|
pub state: ActionState,
|
||||||
|
pub status: String,
|
||||||
|
pub conclusion: String,
|
||||||
|
pub branch: String,
|
||||||
|
pub event: String,
|
||||||
|
pub meta: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct ActionStepRow {
|
||||||
|
pub name: String,
|
||||||
|
pub state: ActionState,
|
||||||
|
pub meta: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct ActionJobRow {
|
||||||
|
pub id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub state: ActionState,
|
||||||
|
pub meta: String,
|
||||||
|
pub steps: Vec<ActionStepRow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct ActionRunListPage {
|
||||||
|
pub rows: Vec<ActionRunRow>,
|
||||||
|
pub has_more: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct ActionRunPage {
|
||||||
|
pub run: ActionRunRow,
|
||||||
|
pub jobs: Vec<ActionJobRow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct ActionJobLogPage {
|
||||||
|
pub job: ActionJobRow,
|
||||||
|
pub text: String,
|
||||||
|
pub groups: Vec<ActionLogGroupRow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct ActionLogGroupRow {
|
||||||
|
pub name: String,
|
||||||
|
pub text: String,
|
||||||
|
pub line_count: u64,
|
||||||
|
pub state: ActionState,
|
||||||
|
pub duration: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct RepositoryListPage {
|
||||||
|
pub rows: Vec<RepositoryRow>,
|
||||||
|
pub has_more: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct LabelRow {
|
||||||
|
pub name: String,
|
||||||
|
pub color: String,
|
||||||
|
pub light: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct IssueRow {
|
||||||
|
pub number: i64,
|
||||||
|
pub state: WorkItemState,
|
||||||
|
pub title: String,
|
||||||
|
pub summary: String,
|
||||||
|
pub meta: String,
|
||||||
|
pub milestone: String,
|
||||||
|
pub labels: Vec<LabelRow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct IssueListPage {
|
||||||
|
pub rows: Vec<IssueRow>,
|
||||||
|
pub has_more: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct PullRow {
|
||||||
|
pub number: i64,
|
||||||
|
pub owner: String,
|
||||||
|
pub repository: String,
|
||||||
|
pub state: WorkItemState,
|
||||||
|
pub title: String,
|
||||||
|
pub summary: String,
|
||||||
|
pub meta: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct CommentRow {
|
||||||
|
pub id: i64,
|
||||||
|
pub author: String,
|
||||||
|
pub body: String,
|
||||||
|
pub meta: String,
|
||||||
|
pub can_edit: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct IssuePage {
|
||||||
|
pub title: String,
|
||||||
|
pub state: WorkItemState,
|
||||||
|
pub meta: String,
|
||||||
|
pub milestone: String,
|
||||||
|
pub labels: Vec<LabelRow>,
|
||||||
|
pub body: String,
|
||||||
|
pub comments: Vec<CommentRow>,
|
||||||
|
pub has_more: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct IssueEditorLabel {
|
||||||
|
pub id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub selected: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct IssueEditorMilestone {
|
||||||
|
pub id: i64,
|
||||||
|
pub title: String,
|
||||||
|
pub selected: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct IssueEditorPage {
|
||||||
|
pub title: String,
|
||||||
|
pub body: String,
|
||||||
|
pub due_date: Option<i64>,
|
||||||
|
pub closed: bool,
|
||||||
|
pub labels: Vec<IssueEditorLabel>,
|
||||||
|
pub milestones: Vec<IssueEditorMilestone>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct IssueFilterOptions {
|
||||||
|
pub milestones: Vec<String>,
|
||||||
|
pub labels: Vec<String>,
|
||||||
|
pub unavailable_labels: Vec<String>,
|
||||||
|
pub selected_milestone: String,
|
||||||
|
pub selected_labels: Vec<String>,
|
||||||
|
pub search_text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct MilestoneRow {
|
||||||
|
pub id: i64,
|
||||||
|
pub state: WorkItemState,
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
pub meta: String,
|
||||||
|
pub has_issues: bool,
|
||||||
|
pub progress: f64,
|
||||||
|
pub progress_accessibility: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct MilestonePage {
|
||||||
|
pub milestone: MilestoneRow,
|
||||||
|
pub issues: Vec<IssueRow>,
|
||||||
|
pub pulls: Vec<PullRow>,
|
||||||
|
pub has_more: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct MilestoneListPage {
|
||||||
|
pub rows: Vec<MilestoneRow>,
|
||||||
|
pub has_more: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct MilestoneEditorPage {
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
pub due_date: Option<i64>,
|
||||||
|
pub closed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct PullFilterOptions {
|
||||||
|
pub milestones: Vec<String>,
|
||||||
|
pub selected_milestone: String,
|
||||||
|
pub search_text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct PullPage {
|
||||||
|
pub title: String,
|
||||||
|
pub state: WorkItemState,
|
||||||
|
pub meta: String,
|
||||||
|
pub body: String,
|
||||||
|
pub files_ref: String,
|
||||||
|
pub files: Vec<FileRow>,
|
||||||
|
pub comments: Vec<CommentRow>,
|
||||||
|
pub has_more: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct PullListPage {
|
||||||
|
pub rows: Vec<PullRow>,
|
||||||
|
pub has_more: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct CommitRow {
|
||||||
|
pub sha: String,
|
||||||
|
pub title: String,
|
||||||
|
pub detail: String,
|
||||||
|
pub branch_label: Option<String>,
|
||||||
|
pub top_lanes: Vec<u32>,
|
||||||
|
pub bottom_lanes: Vec<u32>,
|
||||||
|
pub node_lane: Option<u32>,
|
||||||
|
pub top_connections: Vec<u32>,
|
||||||
|
pub bottom_connections: Vec<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct CommitPage {
|
||||||
|
pub branches: Vec<String>,
|
||||||
|
pub commits: Vec<CommitRow>,
|
||||||
|
pub lane_count: u32,
|
||||||
|
pub has_more: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct CommitMetadataRow {
|
||||||
|
pub label: String,
|
||||||
|
pub value: String,
|
||||||
|
pub monospaced: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct CommitDetailsPage {
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
pub metadata: Vec<CommitMetadataRow>,
|
||||||
|
pub files: Vec<FileRow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct FileRow {
|
||||||
|
pub path: String,
|
||||||
|
pub status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct FileListPage {
|
||||||
|
pub rows: Vec<FileRow>,
|
||||||
|
pub has_more: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct RepositoryContentRow {
|
||||||
|
pub name: String,
|
||||||
|
pub path: String,
|
||||||
|
pub kind: RepositoryContentKind,
|
||||||
|
pub size: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||||
|
pub enum RepositoryContentKind {
|
||||||
|
Directory,
|
||||||
|
File,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct RepositoryFilePage {
|
||||||
|
pub name: String,
|
||||||
|
pub kind: RepositoryFileKind,
|
||||||
|
pub language: String,
|
||||||
|
pub text: String,
|
||||||
|
pub data: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||||
|
pub enum RepositoryFileKind {
|
||||||
|
Markdown,
|
||||||
|
Source,
|
||||||
|
Preview,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct DiffLine {
|
||||||
|
pub old_number: String,
|
||||||
|
pub new_number: String,
|
||||||
|
pub text: String,
|
||||||
|
pub kind: DiffLineKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||||
|
pub enum DiffLineKind {
|
||||||
|
Addition,
|
||||||
|
Removal,
|
||||||
|
Hunk,
|
||||||
|
Header,
|
||||||
|
Context,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct DiffPage {
|
||||||
|
pub title: String,
|
||||||
|
pub columns: u32,
|
||||||
|
pub lines: Vec<DiffLine>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct ActivityRow {
|
||||||
|
pub icon: ActivityIcon,
|
||||||
|
pub title: String,
|
||||||
|
pub detail: String,
|
||||||
|
pub meta: String,
|
||||||
|
pub target: ActivityTargetKind,
|
||||||
|
pub owner: String,
|
||||||
|
pub repository: String,
|
||||||
|
pub number: i64,
|
||||||
|
pub sha: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||||
|
pub enum ActivityIcon {
|
||||||
|
Repository,
|
||||||
|
Issue,
|
||||||
|
PullRequest,
|
||||||
|
Branch,
|
||||||
|
Tag,
|
||||||
|
Push,
|
||||||
|
Release,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||||
|
pub enum ActivityTargetKind {
|
||||||
|
None,
|
||||||
|
Repository,
|
||||||
|
Issue,
|
||||||
|
PullRequest,
|
||||||
|
Commit,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, uniffi::Enum)]
|
||||||
|
pub enum HomeActivityFilter {
|
||||||
|
All,
|
||||||
|
Issues,
|
||||||
|
PullRequests,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<HomeActivityFilter> for gotcha_gitea::ActivityFilter {
|
||||||
|
fn from(filter: HomeActivityFilter) -> Self {
|
||||||
|
match filter {
|
||||||
|
HomeActivityFilter::All => Self::All,
|
||||||
|
HomeActivityFilter::Issues => Self::Issues,
|
||||||
|
HomeActivityFilter::PullRequests => Self::PullRequests,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct HeatCell {
|
||||||
|
pub level: u32,
|
||||||
|
pub timestamp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct HomePage {
|
||||||
|
pub server_name: String,
|
||||||
|
pub activities: Vec<ActivityRow>,
|
||||||
|
pub heat_cells: Vec<HeatCell>,
|
||||||
|
pub contribution_count: i64,
|
||||||
|
pub next_page: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct ServerActivityPage {
|
||||||
|
pub rows: Vec<ActivityRow>,
|
||||||
|
pub has_more: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, uniffi::Enum)]
|
||||||
|
pub enum NotificationStatus {
|
||||||
|
Open,
|
||||||
|
Closed,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct NotificationRow {
|
||||||
|
pub id: i64,
|
||||||
|
pub server_id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub detail: String,
|
||||||
|
pub meta: String,
|
||||||
|
pub unread: bool,
|
||||||
|
pub target: ActivityTargetKind,
|
||||||
|
pub owner: String,
|
||||||
|
pub repository: String,
|
||||||
|
pub number: i64,
|
||||||
|
pub sha: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct NotificationListPage {
|
||||||
|
pub rows: Vec<NotificationRow>,
|
||||||
|
pub has_more: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct WidgetActivityPage {
|
||||||
|
pub server_name: String,
|
||||||
|
pub rows: Vec<ActivityRow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct WidgetPullPage {
|
||||||
|
pub server_name: String,
|
||||||
|
pub rows: Vec<PullRow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
mod actions;
|
||||||
|
mod details;
|
||||||
|
mod files;
|
||||||
|
mod helpers;
|
||||||
|
mod home;
|
||||||
|
mod lists;
|
||||||
|
mod notifications;
|
||||||
|
|
||||||
|
pub use actions::*;
|
||||||
|
pub use details::*;
|
||||||
|
pub use files::*;
|
||||||
|
pub use helpers::compact_date;
|
||||||
|
pub use home::*;
|
||||||
|
pub use lists::*;
|
||||||
|
pub use notifications::*;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
240
crates/app/src/presentation/actions.rs
Normal file
240
crates/app/src/presentation/actions.rs
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
use gotcha_gitea::{ActionJobDetails, ActionRunDetails, models};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub fn action_workflow_rows(workflows: Vec<models::ActionWorkflow>) -> Vec<ActionWorkflowRow> {
|
||||||
|
workflows
|
||||||
|
.into_iter()
|
||||||
|
.map(|workflow| {
|
||||||
|
let path = workflow.path.unwrap_or_default();
|
||||||
|
ActionWorkflowRow {
|
||||||
|
id: workflow.id.unwrap_or_else(|| path.clone()),
|
||||||
|
name: workflow.name.unwrap_or_else(|| "Unnamed workflow".into()),
|
||||||
|
path,
|
||||||
|
state: workflow.state.unwrap_or_else(|| "unknown".into()),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn action_run_rows(runs: &[models::ActionWorkflowRun]) -> Vec<ActionRunRow> {
|
||||||
|
runs.iter().map(action_run_row).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn action_run_page(details: ActionRunDetails) -> ActionRunPage {
|
||||||
|
ActionRunPage {
|
||||||
|
run: action_run_row(&details.run),
|
||||||
|
jobs: details.jobs.iter().map(action_job_row).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn action_job_log_page(details: ActionJobDetails) -> ActionJobLogPage {
|
||||||
|
let job = action_job_row(&details.job);
|
||||||
|
let groups = details
|
||||||
|
.groups
|
||||||
|
.into_iter()
|
||||||
|
.map(|group| ActionLogGroupRow {
|
||||||
|
state: action_log_group_state(&job, &group.name, &group.text),
|
||||||
|
duration: group
|
||||||
|
.duration_seconds
|
||||||
|
.map(format_duration)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
line_count: group.text.lines().count() as u64,
|
||||||
|
name: group.name,
|
||||||
|
text: group.text,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
ActionJobLogPage {
|
||||||
|
job,
|
||||||
|
text: details.text,
|
||||||
|
groups,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_duration(seconds: u64) -> String {
|
||||||
|
if seconds >= 3_600 {
|
||||||
|
format!("{}h {}m", seconds / 3_600, seconds % 3_600 / 60)
|
||||||
|
} else if seconds >= 60 {
|
||||||
|
format!("{}m {}s", seconds / 60, seconds % 60)
|
||||||
|
} else {
|
||||||
|
format!("{seconds}s")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn action_log_group_state(job: &ActionJobRow, name: &str, text: &str) -> ActionState {
|
||||||
|
if let Some(step) = job
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.find(|step| step.name.eq_ignore_ascii_case(name))
|
||||||
|
{
|
||||||
|
return step.state;
|
||||||
|
}
|
||||||
|
if text.contains("::error::")
|
||||||
|
|| text.contains("##[error]")
|
||||||
|
|| text.contains("Process completed with exit code")
|
||||||
|
|| text.contains("Job failed")
|
||||||
|
{
|
||||||
|
ActionState::Failed
|
||||||
|
} else if name == "Complete job" {
|
||||||
|
job.state
|
||||||
|
} else if matches!(job.state, ActionState::Succeeded | ActionState::Failed) {
|
||||||
|
ActionState::Succeeded
|
||||||
|
} else {
|
||||||
|
ActionState::Unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn action_run_row(run: &models::ActionWorkflowRun) -> ActionRunRow {
|
||||||
|
let status = run.status.clone().unwrap_or_else(|| "unknown".into());
|
||||||
|
let conclusion = run.conclusion.clone().unwrap_or_default();
|
||||||
|
let duration = duration_between(run.started_at.as_deref(), run.completed_at.as_deref());
|
||||||
|
ActionRunRow {
|
||||||
|
id: run.id.unwrap_or_default(),
|
||||||
|
number: run.run_number.unwrap_or_default(),
|
||||||
|
title: run
|
||||||
|
.display_title
|
||||||
|
.clone()
|
||||||
|
.or_else(|| run.path.clone())
|
||||||
|
.unwrap_or_else(|| "Workflow run".into()),
|
||||||
|
state: action_state(&status, &conclusion),
|
||||||
|
status,
|
||||||
|
conclusion,
|
||||||
|
branch: run.head_branch.clone().unwrap_or_default(),
|
||||||
|
event: run.event.clone().unwrap_or_default(),
|
||||||
|
meta: [
|
||||||
|
duration.as_deref().unwrap_or_default(),
|
||||||
|
run.started_at.as_deref().unwrap_or_default(),
|
||||||
|
run.actor
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|actor| actor.login.as_deref())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" · "),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn action_job_row(job: &models::ActionWorkflowJob) -> ActionJobRow {
|
||||||
|
let status = job.status.as_deref().unwrap_or("unknown");
|
||||||
|
let conclusion = job.conclusion.as_deref().unwrap_or_default();
|
||||||
|
let duration = duration_between(job.started_at.as_deref(), job.completed_at.as_deref());
|
||||||
|
ActionJobRow {
|
||||||
|
id: job.id.unwrap_or_default(),
|
||||||
|
name: job.name.clone().unwrap_or_else(|| "Job".into()),
|
||||||
|
state: action_state(status, conclusion),
|
||||||
|
meta: [
|
||||||
|
status,
|
||||||
|
conclusion,
|
||||||
|
duration.as_deref().unwrap_or_default(),
|
||||||
|
job.runner_name.as_deref().unwrap_or_default(),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" · "),
|
||||||
|
steps: job
|
||||||
|
.steps
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter()
|
||||||
|
.map(|step| {
|
||||||
|
let status = step.status.as_deref().unwrap_or("unknown");
|
||||||
|
let conclusion = step.conclusion.as_deref().unwrap_or_default();
|
||||||
|
let duration =
|
||||||
|
duration_between(step.started_at.as_deref(), step.completed_at.as_deref());
|
||||||
|
ActionStepRow {
|
||||||
|
name: step.name.clone().unwrap_or_else(|| "Step".into()),
|
||||||
|
state: action_state(status, conclusion),
|
||||||
|
meta: [status, conclusion, duration.as_deref().unwrap_or_default()]
|
||||||
|
.into_iter()
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" · "),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn duration_between(start: Option<&str>, end: Option<&str>) -> Option<String> {
|
||||||
|
let seconds = parse_api_timestamp(end?)?.checked_sub(parse_api_timestamp(start?)?)?;
|
||||||
|
u64::try_from(seconds).ok().map(format_duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn action_state(status: &str, conclusion: &str) -> ActionState {
|
||||||
|
match conclusion {
|
||||||
|
"success" => ActionState::Succeeded,
|
||||||
|
"failure" | "timed_out" | "startup_failure" | "stale" => ActionState::Failed,
|
||||||
|
"cancelled" => ActionState::Cancelled,
|
||||||
|
"skipped" | "neutral" => ActionState::Skipped,
|
||||||
|
_ => match status {
|
||||||
|
"queued" | "requested" => ActionState::Queued,
|
||||||
|
"waiting" | "pending" | "blocked" => ActionState::Waiting,
|
||||||
|
"in_progress" | "running" => ActionState::InProgress,
|
||||||
|
"success" => ActionState::Succeeded,
|
||||||
|
"failure" | "timed_out" | "startup_failure" | "stale" => ActionState::Failed,
|
||||||
|
"cancelled" => ActionState::Cancelled,
|
||||||
|
"skipped" | "neutral" => ActionState::Skipped,
|
||||||
|
_ => ActionState::Unknown,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classifies_progress_and_terminal_conclusions() {
|
||||||
|
assert_eq!(action_state("queued", ""), ActionState::Queued);
|
||||||
|
assert_eq!(action_state("waiting", ""), ActionState::Waiting);
|
||||||
|
assert_eq!(action_state("in_progress", ""), ActionState::InProgress);
|
||||||
|
assert_eq!(action_state("completed", "success"), ActionState::Succeeded);
|
||||||
|
assert_eq!(action_state("completed", "failure"), ActionState::Failed);
|
||||||
|
assert_eq!(
|
||||||
|
action_state("completed", "cancelled"),
|
||||||
|
ActionState::Cancelled
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut workflow = models::ActionWorkflow::new();
|
||||||
|
workflow.path = Some(".gitea/workflows/ci.yml".into());
|
||||||
|
assert_eq!(
|
||||||
|
action_workflow_rows(vec![workflow])[0].id,
|
||||||
|
".gitea/workflows/ci.yml"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut job = models::ActionWorkflowJob::new();
|
||||||
|
job.id = Some(1);
|
||||||
|
job.name = Some("build".into());
|
||||||
|
job.status = Some("completed".into());
|
||||||
|
job.conclusion = Some("failure".into());
|
||||||
|
let mut step = models::ActionWorkflowStep::new();
|
||||||
|
step.name = Some("Test".into());
|
||||||
|
step.status = Some("completed".into());
|
||||||
|
step.conclusion = Some("failure".into());
|
||||||
|
job.steps = Some(vec![step]);
|
||||||
|
let log = action_job_log_page(ActionJobDetails {
|
||||||
|
job,
|
||||||
|
text: "one\ntwo".into(),
|
||||||
|
groups: vec![
|
||||||
|
gotcha_gitea::ActionLogGroup {
|
||||||
|
name: "Test".into(),
|
||||||
|
text: "one\ntwo".into(),
|
||||||
|
duration_seconds: Some(65),
|
||||||
|
},
|
||||||
|
gotcha_gitea::ActionLogGroup {
|
||||||
|
name: "Set up job".into(),
|
||||||
|
text: "ready".into(),
|
||||||
|
duration_seconds: Some(1),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert_eq!(log.groups[0].name, "Test");
|
||||||
|
assert_eq!(log.groups[0].line_count, 2);
|
||||||
|
assert_eq!(log.groups[0].state, ActionState::Failed);
|
||||||
|
assert_eq!(log.groups[0].duration, "1m 5s");
|
||||||
|
assert_eq!(log.groups[1].state, ActionState::Succeeded);
|
||||||
|
}
|
||||||
|
}
|
||||||
256
crates/app/src/presentation/details.rs
Normal file
256
crates/app/src/presentation/details.rs
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
use super::{helpers::*, *};
|
||||||
|
|
||||||
|
pub fn issue_page(details: IssueDetails) -> IssuePage {
|
||||||
|
IssuePage {
|
||||||
|
title: details
|
||||||
|
.issue
|
||||||
|
.title
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "Untitled issue".into()),
|
||||||
|
state: work_item_state(details.issue.state.as_deref()),
|
||||||
|
meta: issue_meta(&details.issue),
|
||||||
|
milestone: issue_milestone(&details.issue),
|
||||||
|
labels: details
|
||||||
|
.issue
|
||||||
|
.labels
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter()
|
||||||
|
.filter_map(label_row)
|
||||||
|
.collect(),
|
||||||
|
body: details
|
||||||
|
.issue
|
||||||
|
.body
|
||||||
|
.filter(|body| !body.is_empty())
|
||||||
|
.unwrap_or_else(|| "No description provided.".into()),
|
||||||
|
comments: details
|
||||||
|
.comments
|
||||||
|
.iter()
|
||||||
|
.map(|comment| comment_row(comment, details.viewer_id))
|
||||||
|
.collect(),
|
||||||
|
has_more: details.has_more,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn issue_editor_page(data: IssueEditorData) -> IssueEditorPage {
|
||||||
|
let selected_labels: std::collections::BTreeSet<_> = data
|
||||||
|
.issue
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|issue| issue.labels.as_deref())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|label| label.id)
|
||||||
|
.collect();
|
||||||
|
let selected_milestone = data
|
||||||
|
.issue
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|issue| issue.milestone.as_ref())
|
||||||
|
.and_then(|milestone| milestone.id);
|
||||||
|
let mut labels: Vec<_> = data
|
||||||
|
.labels
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|label| {
|
||||||
|
let id = label.id?;
|
||||||
|
Some(IssueEditorLabel {
|
||||||
|
id,
|
||||||
|
name: label.name?,
|
||||||
|
selected: selected_labels.contains(&id),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
labels.sort_by_key(|label| label.name.to_lowercase());
|
||||||
|
let mut milestones: Vec<_> = data
|
||||||
|
.milestones
|
||||||
|
.into_iter()
|
||||||
|
.filter(|milestone| {
|
||||||
|
milestone.state.as_deref() != Some("closed") || milestone.id == selected_milestone
|
||||||
|
})
|
||||||
|
.filter_map(|milestone| {
|
||||||
|
let id = milestone.id?;
|
||||||
|
Some(IssueEditorMilestone {
|
||||||
|
id,
|
||||||
|
title: milestone.title?,
|
||||||
|
selected: Some(id) == selected_milestone,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
milestones.sort_by_key(|milestone| milestone.title.to_lowercase());
|
||||||
|
IssueEditorPage {
|
||||||
|
title: data
|
||||||
|
.issue
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|issue| issue.title.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
body: data
|
||||||
|
.issue
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|issue| issue.body.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
due_date: data
|
||||||
|
.issue
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|issue| issue.due_date.as_deref())
|
||||||
|
.and_then(parse_api_date),
|
||||||
|
closed: data.issue.as_ref().and_then(|issue| issue.state.as_deref()) == Some("closed"),
|
||||||
|
labels,
|
||||||
|
milestones,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pull_page(details: PullDetails) -> PullPage {
|
||||||
|
let pull = &details.pull;
|
||||||
|
let author = pull
|
||||||
|
.user
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|user| user.login.as_deref())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
let head = pull
|
||||||
|
.head
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|branch| branch.label.as_deref().or(branch.r#ref.as_deref()))
|
||||||
|
.unwrap_or("head");
|
||||||
|
let base = pull
|
||||||
|
.base
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|branch| branch.label.as_deref().or(branch.r#ref.as_deref()))
|
||||||
|
.unwrap_or("base");
|
||||||
|
let state = pull_state(pull);
|
||||||
|
PullPage {
|
||||||
|
title: pull
|
||||||
|
.title
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "Untitled pull request".into()),
|
||||||
|
state: work_item_state(pull.state.as_deref()),
|
||||||
|
meta: format!(
|
||||||
|
"#{} · {state} · {author} · {head} → {base}\n{} files · +{} −{} · {} comments",
|
||||||
|
pull.number.unwrap_or_default(),
|
||||||
|
pull.changed_files.unwrap_or_default(),
|
||||||
|
pull.additions.unwrap_or_default(),
|
||||||
|
pull.deletions.unwrap_or_default(),
|
||||||
|
pull.comments.unwrap_or_default(),
|
||||||
|
),
|
||||||
|
body: pull
|
||||||
|
.body
|
||||||
|
.clone()
|
||||||
|
.filter(|body| !body.is_empty())
|
||||||
|
.unwrap_or_else(|| "No description provided.".into()),
|
||||||
|
files_ref: pull_files_ref(pull),
|
||||||
|
files: details.files.iter().filter_map(file_row).collect(),
|
||||||
|
comments: details
|
||||||
|
.comments
|
||||||
|
.iter()
|
||||||
|
.map(|comment| comment_row(comment, None))
|
||||||
|
.collect(),
|
||||||
|
has_more: details.has_more,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn file_rows(files: Vec<models::ChangedFile>) -> Vec<FileRow> {
|
||||||
|
files.iter().filter_map(file_row).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn commit_file_rows(files: Vec<models::CommitAffectedFiles>) -> Vec<FileRow> {
|
||||||
|
files
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|file| {
|
||||||
|
Some(FileRow {
|
||||||
|
path: file.filename?,
|
||||||
|
status: file.status.unwrap_or_else(|| "modified".into()),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn commit_details_page(commit: models::Commit, branch: Option<String>) -> CommitDetailsPage {
|
||||||
|
let details = commit.commit.as_deref();
|
||||||
|
let message = details
|
||||||
|
.and_then(|details| details.message.as_deref())
|
||||||
|
.unwrap_or("Commit");
|
||||||
|
let mut lines = message.lines();
|
||||||
|
let title = lines.next().unwrap_or("Commit").to_string();
|
||||||
|
let description = lines.collect::<Vec<_>>().join("\n").trim().to_string();
|
||||||
|
let author = details
|
||||||
|
.and_then(|details| details.author.as_deref())
|
||||||
|
.and_then(commit_user)
|
||||||
|
.or_else(|| {
|
||||||
|
commit
|
||||||
|
.author
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|author| author.login.clone())
|
||||||
|
});
|
||||||
|
let committer = details
|
||||||
|
.and_then(|details| details.committer.as_deref())
|
||||||
|
.and_then(commit_user)
|
||||||
|
.or_else(|| {
|
||||||
|
commit
|
||||||
|
.committer
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|committer| committer.login.clone())
|
||||||
|
});
|
||||||
|
let date = details
|
||||||
|
.and_then(|details| details.committer.as_deref())
|
||||||
|
.and_then(|committer| committer.date.as_deref())
|
||||||
|
.or_else(|| {
|
||||||
|
details
|
||||||
|
.and_then(|details| details.author.as_deref())
|
||||||
|
.and_then(|author| author.date.as_deref())
|
||||||
|
})
|
||||||
|
.or(commit.created.as_deref());
|
||||||
|
let files = commit.files.unwrap_or_default();
|
||||||
|
let mut metadata = Vec::new();
|
||||||
|
if let Some(author) = author {
|
||||||
|
metadata.push(metadata_row("Author", author, false));
|
||||||
|
}
|
||||||
|
if let Some(committer) = committer {
|
||||||
|
metadata.push(metadata_row("Committer", committer, false));
|
||||||
|
}
|
||||||
|
if date.is_some() {
|
||||||
|
metadata.push(metadata_row("Committed", compact_date(date), false));
|
||||||
|
}
|
||||||
|
if let Some(branch) = branch.filter(|branch| !branch.is_empty()) {
|
||||||
|
metadata.push(metadata_row("Branch", branch, false));
|
||||||
|
}
|
||||||
|
metadata.push(metadata_row(
|
||||||
|
"Commit",
|
||||||
|
commit.sha.clone().unwrap_or_else(|| "unknown".into()),
|
||||||
|
true,
|
||||||
|
));
|
||||||
|
let verification = details.and_then(|details| details.verification.as_deref());
|
||||||
|
metadata.push(metadata_row(
|
||||||
|
"Signature",
|
||||||
|
match verification.filter(|verification| {
|
||||||
|
verification
|
||||||
|
.signature
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|signature| !signature.is_empty())
|
||||||
|
}) {
|
||||||
|
Some(verification) if verification.verified.unwrap_or(false) => "Verified".into(),
|
||||||
|
Some(_) => "Unverified".into(),
|
||||||
|
None => "Unsigned".into(),
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
CommitDetailsPage {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
metadata,
|
||||||
|
files: commit_file_rows(files),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commit_user(user: &models::CommitUser) -> Option<String> {
|
||||||
|
match (user.name.as_deref(), user.email.as_deref()) {
|
||||||
|
(Some(name), Some(email)) => Some(format!("{name} <{email}>")),
|
||||||
|
(Some(name), None) => Some(name.into()),
|
||||||
|
(None, Some(email)) => Some(email.into()),
|
||||||
|
(None, None) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metadata_row(label: &str, value: String, monospaced: bool) -> CommitMetadataRow {
|
||||||
|
CommitMetadataRow {
|
||||||
|
label: label.into(),
|
||||||
|
value,
|
||||||
|
monospaced,
|
||||||
|
}
|
||||||
|
}
|
||||||
115
crates/app/src/presentation/files.rs
Normal file
115
crates/app/src/presentation/files.rs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
use super::{helpers::*, *};
|
||||||
|
|
||||||
|
pub fn repository_content_rows(
|
||||||
|
contents: Vec<models::ContentsResponse>,
|
||||||
|
) -> Vec<RepositoryContentRow> {
|
||||||
|
let mut rows: Vec<_> = contents
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|content| {
|
||||||
|
Some(RepositoryContentRow {
|
||||||
|
name: content.name?,
|
||||||
|
path: content.path?,
|
||||||
|
kind: if content.r#type.as_deref() == Some("dir") {
|
||||||
|
RepositoryContentKind::Directory
|
||||||
|
} else {
|
||||||
|
RepositoryContentKind::File
|
||||||
|
},
|
||||||
|
size: content.size.unwrap_or_default(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
rows.sort_by_key(|row| {
|
||||||
|
(
|
||||||
|
row.kind != RepositoryContentKind::Directory,
|
||||||
|
row.name.to_lowercase(),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn repository_file_page(path: &str, data: Vec<u8>) -> RepositoryFilePage {
|
||||||
|
let name = path.rsplit('/').next().unwrap_or("Preview").to_string();
|
||||||
|
let extension = path.rsplit('.').next().unwrap_or_default().to_lowercase();
|
||||||
|
let language = source_language(path).unwrap_or_default().to_string();
|
||||||
|
let markdown = matches!(extension.as_str(), "md" | "markdown" | "mdown" | "mkd");
|
||||||
|
let media = matches!(
|
||||||
|
extension.as_str(),
|
||||||
|
"apng"
|
||||||
|
| "avi"
|
||||||
|
| "bmp"
|
||||||
|
| "flac"
|
||||||
|
| "gif"
|
||||||
|
| "heic"
|
||||||
|
| "heif"
|
||||||
|
| "jpeg"
|
||||||
|
| "jpg"
|
||||||
|
| "m4a"
|
||||||
|
| "m4v"
|
||||||
|
| "mkv"
|
||||||
|
| "mov"
|
||||||
|
| "mp3"
|
||||||
|
| "mp4"
|
||||||
|
| "mpeg"
|
||||||
|
| "mpg"
|
||||||
|
| "ogg"
|
||||||
|
| "pdf"
|
||||||
|
| "png"
|
||||||
|
| "tif"
|
||||||
|
| "tiff"
|
||||||
|
| "wav"
|
||||||
|
| "webm"
|
||||||
|
| "webp"
|
||||||
|
);
|
||||||
|
if media {
|
||||||
|
return RepositoryFilePage {
|
||||||
|
name,
|
||||||
|
kind: RepositoryFileKind::Preview,
|
||||||
|
language,
|
||||||
|
text: String::new(),
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
match String::from_utf8(data) {
|
||||||
|
Ok(text) => RepositoryFilePage {
|
||||||
|
name,
|
||||||
|
kind: if markdown {
|
||||||
|
RepositoryFileKind::Markdown
|
||||||
|
} else {
|
||||||
|
RepositoryFileKind::Source
|
||||||
|
},
|
||||||
|
language,
|
||||||
|
text,
|
||||||
|
data: Vec::new(),
|
||||||
|
},
|
||||||
|
Err(error) => RepositoryFilePage {
|
||||||
|
name,
|
||||||
|
kind: RepositoryFileKind::Preview,
|
||||||
|
language,
|
||||||
|
text: String::new(),
|
||||||
|
data: error.into_bytes(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn diff_page(path: &str, diff: diff::Parsed) -> DiffPage {
|
||||||
|
DiffPage {
|
||||||
|
title: path.rsplit('/').next().unwrap_or(path).into(),
|
||||||
|
columns: diff.columns.min(u32::MAX as usize) as u32,
|
||||||
|
lines: diff
|
||||||
|
.lines
|
||||||
|
.into_iter()
|
||||||
|
.map(|line| DiffLine {
|
||||||
|
old_number: line.old_number,
|
||||||
|
new_number: line.new_number,
|
||||||
|
text: line.text,
|
||||||
|
kind: match line.kind {
|
||||||
|
"addition" => DiffLineKind::Addition,
|
||||||
|
"removal" => DiffLineKind::Removal,
|
||||||
|
"hunk" => DiffLineKind::Hunk,
|
||||||
|
"header" => DiffLineKind::Header,
|
||||||
|
_ => DiffLineKind::Context,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
198
crates/app/src/presentation/helpers.rs
Normal file
198
crates/app/src/presentation/helpers.rs
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(super) fn label_row(label: &models::Label) -> Option<LabelRow> {
|
||||||
|
let name = label.name.as_deref()?.trim();
|
||||||
|
let color = label.color.as_deref()?.trim().trim_start_matches('#');
|
||||||
|
if name.is_empty() || color.len() != 6 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let value = u32::from_str_radix(color, 16).ok()?;
|
||||||
|
let red = (value >> 16) & 0xff;
|
||||||
|
let green = (value >> 8) & 0xff;
|
||||||
|
let blue = value & 0xff;
|
||||||
|
Some(LabelRow {
|
||||||
|
name: name.into(),
|
||||||
|
color: format!("#{color}"),
|
||||||
|
light: red * 299 + green * 587 + blue * 114 > 150_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn comment_row(comment: &models::Comment, viewer_id: Option<i64>) -> CommentRow {
|
||||||
|
CommentRow {
|
||||||
|
id: comment.id.unwrap_or_default(),
|
||||||
|
author: comment
|
||||||
|
.user
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|user| user.login.as_deref())
|
||||||
|
.or(comment.original_author.as_deref())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.into(),
|
||||||
|
body: comment
|
||||||
|
.body
|
||||||
|
.clone()
|
||||||
|
.filter(|body| !body.is_empty())
|
||||||
|
.unwrap_or_else(|| "No comment text.".into()),
|
||||||
|
meta: compact_date(
|
||||||
|
comment
|
||||||
|
.updated_at
|
||||||
|
.as_deref()
|
||||||
|
.or(comment.created_at.as_deref()),
|
||||||
|
),
|
||||||
|
can_edit: comment_can_edit(comment, viewer_id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn file_row(file: &models::ChangedFile) -> Option<FileRow> {
|
||||||
|
Some(FileRow {
|
||||||
|
path: file.filename.clone()?,
|
||||||
|
status: file.status.clone().unwrap_or_else(|| "modified".into()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn pull_files_ref(pull: &models::PullRequest) -> String {
|
||||||
|
match pull_file_source(pull) {
|
||||||
|
PullFileSource::Branch(branch) => format!("Files on {branch}"),
|
||||||
|
PullFileSource::Commit(sha) => format!("Files at merge commit {sha}"),
|
||||||
|
PullFileSource::Request => "Files from pull request".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn issue_meta(issue: &models::Issue) -> String {
|
||||||
|
let author = issue
|
||||||
|
.user
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|user| user.login.as_deref())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
format!(
|
||||||
|
"#{} · {} · updated {} · {} comments",
|
||||||
|
issue.number.unwrap_or_default(),
|
||||||
|
author,
|
||||||
|
compact_date(issue.updated_at.as_deref()),
|
||||||
|
issue.comments.unwrap_or_default()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn issue_milestone(issue: &models::Issue) -> String {
|
||||||
|
issue
|
||||||
|
.milestone
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|milestone| milestone.title.as_deref())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn milestone_row(milestone: &models::Milestone) -> MilestoneRow {
|
||||||
|
let open = milestone.open_issues.unwrap_or_default();
|
||||||
|
let closed = milestone.closed_issues.unwrap_or_default();
|
||||||
|
let total = open + closed;
|
||||||
|
let due = milestone
|
||||||
|
.due_on
|
||||||
|
.as_deref()
|
||||||
|
.map(|date| format!("due {}", compact_date(Some(date))))
|
||||||
|
.unwrap_or_else(|| "no due date".into());
|
||||||
|
MilestoneRow {
|
||||||
|
id: milestone.id.unwrap_or_default(),
|
||||||
|
state: work_item_state(milestone.state.as_deref()),
|
||||||
|
title: milestone
|
||||||
|
.title
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "Untitled milestone".into()),
|
||||||
|
description: milestone
|
||||||
|
.description
|
||||||
|
.clone()
|
||||||
|
.filter(|description| !description.is_empty())
|
||||||
|
.unwrap_or_else(|| "No description".into()),
|
||||||
|
meta: format!(
|
||||||
|
"{} · {closed} of {} closed · {due}",
|
||||||
|
milestone.state.as_deref().unwrap_or("unknown"),
|
||||||
|
open + closed
|
||||||
|
),
|
||||||
|
has_issues: total > 0,
|
||||||
|
progress: if total == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
closed as f64 / total as f64
|
||||||
|
},
|
||||||
|
progress_accessibility: format!("{closed} closed, {open} open"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn work_item_state(state: Option<&str>) -> WorkItemState {
|
||||||
|
match state {
|
||||||
|
Some("open") => WorkItemState::Open,
|
||||||
|
Some("closed") => WorkItemState::Closed,
|
||||||
|
Some(_) | None => WorkItemState::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn short_sha(sha: &str) -> &str {
|
||||||
|
&sha[..sha.len().min(8)]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn source_language(path: &str) -> Option<&'static str> {
|
||||||
|
let filename = path.rsplit('/').next()?.to_lowercase();
|
||||||
|
match filename.as_str() {
|
||||||
|
"cmakelists.txt" => return Some("cmake"),
|
||||||
|
"dockerfile" => return Some("dockerfile"),
|
||||||
|
"gemfile" | "podfile" => return Some("ruby"),
|
||||||
|
"makefile" => return Some("makefile"),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
match filename.rsplit('.').next()? {
|
||||||
|
"asm" => Some("x86asm"),
|
||||||
|
"c" => Some("c"),
|
||||||
|
"cc" | "cpp" | "h" | "hpp" => Some("cpp"),
|
||||||
|
"clj" => Some("clojure"),
|
||||||
|
"cs" => Some("csharp"),
|
||||||
|
"css" => Some("css"),
|
||||||
|
"dart" => Some("dart"),
|
||||||
|
"ex" | "exs" => Some("elixir"),
|
||||||
|
"fs" => Some("fsharp"),
|
||||||
|
"go" => Some("go"),
|
||||||
|
"groovy" => Some("groovy"),
|
||||||
|
"hs" => Some("haskell"),
|
||||||
|
"html" | "plist" | "xml" => Some("xml"),
|
||||||
|
"java" => Some("java"),
|
||||||
|
"jl" => Some("julia"),
|
||||||
|
"js" | "jsx" => Some("javascript"),
|
||||||
|
"json" => Some("json"),
|
||||||
|
"kt" | "kts" => Some("kotlin"),
|
||||||
|
"lua" => Some("lua"),
|
||||||
|
"m" | "mm" => Some("objectivec"),
|
||||||
|
"md" | "markdown" | "mdown" | "mkd" => Some("markdown"),
|
||||||
|
"php" => Some("php"),
|
||||||
|
"pl" => Some("perl"),
|
||||||
|
"ps1" => Some("powershell"),
|
||||||
|
"py" => Some("python"),
|
||||||
|
"r" => Some("r"),
|
||||||
|
"rb" => Some("ruby"),
|
||||||
|
"rs" => Some("rust"),
|
||||||
|
"scala" => Some("scala"),
|
||||||
|
"scss" => Some("scss"),
|
||||||
|
"sh" => Some("bash"),
|
||||||
|
"sql" => Some("sql"),
|
||||||
|
"swift" => Some("swift"),
|
||||||
|
"toml" => Some("ini"),
|
||||||
|
"ts" | "tsx" => Some("typescript"),
|
||||||
|
"txt" => Some("plaintext"),
|
||||||
|
"yaml" | "yml" => Some("yaml"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn summary(body: &str) -> String {
|
||||||
|
body.split_whitespace()
|
||||||
|
.take(24)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn indices(values: &[usize]) -> Vec<u32> {
|
||||||
|
values.iter().map(|value| *value as u32).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compact_date(date: Option<&str>) -> String {
|
||||||
|
date.and_then(|date| date.get(..10))
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
251
crates/app/src/presentation/home.rs
Normal file
251
crates/app/src/presentation/home.rs
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
use super::{helpers::*, *};
|
||||||
|
|
||||||
|
pub fn home_page(server_name: String, home: HomeData) -> HomePage {
|
||||||
|
let (heat_cells, contribution_count) = heat_cells(&home.heatmap);
|
||||||
|
HomePage {
|
||||||
|
server_name,
|
||||||
|
activities: activity_rows(&home.activities),
|
||||||
|
heat_cells,
|
||||||
|
contribution_count,
|
||||||
|
next_page: home.next_page.map(|page| page as u32),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn activity_rows(activities: &[models::Activity]) -> Vec<ActivityRow> {
|
||||||
|
activities.iter().map(activity_row).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn server_activity_page(page: gotcha_gitea::Page<models::Activity>) -> ServerActivityPage {
|
||||||
|
ServerActivityPage {
|
||||||
|
rows: page
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.map(|activity| {
|
||||||
|
let mut row = activity_row(activity);
|
||||||
|
let actor = activity
|
||||||
|
.act_user
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|user| user.login.as_deref())
|
||||||
|
.unwrap_or("unknown user");
|
||||||
|
row.title = format!("@{actor} · {}", row.title);
|
||||||
|
row
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
has_more: page.has_more,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn activity_row(activity: &models::Activity) -> ActivityRow {
|
||||||
|
use models::activity::OpType;
|
||||||
|
|
||||||
|
let repository = activity
|
||||||
|
.repo
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|repo| repo.full_name.as_deref())
|
||||||
|
.unwrap_or("repository");
|
||||||
|
let branch = activity
|
||||||
|
.ref_name
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|name| name.strip_prefix("refs/heads/").or(Some(name)))
|
||||||
|
.unwrap_or("default branch");
|
||||||
|
let (icon, title) = match activity.op_type {
|
||||||
|
Some(OpType::CommitRepo) if activity.content.as_deref().unwrap_or("").is_empty() => (
|
||||||
|
ActivityIcon::Branch,
|
||||||
|
format!("Created branch {branch} in {repository}"),
|
||||||
|
),
|
||||||
|
Some(OpType::CommitRepo | OpType::MirrorSyncPush) => (
|
||||||
|
ActivityIcon::Push,
|
||||||
|
format!("Pushed to {branch} in {repository}"),
|
||||||
|
),
|
||||||
|
Some(OpType::CreateRepo) => (ActivityIcon::Repository, format!("Created {repository}")),
|
||||||
|
Some(OpType::RenameRepo) => (ActivityIcon::Repository, format!("Renamed {repository}")),
|
||||||
|
Some(OpType::StarRepo) => (ActivityIcon::Repository, format!("Starred {repository}")),
|
||||||
|
Some(OpType::WatchRepo) => (
|
||||||
|
ActivityIcon::Repository,
|
||||||
|
format!("Started watching {repository}"),
|
||||||
|
),
|
||||||
|
Some(OpType::CreateIssue) => (
|
||||||
|
ActivityIcon::Issue,
|
||||||
|
format!("Opened an issue in {repository}"),
|
||||||
|
),
|
||||||
|
Some(OpType::CloseIssue) => (
|
||||||
|
ActivityIcon::Issue,
|
||||||
|
format!("Closed an issue in {repository}"),
|
||||||
|
),
|
||||||
|
Some(OpType::ReopenIssue) => (
|
||||||
|
ActivityIcon::Issue,
|
||||||
|
format!("Reopened an issue in {repository}"),
|
||||||
|
),
|
||||||
|
Some(OpType::CommentIssue) => (
|
||||||
|
ActivityIcon::Issue,
|
||||||
|
format!("Commented on an issue in {repository}"),
|
||||||
|
),
|
||||||
|
Some(OpType::CreatePullRequest) => (
|
||||||
|
ActivityIcon::PullRequest,
|
||||||
|
format!("Opened a pull request in {repository}"),
|
||||||
|
),
|
||||||
|
Some(OpType::MergePullRequest | OpType::AutoMergePullRequest) => (
|
||||||
|
ActivityIcon::PullRequest,
|
||||||
|
format!("Merged a pull request in {repository}"),
|
||||||
|
),
|
||||||
|
Some(OpType::ClosePullRequest) => (
|
||||||
|
ActivityIcon::PullRequest,
|
||||||
|
format!("Closed a pull request in {repository}"),
|
||||||
|
),
|
||||||
|
Some(OpType::ReopenPullRequest) => (
|
||||||
|
ActivityIcon::PullRequest,
|
||||||
|
format!("Reopened a pull request in {repository}"),
|
||||||
|
),
|
||||||
|
Some(OpType::CommentPull) => (
|
||||||
|
ActivityIcon::PullRequest,
|
||||||
|
format!("Commented on a pull request in {repository}"),
|
||||||
|
),
|
||||||
|
Some(OpType::ApprovePullRequest) => (
|
||||||
|
ActivityIcon::PullRequest,
|
||||||
|
format!("Approved a pull request in {repository}"),
|
||||||
|
),
|
||||||
|
Some(OpType::RejectPullRequest) => (
|
||||||
|
ActivityIcon::PullRequest,
|
||||||
|
format!("Requested changes in {repository}"),
|
||||||
|
),
|
||||||
|
Some(OpType::PushTag) => (
|
||||||
|
ActivityIcon::Tag,
|
||||||
|
format!("Pushed tag {branch} in {repository}"),
|
||||||
|
),
|
||||||
|
Some(OpType::DeleteTag) => (
|
||||||
|
ActivityIcon::Tag,
|
||||||
|
format!("Deleted tag {branch} in {repository}"),
|
||||||
|
),
|
||||||
|
Some(OpType::DeleteBranch) => (
|
||||||
|
ActivityIcon::Branch,
|
||||||
|
format!("Deleted branch {branch} in {repository}"),
|
||||||
|
),
|
||||||
|
Some(OpType::PublishRelease) => (
|
||||||
|
ActivityIcon::Release,
|
||||||
|
format!("Published a release in {repository}"),
|
||||||
|
),
|
||||||
|
Some(_) | None => (ActivityIcon::Repository, format!("Updated {repository}")),
|
||||||
|
};
|
||||||
|
let target = activity::target(activity);
|
||||||
|
let (target, owner, repository, number, sha) = match target {
|
||||||
|
Some(activity::Target::Repository { owner, repository }) => (
|
||||||
|
ActivityTargetKind::Repository,
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
0,
|
||||||
|
String::new(),
|
||||||
|
),
|
||||||
|
Some(activity::Target::Issue {
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
number,
|
||||||
|
}) => (
|
||||||
|
ActivityTargetKind::Issue,
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
number,
|
||||||
|
String::new(),
|
||||||
|
),
|
||||||
|
Some(activity::Target::Pull {
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
number,
|
||||||
|
}) => (
|
||||||
|
ActivityTargetKind::PullRequest,
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
number,
|
||||||
|
String::new(),
|
||||||
|
),
|
||||||
|
Some(activity::Target::Commit {
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
sha,
|
||||||
|
}) => (ActivityTargetKind::Commit, owner, repository, 0, sha),
|
||||||
|
None => (
|
||||||
|
ActivityTargetKind::None,
|
||||||
|
String::new(),
|
||||||
|
String::new(),
|
||||||
|
0,
|
||||||
|
String::new(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
ActivityRow {
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
detail: activity_detail(activity),
|
||||||
|
meta: compact_date(activity.created.as_deref()),
|
||||||
|
target,
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
number,
|
||||||
|
sha,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn activity_detail(activity: &models::Activity) -> String {
|
||||||
|
let comment = activity
|
||||||
|
.comment
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|comment| comment.body.as_deref());
|
||||||
|
let text = comment.or(activity.content.as_deref()).unwrap_or("");
|
||||||
|
if comment.is_none()
|
||||||
|
&& let Some(payload) = activity::commit_activity(activity)
|
||||||
|
{
|
||||||
|
let message = payload
|
||||||
|
.commits
|
||||||
|
.last()
|
||||||
|
.map(|commit| summary(&commit.message))
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !message.is_empty() {
|
||||||
|
return match payload.count {
|
||||||
|
1 => format!("1 commit · {message}"),
|
||||||
|
count => format!("{count} commits · {message}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let text = summary(text);
|
||||||
|
if text.is_empty() {
|
||||||
|
"Server activity".into()
|
||||||
|
} else {
|
||||||
|
text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn heat_cells(data: &[models::UserHeatmapData]) -> (Vec<HeatCell>, i64) {
|
||||||
|
let latest = data
|
||||||
|
.iter()
|
||||||
|
.filter_map(|entry| entry.timestamp)
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0)
|
||||||
|
/ 86_400;
|
||||||
|
let (year, month, _) = civil_from_days(latest);
|
||||||
|
let first_month = year * 12 + month - 1 - 8;
|
||||||
|
let start = days_from_civil(
|
||||||
|
first_month.div_euclid(12),
|
||||||
|
first_month.rem_euclid(12) + 1,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
let mut counts = vec![0_i64; (latest - start + 1) as usize];
|
||||||
|
for entry in data {
|
||||||
|
let day = entry.timestamp.unwrap_or_default() / 86_400;
|
||||||
|
if (start..=latest).contains(&day) {
|
||||||
|
counts[(day - start) as usize] += entry.contributions.unwrap_or_default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let maximum = counts.iter().copied().max().unwrap_or_default();
|
||||||
|
let contribution_count = counts.iter().sum();
|
||||||
|
let cells = counts
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, count)| HeatCell {
|
||||||
|
level: if count == 0 || maximum == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
((count * 4 + maximum - 1) / maximum).clamp(1, 4) as u32
|
||||||
|
},
|
||||||
|
timestamp: (start + index as i64) * 86_400,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
(cells, contribution_count)
|
||||||
|
}
|
||||||
273
crates/app/src/presentation/lists.rs
Normal file
273
crates/app/src/presentation/lists.rs
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
use super::{helpers::*, *};
|
||||||
|
|
||||||
|
pub fn repository_rows(
|
||||||
|
repositories: &[RepositoryData],
|
||||||
|
is_favorite: impl Fn(&RepositoryData) -> bool,
|
||||||
|
) -> Vec<RepositoryRow> {
|
||||||
|
let mut repositories = repositories.to_vec();
|
||||||
|
repositories
|
||||||
|
.sort_by_key(|repository| (!is_favorite(repository), repository.name.to_lowercase()));
|
||||||
|
repositories
|
||||||
|
.into_iter()
|
||||||
|
.map(|repository| RepositoryRow {
|
||||||
|
favorite: is_favorite(&repository),
|
||||||
|
meta: format!(
|
||||||
|
"{} · {} open · {}",
|
||||||
|
repository.language, repository.open_issues, repository.updated
|
||||||
|
),
|
||||||
|
name: repository.name,
|
||||||
|
owner: repository.owner,
|
||||||
|
description: repository.description,
|
||||||
|
default_branch: repository.default_branch,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn issue_rows(issues: &[models::Issue]) -> Vec<IssueRow> {
|
||||||
|
issues
|
||||||
|
.iter()
|
||||||
|
.map(|issue| IssueRow {
|
||||||
|
number: issue.number.unwrap_or_default(),
|
||||||
|
state: work_item_state(issue.state.as_deref()),
|
||||||
|
title: issue
|
||||||
|
.title
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "Untitled issue".into()),
|
||||||
|
summary: issue
|
||||||
|
.body
|
||||||
|
.as_deref()
|
||||||
|
.map(summary)
|
||||||
|
.unwrap_or_else(|| "No description".into()),
|
||||||
|
meta: issue_meta(issue),
|
||||||
|
milestone: issue_milestone(issue),
|
||||||
|
labels: issue
|
||||||
|
.labels
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter()
|
||||||
|
.filter_map(label_row)
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn issue_filter_options(
|
||||||
|
labels: &[models::Label],
|
||||||
|
milestones: &[models::Milestone],
|
||||||
|
filter: &IssueFilter,
|
||||||
|
) -> IssueFilterOptions {
|
||||||
|
let mut labels: Vec<_> = labels
|
||||||
|
.iter()
|
||||||
|
.filter_map(|label| label.name.clone())
|
||||||
|
.collect();
|
||||||
|
let unavailable_labels = filter
|
||||||
|
.labels
|
||||||
|
.iter()
|
||||||
|
.filter(|label| !labels.contains(label))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
labels.extend(filter.labels.iter().cloned());
|
||||||
|
labels.sort_by_key(|label| label.to_lowercase());
|
||||||
|
labels.dedup();
|
||||||
|
let mut milestones: Vec<_> = milestones
|
||||||
|
.iter()
|
||||||
|
.filter_map(|milestone| milestone.title.clone())
|
||||||
|
.collect();
|
||||||
|
if !filter.milestone.is_empty() {
|
||||||
|
milestones.push(filter.milestone.clone());
|
||||||
|
}
|
||||||
|
milestones.sort_by_key(|milestone| milestone.to_lowercase());
|
||||||
|
milestones.dedup();
|
||||||
|
IssueFilterOptions {
|
||||||
|
milestones,
|
||||||
|
labels,
|
||||||
|
unavailable_labels,
|
||||||
|
selected_milestone: filter.milestone.clone(),
|
||||||
|
selected_labels: filter.labels.iter().cloned().collect(),
|
||||||
|
search_text: filter.search_text.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn milestone_rows(milestones: &[models::Milestone]) -> Vec<MilestoneRow> {
|
||||||
|
let mut milestones = milestones.to_vec();
|
||||||
|
milestones.sort_by_key(|milestone| {
|
||||||
|
(
|
||||||
|
milestone.state.as_deref() == Some("closed"),
|
||||||
|
milestone
|
||||||
|
.title
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_lowercase(),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
milestones.iter().map(milestone_row).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn milestone_page(details: MilestoneDetails) -> MilestonePage {
|
||||||
|
MilestonePage {
|
||||||
|
milestone: milestone_row(&details.milestone),
|
||||||
|
issues: issue_rows(&details.issues),
|
||||||
|
pulls: pull_rows(&details.pulls),
|
||||||
|
has_more: details.has_more,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn milestone_editor_page(milestone: Option<models::Milestone>) -> MilestoneEditorPage {
|
||||||
|
MilestoneEditorPage {
|
||||||
|
title: milestone
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|milestone| milestone.title.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
description: milestone
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|milestone| milestone.description.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
due_date: milestone
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|milestone| milestone.due_on.as_deref())
|
||||||
|
.and_then(parse_api_date),
|
||||||
|
closed: milestone
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|milestone| milestone.state.as_deref())
|
||||||
|
== Some("closed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pull_filter_options(milestones: &[String], filter: &PullFilter) -> PullFilterOptions {
|
||||||
|
let mut milestones = milestones.to_vec();
|
||||||
|
if !filter.milestone.is_empty() {
|
||||||
|
milestones.push(filter.milestone.clone());
|
||||||
|
}
|
||||||
|
milestones.sort_by_key(|milestone| milestone.to_lowercase());
|
||||||
|
milestones.dedup();
|
||||||
|
PullFilterOptions {
|
||||||
|
milestones,
|
||||||
|
selected_milestone: filter.milestone.clone(),
|
||||||
|
search_text: filter.search_text.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pull_rows(pulls: &[models::Issue]) -> Vec<PullRow> {
|
||||||
|
pulls
|
||||||
|
.iter()
|
||||||
|
.filter_map(|pull| {
|
||||||
|
let repository = pull.repository.as_ref()?;
|
||||||
|
let author = pull
|
||||||
|
.user
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|user| user.login.as_deref())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
let kind = if pull
|
||||||
|
.pull_request
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|pull| pull.draft)
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
"Draft"
|
||||||
|
} else {
|
||||||
|
pull.state.as_deref().unwrap_or("unknown")
|
||||||
|
};
|
||||||
|
Some(PullRow {
|
||||||
|
number: pull.number.unwrap_or_default(),
|
||||||
|
owner: repository.owner.clone()?,
|
||||||
|
repository: repository.name.clone()?,
|
||||||
|
state: work_item_state(pull.state.as_deref()),
|
||||||
|
title: format!(
|
||||||
|
"{} #{}\n{}",
|
||||||
|
repository.name.as_deref()?,
|
||||||
|
pull.number.unwrap_or_default(),
|
||||||
|
pull.title.as_deref().unwrap_or("Untitled pull request")
|
||||||
|
),
|
||||||
|
summary: pull
|
||||||
|
.body
|
||||||
|
.as_deref()
|
||||||
|
.map(summary)
|
||||||
|
.filter(|body| !body.is_empty())
|
||||||
|
.unwrap_or_else(|| "No description".into()),
|
||||||
|
meta: format!(
|
||||||
|
"{kind} · {author} · {} · {} comments",
|
||||||
|
compact_date(pull.updated_at.as_deref()),
|
||||||
|
pull.comments.unwrap_or_default()
|
||||||
|
),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn commit_page(
|
||||||
|
branches: Vec<String>,
|
||||||
|
commits: &[HistoryCommit],
|
||||||
|
show_graph: bool,
|
||||||
|
has_more: bool,
|
||||||
|
) -> CommitPage {
|
||||||
|
let lane_count = if show_graph {
|
||||||
|
commits
|
||||||
|
.iter()
|
||||||
|
.flat_map(|commit| {
|
||||||
|
commit
|
||||||
|
.top_lanes
|
||||||
|
.iter()
|
||||||
|
.chain(commit.bottom_lanes.iter())
|
||||||
|
.chain(commit.top_connections.iter())
|
||||||
|
.chain(commit.bottom_connections.iter())
|
||||||
|
.chain(commit.node_lane.iter())
|
||||||
|
})
|
||||||
|
.max()
|
||||||
|
.map_or(0, |lane| lane + 1)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let commits = commits
|
||||||
|
.iter()
|
||||||
|
.filter_map(|row| {
|
||||||
|
let commit = &row.commit;
|
||||||
|
let sha = commit.sha.as_deref()?;
|
||||||
|
let details = commit.commit.as_ref();
|
||||||
|
let author = details
|
||||||
|
.and_then(|commit| commit.author.as_ref())
|
||||||
|
.and_then(|author| author.name.as_deref())
|
||||||
|
.or_else(|| {
|
||||||
|
commit
|
||||||
|
.author
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|author| author.login.as_deref())
|
||||||
|
})
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
let date = details
|
||||||
|
.and_then(|commit| commit.author.as_ref())
|
||||||
|
.and_then(|author| author.date.as_deref())
|
||||||
|
.or(commit.created.as_deref());
|
||||||
|
let mut branch_labels = row.refs.clone();
|
||||||
|
for label in &row.branch_starts {
|
||||||
|
if !branch_labels.contains(label) {
|
||||||
|
branch_labels.push(label.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(CommitRow {
|
||||||
|
sha: sha.into(),
|
||||||
|
title: details
|
||||||
|
.and_then(|commit| commit.message.as_deref())
|
||||||
|
.map(|message| message.lines().next().unwrap_or(message))
|
||||||
|
.unwrap_or("Commit")
|
||||||
|
.into(),
|
||||||
|
detail: if !branch_labels.is_empty() {
|
||||||
|
format!("{author} · {} · {}", compact_date(date), short_sha(sha))
|
||||||
|
} else {
|
||||||
|
format!("{author} · {}\n{}", compact_date(date), short_sha(sha))
|
||||||
|
},
|
||||||
|
branch_label: (!branch_labels.is_empty()).then(|| branch_labels.join(" · ")),
|
||||||
|
top_lanes: indices(&row.top_lanes),
|
||||||
|
bottom_lanes: indices(&row.bottom_lanes),
|
||||||
|
node_lane: row.node_lane.map(|lane| lane as u32),
|
||||||
|
top_connections: indices(&row.top_connections),
|
||||||
|
bottom_connections: indices(&row.bottom_connections),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
CommitPage {
|
||||||
|
branches,
|
||||||
|
commits,
|
||||||
|
lane_count: lane_count as u32,
|
||||||
|
has_more,
|
||||||
|
}
|
||||||
|
}
|
||||||
142
crates/app/src/presentation/notifications.rs
Normal file
142
crates/app/src/presentation/notifications.rs
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
use gotcha_gitea::{models, notifications};
|
||||||
|
|
||||||
|
use super::{ActivityTargetKind, NotificationRow, compact_date};
|
||||||
|
|
||||||
|
pub fn notification_rows(
|
||||||
|
server_id: &str,
|
||||||
|
notifications: Vec<models::NotificationThread>,
|
||||||
|
) -> Vec<NotificationRow> {
|
||||||
|
notifications
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|notification| notification_row(server_id, notification))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn notification_row(
|
||||||
|
server_id: &str,
|
||||||
|
notification: models::NotificationThread,
|
||||||
|
) -> Option<NotificationRow> {
|
||||||
|
let id = notification.id?;
|
||||||
|
let title = notification
|
||||||
|
.subject
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|subject| subject.title.clone())
|
||||||
|
.filter(|title| !title.is_empty())
|
||||||
|
.unwrap_or_else(|| "Server notification".into());
|
||||||
|
let kind = notification
|
||||||
|
.subject
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|subject| subject.r#type.as_deref())
|
||||||
|
.unwrap_or("Update");
|
||||||
|
let full_name = notification
|
||||||
|
.repository
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|repository| repository.full_name.clone())
|
||||||
|
.or_else(|| {
|
||||||
|
let repository = notification.repository.as_deref()?;
|
||||||
|
Some(format!(
|
||||||
|
"{}/{}",
|
||||||
|
repository.owner.as_deref()?.login.as_deref()?,
|
||||||
|
repository.name.as_deref()?
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "Server".into());
|
||||||
|
let (target, owner, repository, number, sha) = match notifications::target(¬ification) {
|
||||||
|
notifications::NotificationTarget::None => (
|
||||||
|
ActivityTargetKind::None,
|
||||||
|
String::new(),
|
||||||
|
String::new(),
|
||||||
|
0,
|
||||||
|
String::new(),
|
||||||
|
),
|
||||||
|
notifications::NotificationTarget::Repository { owner, repository } => (
|
||||||
|
ActivityTargetKind::Repository,
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
0,
|
||||||
|
String::new(),
|
||||||
|
),
|
||||||
|
notifications::NotificationTarget::Issue {
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
number,
|
||||||
|
} => (
|
||||||
|
ActivityTargetKind::Issue,
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
number,
|
||||||
|
String::new(),
|
||||||
|
),
|
||||||
|
notifications::NotificationTarget::Pull {
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
number,
|
||||||
|
} => (
|
||||||
|
ActivityTargetKind::PullRequest,
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
number,
|
||||||
|
String::new(),
|
||||||
|
),
|
||||||
|
notifications::NotificationTarget::Commit {
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
sha,
|
||||||
|
} => (ActivityTargetKind::Commit, owner, repository, 0, sha),
|
||||||
|
};
|
||||||
|
Some(NotificationRow {
|
||||||
|
id,
|
||||||
|
server_id: server_id.into(),
|
||||||
|
title,
|
||||||
|
detail: format!("{kind} · {full_name}"),
|
||||||
|
meta: compact_date(notification.updated_at.as_deref()),
|
||||||
|
unread: notification.unread.unwrap_or_default(),
|
||||||
|
target,
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
number,
|
||||||
|
sha,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn presents_targetable_notification_without_exposing_api_models() {
|
||||||
|
let rows = notification_rows(
|
||||||
|
"work",
|
||||||
|
vec![models::NotificationThread {
|
||||||
|
id: Some(9),
|
||||||
|
unread: Some(true),
|
||||||
|
updated_at: Some("2026-08-15T10:00:00Z".into()),
|
||||||
|
repository: Some(Box::new(models::Repository {
|
||||||
|
full_name: Some("octo/demo".into()),
|
||||||
|
name: Some("demo".into()),
|
||||||
|
owner: Some(Box::new(models::User {
|
||||||
|
login: Some("octo".into()),
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
subject: Some(Box::new(models::NotificationSubject {
|
||||||
|
title: Some("Fix the bug".into()),
|
||||||
|
r#type: Some("Issue".into()),
|
||||||
|
url: Some("https://example/api/v1/repos/octo/demo/issues/42".into()),
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(rows.len(), 1);
|
||||||
|
assert_eq!(rows[0].id, 9);
|
||||||
|
assert_eq!(rows[0].server_id, "work");
|
||||||
|
assert_eq!(rows[0].title, "Fix the bug");
|
||||||
|
assert_eq!(rows[0].detail, "Issue · octo/demo");
|
||||||
|
assert!(rows[0].unread);
|
||||||
|
assert_eq!(rows[0].target, ActivityTargetKind::Issue);
|
||||||
|
assert_eq!(rows[0].number, 42);
|
||||||
|
}
|
||||||
|
}
|
||||||
419
crates/app/src/presentation/tests.rs
Normal file
419
crates/app/src/presentation/tests.rs
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
use super::{helpers::*, home::heat_cells, *};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repository_directories_sort_before_files() {
|
||||||
|
let contents = [
|
||||||
|
("z.swift", "file"),
|
||||||
|
("Sources", "dir"),
|
||||||
|
("README.md", "file"),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, kind)| models::ContentsResponse {
|
||||||
|
name: Some(name.into()),
|
||||||
|
path: Some(name.into()),
|
||||||
|
r#type: Some(kind.into()),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
repository_content_rows(contents)
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| row.name)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
["Sources", "README.md", "z.swift"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn presents_complete_commit_details() {
|
||||||
|
let page = commit_details_page(
|
||||||
|
models::Commit {
|
||||||
|
sha: Some("0123456789abcdef".into()),
|
||||||
|
commit: Some(Box::new(models::RepoCommit {
|
||||||
|
message: Some("Add details\n\nExplain the change.".into()),
|
||||||
|
author: Some(Box::new(models::CommitUser {
|
||||||
|
name: Some("Ada".into()),
|
||||||
|
email: Some("ada@example.com".into()),
|
||||||
|
date: Some("2026-08-01T10:00:00Z".into()),
|
||||||
|
})),
|
||||||
|
committer: Some(Box::new(models::CommitUser {
|
||||||
|
name: Some("Grace".into()),
|
||||||
|
date: Some("2026-08-02T11:00:00Z".into()),
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
verification: Some(Box::new(models::PayloadCommitVerification {
|
||||||
|
verified: Some(true),
|
||||||
|
signature: Some("signed".into()),
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
files: Some(vec![models::CommitAffectedFiles {
|
||||||
|
filename: Some("README.md".into()),
|
||||||
|
status: Some("modified".into()),
|
||||||
|
}]),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Some("main".into()),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(page.title, "Add details");
|
||||||
|
assert_eq!(page.description, "Explain the change.");
|
||||||
|
assert_eq!(page.files[0].path, "README.md");
|
||||||
|
assert_eq!(
|
||||||
|
page.metadata
|
||||||
|
.iter()
|
||||||
|
.map(|row| (row.label.as_str(), row.value.as_str()))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
[
|
||||||
|
("Author", "Ada <ada@example.com>"),
|
||||||
|
("Committer", "Grace"),
|
||||||
|
("Committed", "2026-08-02"),
|
||||||
|
("Branch", "main"),
|
||||||
|
("Commit", "0123456789abcdef"),
|
||||||
|
("Signature", "Verified"),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
commit_details_page(models::Commit::default(), None)
|
||||||
|
.metadata
|
||||||
|
.last()
|
||||||
|
.unwrap()
|
||||||
|
.value,
|
||||||
|
"Unsigned"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maps_heat_levels_and_labels() {
|
||||||
|
let heatmap = vec![
|
||||||
|
models::UserHeatmapData {
|
||||||
|
timestamp: Some(20_393 * 86_400),
|
||||||
|
contributions: Some(1),
|
||||||
|
},
|
||||||
|
models::UserHeatmapData {
|
||||||
|
timestamp: Some(20_665 * 86_400),
|
||||||
|
contributions: Some(4),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let (cells, total) = heat_cells(&heatmap);
|
||||||
|
assert_eq!((cells.len(), total), (273, 5));
|
||||||
|
assert_eq!(
|
||||||
|
(cells[0].level, cells[0].timestamp, cells[272].level,),
|
||||||
|
(1, 20_393 * 86_400, 4)
|
||||||
|
);
|
||||||
|
|
||||||
|
let label = models::Label {
|
||||||
|
name: Some("bug".into()),
|
||||||
|
color: Some("d73a4a".into()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(label_row(&label).is_some());
|
||||||
|
assert!(label_row(&models::Label::default()).is_none());
|
||||||
|
|
||||||
|
let milestone = models::Milestone {
|
||||||
|
id: Some(1),
|
||||||
|
title: Some("Version 1".into()),
|
||||||
|
open_issues: Some(3),
|
||||||
|
closed_issues: Some(2),
|
||||||
|
state: Some("open".into()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let milestone_row = milestone_rows(std::slice::from_ref(&milestone)).remove(0);
|
||||||
|
assert_eq!(milestone_row.progress, 0.4);
|
||||||
|
assert_eq!(milestone_row.state, WorkItemState::Open);
|
||||||
|
assert_eq!(milestone_row.progress_accessibility, "2 closed, 3 open");
|
||||||
|
let issue = models::Issue {
|
||||||
|
state: Some("closed".into()),
|
||||||
|
milestone: Some(Box::new(milestone)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let row = &issue_rows(&[issue])[0];
|
||||||
|
assert_eq!(row.state, WorkItemState::Closed);
|
||||||
|
assert_eq!(row.milestone, "Version 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn presents_filtered_home_activity_and_continuation() {
|
||||||
|
use models::activity::OpType;
|
||||||
|
|
||||||
|
let page = home_page(
|
||||||
|
"Gitea".into(),
|
||||||
|
HomeData {
|
||||||
|
activities: [OpType::CreatePullRequest, OpType::ClosePullRequest]
|
||||||
|
.into_iter()
|
||||||
|
.map(|op_type| models::Activity {
|
||||||
|
op_type: Some(op_type),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
heatmap: Vec::new(),
|
||||||
|
next_page: Some(4),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(page.activities.len(), 2);
|
||||||
|
assert_eq!(page.next_page, Some(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn presents_server_activity_with_actor_and_navigation_target() {
|
||||||
|
use models::activity::OpType;
|
||||||
|
|
||||||
|
let page = server_activity_page(gotcha_gitea::Page {
|
||||||
|
items: vec![models::Activity {
|
||||||
|
act_user: Some(Box::new(models::User {
|
||||||
|
login: Some("apple".into()),
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
op_type: Some(OpType::CreateIssue),
|
||||||
|
content: Some("1|Test issue".into()),
|
||||||
|
repo: Some(Box::new(models::Repository {
|
||||||
|
full_name: Some("apple/SimpleDemoRepo".into()),
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
has_more: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
page.rows[0].title,
|
||||||
|
"@apple · Opened an issue in apple/SimpleDemoRepo"
|
||||||
|
);
|
||||||
|
assert_eq!(page.rows[0].target, ActivityTargetKind::Issue);
|
||||||
|
assert_eq!(page.rows[0].owner, "apple");
|
||||||
|
assert_eq!(page.rows[0].repository, "SimpleDemoRepo");
|
||||||
|
assert_eq!(page.rows[0].number, 1);
|
||||||
|
assert!(page.has_more);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn issue_page_exposes_state_and_owned_comment_editing() {
|
||||||
|
let page = issue_page(IssueDetails {
|
||||||
|
issue: models::Issue {
|
||||||
|
state: Some("closed".into()),
|
||||||
|
labels: Some(vec![models::Label {
|
||||||
|
name: Some("bug".into()),
|
||||||
|
color: Some("ff0000".into()),
|
||||||
|
..Default::default()
|
||||||
|
}]),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
comments: vec![
|
||||||
|
models::Comment {
|
||||||
|
id: Some(7),
|
||||||
|
user: Some(Box::new(models::User {
|
||||||
|
id: Some(3),
|
||||||
|
login: Some("viewer".into()),
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
body: Some("Comment".into()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
models::Comment {
|
||||||
|
id: Some(8),
|
||||||
|
user: Some(Box::new(models::User {
|
||||||
|
id: Some(4),
|
||||||
|
login: Some("someone-else".into()),
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
],
|
||||||
|
viewer_id: Some(3),
|
||||||
|
has_more: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(page.state, WorkItemState::Closed);
|
||||||
|
assert_eq!(page.labels[0].name, "bug");
|
||||||
|
assert_eq!(page.comments[0].id, 7);
|
||||||
|
assert!(page.comments[0].can_edit);
|
||||||
|
assert!(!page.comments[1].can_edit);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn issue_editor_exposes_selected_metadata_and_due_date() {
|
||||||
|
let selected_label = models::Label {
|
||||||
|
id: Some(2),
|
||||||
|
name: Some("bug".into()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let page = issue_editor_page(IssueEditorData {
|
||||||
|
issue: Some(models::Issue {
|
||||||
|
title: Some("Title".into()),
|
||||||
|
body: Some("Body".into()),
|
||||||
|
due_date: Some("2024-02-29T18:00:00Z".into()),
|
||||||
|
state: Some("closed".into()),
|
||||||
|
labels: Some(vec![selected_label.clone()]),
|
||||||
|
milestone: Some(Box::new(models::Milestone {
|
||||||
|
id: Some(4),
|
||||||
|
title: Some("Current".into()),
|
||||||
|
state: Some("closed".into()),
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
labels: vec![selected_label],
|
||||||
|
milestones: vec![models::Milestone {
|
||||||
|
id: Some(4),
|
||||||
|
title: Some("Current".into()),
|
||||||
|
state: Some("closed".into()),
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!((page.title.as_str(), page.body.as_str()), ("Title", "Body"));
|
||||||
|
assert_eq!(page.due_date, Some(1_709_164_800));
|
||||||
|
assert!(page.closed);
|
||||||
|
assert!(page.labels[0].selected);
|
||||||
|
assert!(page.milestones[0].selected);
|
||||||
|
|
||||||
|
let new = issue_editor_page(IssueEditorData {
|
||||||
|
issue: None,
|
||||||
|
labels: Vec::new(),
|
||||||
|
milestones: Vec::new(),
|
||||||
|
});
|
||||||
|
assert!(!new.closed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builds_sorted_issue_filter_options() {
|
||||||
|
let filter = IssueFilter {
|
||||||
|
milestone: "v3".into(),
|
||||||
|
labels: ["bug".into(), "retired".into()].into(),
|
||||||
|
search_text: "login".into(),
|
||||||
|
};
|
||||||
|
let options = issue_filter_options(
|
||||||
|
&[
|
||||||
|
models::Label {
|
||||||
|
name: Some("critical".into()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
models::Label {
|
||||||
|
name: Some("bug".into()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
],
|
||||||
|
&[
|
||||||
|
models::Milestone {
|
||||||
|
title: Some("v2".into()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
models::Milestone {
|
||||||
|
title: Some("v1".into()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
],
|
||||||
|
&filter,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(options.labels, ["bug", "critical", "retired"]);
|
||||||
|
assert_eq!(options.unavailable_labels, ["retired"]);
|
||||||
|
assert_eq!(options.milestones, ["v1", "v2", "v3"]);
|
||||||
|
assert_eq!(options.selected_labels, ["bug", "retired"]);
|
||||||
|
assert_eq!(options.selected_milestone, "v3");
|
||||||
|
assert_eq!(options.search_text, "login");
|
||||||
|
|
||||||
|
let pull_options = pull_filter_options(
|
||||||
|
&["v2".into(), "v1".into()],
|
||||||
|
&PullFilter {
|
||||||
|
milestone: "v3".into(),
|
||||||
|
search_text: "review".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(pull_options.milestones, ["v1", "v2", "v3"]);
|
||||||
|
assert_eq!(pull_options.selected_milestone, "v3");
|
||||||
|
assert_eq!(pull_options.search_text, "review");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn milestone_page_includes_linked_pull_requests() {
|
||||||
|
let page = milestone_page(MilestoneDetails {
|
||||||
|
milestone: models::Milestone::default(),
|
||||||
|
issues: Vec::new(),
|
||||||
|
pulls: vec![models::Issue {
|
||||||
|
number: Some(7),
|
||||||
|
state: Some("closed".into()),
|
||||||
|
title: Some("Linked pull".into()),
|
||||||
|
repository: Some(Box::new(models::RepositoryMeta {
|
||||||
|
name: Some("demo".into()),
|
||||||
|
owner: Some("octo".into()),
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
has_more: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(page.issues.is_empty());
|
||||||
|
assert_eq!(page.pulls.len(), 1);
|
||||||
|
assert_eq!(page.pulls[0].number, 7);
|
||||||
|
assert_eq!(page.pulls[0].state, WorkItemState::Closed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pull_page_exposes_open_closed_state() {
|
||||||
|
let page = pull_page(PullDetails {
|
||||||
|
pull: models::PullRequest {
|
||||||
|
state: Some("closed".into()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
comments: Vec::new(),
|
||||||
|
files: Vec::new(),
|
||||||
|
has_more: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(page.state, WorkItemState::Closed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn milestone_editor_preserves_description_and_optional_due_date() {
|
||||||
|
let page = milestone_editor_page(Some(models::Milestone {
|
||||||
|
title: Some("Version 1".into()),
|
||||||
|
description: Some("Ship it".into()),
|
||||||
|
due_on: Some("2024-02-29T12:34:56Z".into()),
|
||||||
|
state: Some("closed".into()),
|
||||||
|
..Default::default()
|
||||||
|
}));
|
||||||
|
assert_eq!(page.title, "Version 1");
|
||||||
|
assert_eq!(page.description, "Ship it");
|
||||||
|
assert_eq!(page.due_date, Some(1_709_164_800));
|
||||||
|
assert!(page.closed);
|
||||||
|
|
||||||
|
let new = milestone_editor_page(None);
|
||||||
|
assert_eq!(new.due_date, None);
|
||||||
|
assert!(!new.closed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classifies_repository_files_in_rust() {
|
||||||
|
let markdown = repository_file_page("docs/README.md", b"# Hello".to_vec());
|
||||||
|
assert_eq!(
|
||||||
|
(
|
||||||
|
markdown.name.as_str(),
|
||||||
|
markdown.kind,
|
||||||
|
markdown.language.as_str()
|
||||||
|
),
|
||||||
|
("README.md", RepositoryFileKind::Markdown, "markdown")
|
||||||
|
);
|
||||||
|
assert_eq!(markdown.text, "# Hello");
|
||||||
|
|
||||||
|
let source = repository_file_page("Sources/App.swift", b"import UIKit".to_vec());
|
||||||
|
assert_eq!(
|
||||||
|
(source.kind, source.language.as_str()),
|
||||||
|
(RepositoryFileKind::Source, "swift")
|
||||||
|
);
|
||||||
|
|
||||||
|
let preview = repository_file_page("image.png", vec![0, 159]);
|
||||||
|
assert_eq!(preview.kind, RepositoryFileKind::Preview);
|
||||||
|
assert_eq!(preview.data, [0, 159]);
|
||||||
|
|
||||||
|
for path in ["manual.pdf", "sound.mp3", "movie.mp4"] {
|
||||||
|
assert_eq!(
|
||||||
|
repository_file_page(path, Vec::new()).kind,
|
||||||
|
RepositoryFileKind::Preview
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
349
crates/app/src/storage.rs
Normal file
349
crates/app/src/storage.rs
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
use std::{
|
||||||
|
env, fs,
|
||||||
|
path::PathBuf,
|
||||||
|
process,
|
||||||
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
|
use gotcha_gitea::{Client, Provider};
|
||||||
|
use security_framework::passwords::{
|
||||||
|
PasswordOptions, delete_generic_password_options, generic_password, get_generic_password,
|
||||||
|
set_generic_password_options,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
RepositoryPane,
|
||||||
|
domain::{Preferences, Server, normalize_primary_destinations, open_status},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn favorite_key(pane: RepositoryPane, server: &str, owner: &str, repository: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"{}|{}",
|
||||||
|
pane.key(),
|
||||||
|
repository_key(server, owner, repository)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn repository_key(server: &str, owner: &str, repository: &str) -> String {
|
||||||
|
format!("{server}|{owner}/{repository}")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_server(
|
||||||
|
name: &str,
|
||||||
|
url: &str,
|
||||||
|
token: &str,
|
||||||
|
provider: Provider,
|
||||||
|
) -> Result<Server, String> {
|
||||||
|
let name = name.trim();
|
||||||
|
let url = url.trim().trim_end_matches('/');
|
||||||
|
let token = token.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err("Give this server a name.".into());
|
||||||
|
}
|
||||||
|
if token.is_empty() {
|
||||||
|
return Err("Enter an access token.".into());
|
||||||
|
}
|
||||||
|
Client::with_provider(url, Some(token), provider).map_err(|error| error.to_string())?;
|
||||||
|
Ok(Server {
|
||||||
|
name: name.into(),
|
||||||
|
url: url.into(),
|
||||||
|
provider,
|
||||||
|
credential_account: String::new(),
|
||||||
|
token: token.into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const APP_GROUP: &str = "group.de.rfc1437.gotcha";
|
||||||
|
const KEYCHAIN_SERVICE: &str = "de.rfc1437.gotcha";
|
||||||
|
|
||||||
|
pub fn load_preferences(storage_directory: Option<&str>) -> Result<Preferences, String> {
|
||||||
|
let path = preferences_path(storage_directory)?;
|
||||||
|
let legacy = preferences_path(None)?;
|
||||||
|
let source = if path.exists() {
|
||||||
|
path.clone()
|
||||||
|
} else if path != legacy && legacy.exists() {
|
||||||
|
legacy
|
||||||
|
} else {
|
||||||
|
return Ok(Preferences {
|
||||||
|
path,
|
||||||
|
..Preferences::default()
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let mut preferences: Preferences =
|
||||||
|
serde_json::from_slice(&fs::read(&source).map_err(|error| error.to_string())?)
|
||||||
|
.map_err(|error| format!("Cannot read {}: {error}", source.display()))?;
|
||||||
|
preferences.path = path;
|
||||||
|
let storage_migrated = source != preferences.path;
|
||||||
|
let favorites_migrated = migrate_favorites(&mut preferences.favorites);
|
||||||
|
if !matches!(preferences.issue_status.as_str(), "open" | "closed") {
|
||||||
|
preferences.issue_status = open_status();
|
||||||
|
}
|
||||||
|
if !matches!(preferences.pull_status.as_str(), "open" | "closed") {
|
||||||
|
preferences.pull_status = open_status();
|
||||||
|
}
|
||||||
|
let navigation_migrated = normalize_primary_destinations(&mut preferences.primary_destinations);
|
||||||
|
let mut credentials_migrated = false;
|
||||||
|
for server in &mut preferences.servers {
|
||||||
|
if server.credential_account.is_empty() {
|
||||||
|
server.credential_account = format!("{}|{}", server.name, server.url);
|
||||||
|
credentials_migrated = true;
|
||||||
|
}
|
||||||
|
server.token = String::from_utf8(load_server_token(server)?)
|
||||||
|
.map_err(|_| format!("The token for {} is not valid text.", server.name))?;
|
||||||
|
}
|
||||||
|
if storage_migrated || favorites_migrated || navigation_migrated || credentials_migrated {
|
||||||
|
save_preferences(&preferences)?;
|
||||||
|
}
|
||||||
|
Ok(preferences)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn migrate_favorites(favorites: &mut std::collections::BTreeSet<String>) -> bool {
|
||||||
|
let legacy: Vec<_> = favorites
|
||||||
|
.iter()
|
||||||
|
.filter(|favorite| {
|
||||||
|
!RepositoryPane::ALL
|
||||||
|
.iter()
|
||||||
|
.any(|pane| favorite.starts_with(&format!("{}|", pane.key())))
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
for favorite in &legacy {
|
||||||
|
favorites.remove(favorite);
|
||||||
|
favorites.extend(RepositoryPane::ALL.map(|pane| format!("{}|{favorite}", pane.key())));
|
||||||
|
}
|
||||||
|
!legacy.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_preferences(preferences: &Preferences) -> Result<(), String> {
|
||||||
|
let path = if preferences.path.as_os_str().is_empty() {
|
||||||
|
preferences_path(None)?
|
||||||
|
} else {
|
||||||
|
preferences.path.clone()
|
||||||
|
};
|
||||||
|
let parent = path.parent().ok_or("Invalid app data directory.")?;
|
||||||
|
fs::create_dir_all(parent).map_err(|error| error.to_string())?;
|
||||||
|
let temporary = path.with_extension("tmp");
|
||||||
|
fs::write(
|
||||||
|
&temporary,
|
||||||
|
serde_json::to_vec(preferences).map_err(|error| error.to_string())?,
|
||||||
|
)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
fs::rename(&temporary, &path).map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_server_token(server: &Server) -> Result<(), String> {
|
||||||
|
set_generic_password_options(server.token.as_bytes(), shared_password_options(server))
|
||||||
|
.map_err(|error| format!("Cannot save the token for {}: {error}", server.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_server_token(server: &Server) -> Result<(), String> {
|
||||||
|
match delete_generic_password_options(shared_password_options(server)) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
// A missing Keychain item must not make an otherwise valid profile undeletable.
|
||||||
|
Err(error) if error.code() == -25300 => Ok(()), // errSecItemNotFound
|
||||||
|
Err(error) => Err(format!(
|
||||||
|
"Cannot delete the token for {}: {error}",
|
||||||
|
server.name
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn assign_server_credential_account(server: &mut Server, existing: &[Server]) {
|
||||||
|
if !server.credential_account.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let timestamp = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_nanos();
|
||||||
|
let prefix = format!("server-{}-{timestamp}", process::id());
|
||||||
|
server.credential_account = (0_u32..)
|
||||||
|
.map(|suffix| {
|
||||||
|
if suffix == 0 {
|
||||||
|
prefix.clone()
|
||||||
|
} else {
|
||||||
|
format!("{prefix}-{suffix}")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.find(|candidate| {
|
||||||
|
existing
|
||||||
|
.iter()
|
||||||
|
.all(|saved| saved.credential_account != *candidate)
|
||||||
|
})
|
||||||
|
.expect("a unique Keychain account must exist");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn preferences_path(storage_directory: Option<&str>) -> Result<PathBuf, String> {
|
||||||
|
if let Some(directory) = storage_directory {
|
||||||
|
let directory = directory.trim();
|
||||||
|
if directory.is_empty() {
|
||||||
|
return Err("Invalid shared app data directory.".into());
|
||||||
|
}
|
||||||
|
return Ok(PathBuf::from(directory).join("preferences.json"));
|
||||||
|
}
|
||||||
|
let home = env::var_os("HOME").ok_or("Cannot find the app data directory.")?;
|
||||||
|
Ok(PathBuf::from(home).join("Library/Application Support/Gotcha/preferences.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_server_token(server: &Server) -> Result<Vec<u8>, String> {
|
||||||
|
match generic_password(shared_password_options(server)) {
|
||||||
|
Ok(token) => Ok(token),
|
||||||
|
Err(error) if error.code() == -25300 => {
|
||||||
|
let token = get_generic_password(KEYCHAIN_SERVICE, &keychain_account(server))
|
||||||
|
.map_err(|error| format!("Cannot read the token for {}: {error}", server.name))?;
|
||||||
|
set_generic_password_options(&token, shared_password_options(server))
|
||||||
|
.map_err(|error| format!("Cannot share the token for {}: {error}", server.name))?;
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
Err(error) => Err(format!(
|
||||||
|
"Cannot read the token for {}: {error}",
|
||||||
|
server.name
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shared_password_options(server: &Server) -> PasswordOptions {
|
||||||
|
let mut options =
|
||||||
|
PasswordOptions::new_generic_password(KEYCHAIN_SERVICE, &keychain_account(server));
|
||||||
|
options.set_access_group(APP_GROUP);
|
||||||
|
options
|
||||||
|
}
|
||||||
|
|
||||||
|
fn keychain_account(server: &Server) -> String {
|
||||||
|
if server.credential_account.is_empty() {
|
||||||
|
format!("{}|{}", server.name, server.url)
|
||||||
|
} else {
|
||||||
|
server.credential_account.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validates_servers_and_builds_stable_favorite_keys() {
|
||||||
|
assert!(
|
||||||
|
validate_server(
|
||||||
|
"Work",
|
||||||
|
"https://gitea.example.com/",
|
||||||
|
"secret",
|
||||||
|
Provider::Gitea
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
validate_server("", "https://gitea.example.com", "secret", Provider::Gitea).is_err()
|
||||||
|
);
|
||||||
|
assert!(validate_server("Work", "file:///tmp/gitea", "secret", Provider::Forgejo).is_err());
|
||||||
|
assert_eq!(
|
||||||
|
favorite_key(
|
||||||
|
RepositoryPane::Issues,
|
||||||
|
"https://gitea.example.com",
|
||||||
|
"octo",
|
||||||
|
"demo"
|
||||||
|
),
|
||||||
|
"issues|https://gitea.example.com|octo/demo"
|
||||||
|
);
|
||||||
|
let preferences: Preferences = serde_json::from_str(
|
||||||
|
r#"{"issue_filters":{"server|octo/demo":{"milestone":"v1","labels":["bug"]}},"pull_filters":{"server":{"milestone":"v2"}}}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
preferences.issue_filters["server|octo/demo"].milestone,
|
||||||
|
"v1"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
preferences.issue_filters["server|octo/demo"]
|
||||||
|
.labels
|
||||||
|
.contains("bug")
|
||||||
|
);
|
||||||
|
assert_eq!(preferences.pull_filters["server"].milestone, "v2");
|
||||||
|
assert_eq!(Preferences::default().pull_status, "open");
|
||||||
|
assert_eq!(
|
||||||
|
Preferences::default().appearance,
|
||||||
|
crate::domain::AppearanceMode::Auto
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Preferences>(r#"{"appearance":"dark"}"#)
|
||||||
|
.unwrap()
|
||||||
|
.appearance,
|
||||||
|
crate::domain::AppearanceMode::Dark
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Preferences>(r#"{"appearance":"future-mode"}"#)
|
||||||
|
.unwrap()
|
||||||
|
.appearance,
|
||||||
|
crate::domain::AppearanceMode::Auto
|
||||||
|
);
|
||||||
|
let legacy_server: Server =
|
||||||
|
serde_json::from_str(r#"{"name":"Work","url":"https://gitea.example.com"}"#).unwrap();
|
||||||
|
assert_eq!(legacy_server.provider, Provider::Gitea);
|
||||||
|
assert!(legacy_server.credential_account.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
keychain_account(&legacy_server),
|
||||||
|
"Work|https://gitea.example.com"
|
||||||
|
);
|
||||||
|
let mut new_server = legacy_server.clone();
|
||||||
|
assign_server_credential_account(&mut new_server, &[]);
|
||||||
|
assert!(new_server.credential_account.starts_with("server-"));
|
||||||
|
assert_eq!(keychain_account(&new_server), new_server.credential_account);
|
||||||
|
assert_eq!(
|
||||||
|
crate::domain::AppearanceMode::from_index(1),
|
||||||
|
Some(crate::domain::AppearanceMode::Light)
|
||||||
|
);
|
||||||
|
assert_eq!(crate::domain::AppearanceMode::from_index(3), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migrates_global_favorites_to_each_repository_pane() {
|
||||||
|
let mut favorites = ["https://gitea.example.com|octo/demo".to_string()]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert!(migrate_favorites(&mut favorites));
|
||||||
|
assert_eq!(
|
||||||
|
favorites,
|
||||||
|
[
|
||||||
|
"actions|https://gitea.example.com|octo/demo".to_string(),
|
||||||
|
"commits|https://gitea.example.com|octo/demo".to_string(),
|
||||||
|
"issues|https://gitea.example.com|octo/demo".to_string(),
|
||||||
|
"milestones|https://gitea.example.com|octo/demo".to_string(),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect()
|
||||||
|
);
|
||||||
|
assert!(!migrate_favorites(&mut favorites));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn saves_preferences_to_the_selected_storage_directory() {
|
||||||
|
let directory = env::temp_dir().join(format!(
|
||||||
|
"gotcha-preferences-{}-{}",
|
||||||
|
process::id(),
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos()
|
||||||
|
));
|
||||||
|
let path = directory.join("preferences.json");
|
||||||
|
let preferences = Preferences {
|
||||||
|
path: path.clone(),
|
||||||
|
pull_status: "closed".into(),
|
||||||
|
..Preferences::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
save_preferences(&preferences).unwrap();
|
||||||
|
let stored = fs::read_to_string(&path).unwrap();
|
||||||
|
assert!(!stored.contains(path.to_string_lossy().as_ref()));
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Preferences>(&stored)
|
||||||
|
.unwrap()
|
||||||
|
.pull_status,
|
||||||
|
"closed"
|
||||||
|
);
|
||||||
|
|
||||||
|
fs::remove_file(path).unwrap();
|
||||||
|
fs::remove_dir(directory).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "gotcha-cli"
|
name = "gotcha-cli"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
description = "CLI test bed for the Gotcha Gitea client"
|
description = "Command-line Gitea client"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
|
|||||||
415
crates/cli/src/actions.rs
Normal file
415
crates/cli/src/actions.rs
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
use std::error::Error;
|
||||||
|
|
||||||
|
use gotcha_gitea::{
|
||||||
|
ActionJobLog, ActionRunDetails, ActionRunQuery, Client,
|
||||||
|
models::{ActionWorkflow, ActionWorkflowJob, ActionWorkflowRun},
|
||||||
|
parse_action_inputs,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
config::{RepositoryScope, Selection},
|
||||||
|
print_table,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTION_HELP: &str = "\
|
||||||
|
Usage: gotcha action workflow|run SUBCOMMAND
|
||||||
|
|
||||||
|
Workflow subcommands:
|
||||||
|
workflow list [OWNER/REPOSITORY]
|
||||||
|
List workflows
|
||||||
|
workflow dispatch WORKFLOW REF [OWNER/REPOSITORY] [--input KEY=VALUE]…
|
||||||
|
Dispatch a workflow on a Git reference
|
||||||
|
|
||||||
|
Run subcommands:
|
||||||
|
run list [OWNER/REPOSITORY] [--page N] [--limit N]
|
||||||
|
[--status STATUS] [--event EVENT] [--branch BRANCH]
|
||||||
|
[--actor LOGIN] [--sha SHA]
|
||||||
|
List workflow runs
|
||||||
|
run show ID [OWNER/REPOSITORY] Show a run and its jobs
|
||||||
|
run logs ID [OWNER/REPOSITORY] Print every job log for a run";
|
||||||
|
|
||||||
|
const WORKFLOW_HELP: &str = "\
|
||||||
|
Usage: gotcha action workflow SUBCOMMAND
|
||||||
|
|
||||||
|
Subcommands:
|
||||||
|
list [OWNER/REPOSITORY]
|
||||||
|
dispatch WORKFLOW REF [OWNER/REPOSITORY] [--input KEY=VALUE]…";
|
||||||
|
|
||||||
|
const RUN_HELP: &str = "\
|
||||||
|
Usage: gotcha action run SUBCOMMAND
|
||||||
|
|
||||||
|
Subcommands:
|
||||||
|
list [OWNER/REPOSITORY] [--page N] [--limit N] [--status STATUS]
|
||||||
|
[--event EVENT] [--branch BRANCH] [--actor LOGIN] [--sha SHA]
|
||||||
|
show ID [OWNER/REPOSITORY]
|
||||||
|
logs ID [OWNER/REPOSITORY]";
|
||||||
|
|
||||||
|
pub async fn run(
|
||||||
|
command: &[String],
|
||||||
|
selection: &Selection,
|
||||||
|
client: &Client,
|
||||||
|
) -> Result<bool, Box<dyn Error>> {
|
||||||
|
match command {
|
||||||
|
[domain, group, action]
|
||||||
|
if domain == "action" && group == "workflow" && action == "list" =>
|
||||||
|
{
|
||||||
|
print_workflows(&client.action_workflows(&scope(selection, None)?).await?);
|
||||||
|
}
|
||||||
|
[domain, group, action, repository]
|
||||||
|
if domain == "action" && group == "workflow" && action == "list" =>
|
||||||
|
{
|
||||||
|
print_workflows(
|
||||||
|
&client
|
||||||
|
.action_workflows(&scope(selection, Some(repository))?)
|
||||||
|
.await?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
[domain, group, action, arguments @ ..]
|
||||||
|
if domain == "action" && group == "workflow" && action == "dispatch" =>
|
||||||
|
{
|
||||||
|
let arguments = dispatch_arguments(arguments, selection)?;
|
||||||
|
dispatch(
|
||||||
|
client,
|
||||||
|
&arguments.repository,
|
||||||
|
&arguments.workflow,
|
||||||
|
&arguments.reference,
|
||||||
|
&arguments.inputs,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
[domain, group, action, arguments @ ..]
|
||||||
|
if domain == "action" && group == "run" && action == "list" =>
|
||||||
|
{
|
||||||
|
let (repository, query) = run_list_arguments(arguments, selection)?;
|
||||||
|
print_runs(&client.action_runs(&repository, &query).await?.items);
|
||||||
|
}
|
||||||
|
[domain, group, action, id] if domain == "action" && group == "run" && action == "show" => {
|
||||||
|
print_run(
|
||||||
|
&client
|
||||||
|
.action_run_details(&scope(selection, None)?, number(id)?)
|
||||||
|
.await?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
[domain, group, action, id, repository]
|
||||||
|
if domain == "action" && group == "run" && action == "show" =>
|
||||||
|
{
|
||||||
|
print_run(
|
||||||
|
&client
|
||||||
|
.action_run_details(&scope(selection, Some(repository))?, number(id)?)
|
||||||
|
.await?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
[domain, group, action, id] if domain == "action" && group == "run" && action == "logs" => {
|
||||||
|
print_logs(
|
||||||
|
number(id)?,
|
||||||
|
&client
|
||||||
|
.action_run_logs(&scope(selection, None)?, number(id)?)
|
||||||
|
.await?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
[domain, group, action, id, repository]
|
||||||
|
if domain == "action" && group == "run" && action == "logs" =>
|
||||||
|
{
|
||||||
|
print_logs(
|
||||||
|
number(id)?,
|
||||||
|
&client
|
||||||
|
.action_run_logs(&scope(selection, Some(repository))?, number(id)?)
|
||||||
|
.await?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
[domain, ..] if domain == "action" => return Ok(false),
|
||||||
|
_ => return Ok(false),
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn domain_help(domain: &str) -> Option<&'static str> {
|
||||||
|
(domain == "action").then_some(ACTION_HELP)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn subcommand_help(domain: &str, command: &str) -> Option<&'static str> {
|
||||||
|
match (domain, command) {
|
||||||
|
("action", "workflow") => Some(WORKFLOW_HELP),
|
||||||
|
("action", "run") => Some(RUN_HELP),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scope(selection: &Selection, value: Option<&str>) -> Result<RepositoryScope, Box<dyn Error>> {
|
||||||
|
match value {
|
||||||
|
Some(value) => Ok(RepositoryScope::parse(value)?),
|
||||||
|
None => selection.repository.clone().ok_or_else(|| {
|
||||||
|
"cannot infer a repository; run inside one or pass OWNER/REPOSITORY".into()
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn number(value: &str) -> Result<i64, Box<dyn Error>> {
|
||||||
|
let number = value.parse()?;
|
||||||
|
if number < 1 {
|
||||||
|
return Err("run ID must be a positive integer".into());
|
||||||
|
}
|
||||||
|
Ok(number)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dispatch(
|
||||||
|
client: &Client,
|
||||||
|
repository: &RepositoryScope,
|
||||||
|
workflow: &str,
|
||||||
|
reference: &str,
|
||||||
|
inputs: &std::collections::BTreeMap<String, String>,
|
||||||
|
) -> Result<(), Box<dyn Error>> {
|
||||||
|
client
|
||||||
|
.dispatch_action_workflow(repository, workflow, reference, inputs)
|
||||||
|
.await?;
|
||||||
|
println!("Dispatched workflow {workflow} on {reference}.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DispatchArguments {
|
||||||
|
repository: RepositoryScope,
|
||||||
|
workflow: String,
|
||||||
|
reference: String,
|
||||||
|
inputs: std::collections::BTreeMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispatch_arguments(
|
||||||
|
arguments: &[String],
|
||||||
|
selection: &Selection,
|
||||||
|
) -> Result<DispatchArguments, Box<dyn Error>> {
|
||||||
|
let [workflow, reference, remaining @ ..] = arguments else {
|
||||||
|
return Err("usage: gotcha action workflow dispatch WORKFLOW REF [OWNER/REPOSITORY] [--input KEY=VALUE]…".into());
|
||||||
|
};
|
||||||
|
let mut repository = None;
|
||||||
|
let mut inputs = Vec::new();
|
||||||
|
let mut index = 0;
|
||||||
|
while index < remaining.len() {
|
||||||
|
if remaining[index] == "--input" {
|
||||||
|
index += 1;
|
||||||
|
inputs.push(
|
||||||
|
remaining
|
||||||
|
.get(index)
|
||||||
|
.ok_or("--input requires KEY=VALUE")?
|
||||||
|
.clone(),
|
||||||
|
);
|
||||||
|
} else if remaining[index].starts_with('-') {
|
||||||
|
return Err(format!("unknown dispatch option: {}", remaining[index]).into());
|
||||||
|
} else if repository.replace(remaining[index].as_str()).is_some() {
|
||||||
|
return Err("only one OWNER/REPOSITORY may be supplied".into());
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
Ok(DispatchArguments {
|
||||||
|
repository: scope(selection, repository)?,
|
||||||
|
workflow: workflow.clone(),
|
||||||
|
reference: reference.clone(),
|
||||||
|
inputs: parse_action_inputs(&inputs)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_list_arguments(
|
||||||
|
arguments: &[String],
|
||||||
|
selection: &Selection,
|
||||||
|
) -> Result<(RepositoryScope, ActionRunQuery), Box<dyn Error>> {
|
||||||
|
let mut repository = None;
|
||||||
|
let mut query = ActionRunQuery::default();
|
||||||
|
let mut index = 0;
|
||||||
|
while index < arguments.len() {
|
||||||
|
let argument = &arguments[index];
|
||||||
|
if argument.starts_with('-') {
|
||||||
|
index += 1;
|
||||||
|
let value = arguments
|
||||||
|
.get(index)
|
||||||
|
.ok_or_else(|| format!("{argument} requires a value"))?;
|
||||||
|
match argument.as_str() {
|
||||||
|
"--page" => query.page = positive_number(value, "page")?,
|
||||||
|
"--limit" => query.limit = positive_number(value, "limit")?,
|
||||||
|
"--status" => query.status = Some(value.clone()),
|
||||||
|
"--event" => query.event = Some(value.clone()),
|
||||||
|
"--branch" => query.branch = Some(value.clone()),
|
||||||
|
"--actor" => query.actor = Some(value.clone()),
|
||||||
|
"--sha" => query.head_sha = Some(value.clone()),
|
||||||
|
_ => return Err(format!("unknown run-list option: {argument}").into()),
|
||||||
|
}
|
||||||
|
} else if repository.replace(argument.as_str()).is_some() {
|
||||||
|
return Err("only one OWNER/REPOSITORY may be supplied".into());
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
Ok((scope(selection, repository)?, query))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn positive_number(value: &str, name: &str) -> Result<i32, Box<dyn Error>> {
|
||||||
|
value
|
||||||
|
.parse::<i32>()
|
||||||
|
.ok()
|
||||||
|
.filter(|value| *value > 0)
|
||||||
|
.ok_or_else(|| format!("{name} must be a positive integer").into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_workflows(workflows: &[ActionWorkflow]) {
|
||||||
|
print_table(
|
||||||
|
&[("ID", 40), ("STATE", 12), ("NAME", 50), ("PATH", 70)],
|
||||||
|
workflows
|
||||||
|
.iter()
|
||||||
|
.map(|workflow| {
|
||||||
|
vec![
|
||||||
|
workflow.id.as_deref().unwrap_or("unknown").into(),
|
||||||
|
workflow.state.as_deref().unwrap_or("unknown").into(),
|
||||||
|
workflow.name.as_deref().unwrap_or("").into(),
|
||||||
|
workflow.path.as_deref().unwrap_or("").into(),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_runs(runs: &[ActionWorkflowRun]) {
|
||||||
|
print_table(
|
||||||
|
&[
|
||||||
|
("ID", 12),
|
||||||
|
("RUN", 8),
|
||||||
|
("STATUS", 14),
|
||||||
|
("RESULT", 14),
|
||||||
|
("EVENT", 20),
|
||||||
|
("BRANCH", 30),
|
||||||
|
("WORKFLOW", 60),
|
||||||
|
],
|
||||||
|
runs.iter()
|
||||||
|
.map(|run| {
|
||||||
|
vec![
|
||||||
|
run.id.unwrap_or_default().to_string(),
|
||||||
|
run.run_number.unwrap_or_default().to_string(),
|
||||||
|
run.status.as_deref().unwrap_or("unknown").into(),
|
||||||
|
run.conclusion.as_deref().unwrap_or("-").into(),
|
||||||
|
run.event.as_deref().unwrap_or("unknown").into(),
|
||||||
|
run.head_branch.as_deref().unwrap_or("-").into(),
|
||||||
|
run.path.as_deref().unwrap_or("").into(),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_run(details: &ActionRunDetails) {
|
||||||
|
let run = &details.run;
|
||||||
|
println!(
|
||||||
|
"Run {} #{} [{} / {}] {}",
|
||||||
|
run.id.unwrap_or_default(),
|
||||||
|
run.run_number.unwrap_or_default(),
|
||||||
|
run.status.as_deref().unwrap_or("unknown"),
|
||||||
|
run.conclusion.as_deref().unwrap_or("-"),
|
||||||
|
run.display_title.as_deref().unwrap_or("")
|
||||||
|
);
|
||||||
|
field("workflow", run.path.as_deref());
|
||||||
|
field("event", run.event.as_deref());
|
||||||
|
field("branch", run.head_branch.as_deref());
|
||||||
|
field("commit", run.head_sha.as_deref());
|
||||||
|
field("started", run.started_at.as_deref());
|
||||||
|
field("completed", run.completed_at.as_deref());
|
||||||
|
field("url", run.html_url.as_deref());
|
||||||
|
print_jobs(&details.jobs);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_jobs(jobs: &[ActionWorkflowJob]) {
|
||||||
|
println!();
|
||||||
|
print_table(
|
||||||
|
&[
|
||||||
|
("JOB", 12),
|
||||||
|
("STATUS", 14),
|
||||||
|
("RESULT", 14),
|
||||||
|
("RUNNER", 30),
|
||||||
|
("NAME", 60),
|
||||||
|
],
|
||||||
|
jobs.iter()
|
||||||
|
.map(|job| {
|
||||||
|
vec![
|
||||||
|
job.id.unwrap_or_default().to_string(),
|
||||||
|
job.status.as_deref().unwrap_or("unknown").into(),
|
||||||
|
job.conclusion.as_deref().unwrap_or("-").into(),
|
||||||
|
job.runner_name.as_deref().unwrap_or("-").into(),
|
||||||
|
job.name.as_deref().unwrap_or("").into(),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_logs(run: i64, logs: &[ActionJobLog]) {
|
||||||
|
if logs.is_empty() {
|
||||||
|
println!("No jobs found for run {run}.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (index, log) in logs.iter().enumerate() {
|
||||||
|
if index > 0 {
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"== {} ({}) ==",
|
||||||
|
log.job.name.as_deref().unwrap_or("job"),
|
||||||
|
log.job.id.unwrap_or_default()
|
||||||
|
);
|
||||||
|
print!("{}", log.text);
|
||||||
|
if !log.text.ends_with('\n') {
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field(name: &str, value: Option<&str>) {
|
||||||
|
if let Some(value) = value.filter(|value| !value.is_empty()) {
|
||||||
|
println!("{name}: {value}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validates_run_ids_and_exposes_nested_help() {
|
||||||
|
assert!(number("0").is_err());
|
||||||
|
assert_eq!(number("95").unwrap(), 95);
|
||||||
|
assert!(domain_help("action").unwrap().contains("workflow dispatch"));
|
||||||
|
assert!(
|
||||||
|
subcommand_help("action", "run")
|
||||||
|
.unwrap()
|
||||||
|
.contains("logs ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_dispatch_inputs_and_run_filters() {
|
||||||
|
let selection = Selection {
|
||||||
|
name: Some("gitea".into()),
|
||||||
|
url: "https://gitea.example.com".into(),
|
||||||
|
token: Some("secret".into()),
|
||||||
|
provider: gotcha_gitea::Provider::Gitea,
|
||||||
|
repository: Some(RepositoryScope::parse("hugo/Gotcha").unwrap()),
|
||||||
|
};
|
||||||
|
let dispatch = dispatch_arguments(
|
||||||
|
&[
|
||||||
|
"release.yml".into(),
|
||||||
|
"main".into(),
|
||||||
|
"--input".into(),
|
||||||
|
"channel=stable".into(),
|
||||||
|
],
|
||||||
|
&selection,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(dispatch.inputs["channel"], "stable");
|
||||||
|
|
||||||
|
let (_, query) = run_list_arguments(
|
||||||
|
&[
|
||||||
|
"--page".into(),
|
||||||
|
"2".into(),
|
||||||
|
"--status".into(),
|
||||||
|
"in_progress".into(),
|
||||||
|
],
|
||||||
|
&selection,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(query.page, 2);
|
||||||
|
assert_eq!(query.status.as_deref(), Some("in_progress"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,348 +1 @@
|
|||||||
use std::{
|
pub use gotcha_gitea::{Config, RepositoryId as RepositoryScope, Selection, server_url};
|
||||||
collections::BTreeMap,
|
|
||||||
env, fs,
|
|
||||||
io::Write,
|
|
||||||
path::PathBuf,
|
|
||||||
process::{self, Command},
|
|
||||||
time::{SystemTime, UNIX_EPOCH},
|
|
||||||
};
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
|
||||||
|
|
||||||
use gotcha_gitea::{Client, Url};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
type Result<T> = std::result::Result<T, String>;
|
|
||||||
|
|
||||||
#[derive(Clone, Default, Deserialize, Serialize)]
|
|
||||||
pub struct Server {
|
|
||||||
pub url: String,
|
|
||||||
pub token: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default, Deserialize, Serialize)]
|
|
||||||
pub struct Config {
|
|
||||||
#[serde(skip)]
|
|
||||||
path: PathBuf,
|
|
||||||
#[serde(default)]
|
|
||||||
pub servers: BTreeMap<String, Server>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Selection {
|
|
||||||
pub name: Option<String>,
|
|
||||||
pub url: String,
|
|
||||||
pub token: Option<String>,
|
|
||||||
pub repository: Option<RepositoryScope>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct RepositoryScope {
|
|
||||||
pub owner: String,
|
|
||||||
pub repository: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RepositoryScope {
|
|
||||||
pub fn parse(value: &str) -> Result<Self> {
|
|
||||||
let (owner, repository) = value
|
|
||||||
.split_once('/')
|
|
||||||
.ok_or("repository must be OWNER/REPOSITORY")?;
|
|
||||||
if owner.is_empty() || repository.is_empty() || repository.contains('/') {
|
|
||||||
return Err("repository must be OWNER/REPOSITORY".into());
|
|
||||||
}
|
|
||||||
Ok(Self {
|
|
||||||
owner: owner.into(),
|
|
||||||
repository: repository.into(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Config {
|
|
||||||
pub fn load() -> Result<Self> {
|
|
||||||
let home = env::var_os("HOME").ok_or("HOME is not set")?;
|
|
||||||
Self::load_from(PathBuf::from(home).join(".config/gotcha/config"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_from(path: PathBuf) -> Result<Self> {
|
|
||||||
if !path.exists() {
|
|
||||||
return Ok(Self {
|
|
||||||
path,
|
|
||||||
..Self::default()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let text = fs::read_to_string(&path)
|
|
||||||
.map_err(|error| format!("cannot read {}: {error}", path.display()))?;
|
|
||||||
let mut config: Self = serde_yaml::from_str(&text)
|
|
||||||
.map_err(|error| format!("invalid {}: {error}", path.display()))?;
|
|
||||||
config.path = path;
|
|
||||||
for (name, server) in &config.servers {
|
|
||||||
validate_name(name)?;
|
|
||||||
Client::new(&server.url, Some(&server.token))
|
|
||||||
.map_err(|error| format!("invalid server {name}: {error}"))?;
|
|
||||||
}
|
|
||||||
Ok(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn login(&mut self, name: &str, token: &str) -> Result<()> {
|
|
||||||
let url = server_url(name)?;
|
|
||||||
Client::new(&url, Some(token)).map_err(|error| error.to_string())?;
|
|
||||||
self.servers.insert(
|
|
||||||
name.into(),
|
|
||||||
Server {
|
|
||||||
url,
|
|
||||||
token: token.into(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
self.save()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn logout(&mut self, name: &str) -> Result<()> {
|
|
||||||
if self.servers.remove(name).is_none() {
|
|
||||||
return Err(format!("server profile {name:?} does not exist"));
|
|
||||||
}
|
|
||||||
self.save()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn select(&self, name: Option<&str>, url: Option<&str>) -> Result<Selection> {
|
|
||||||
if name.is_some() && url.is_some() {
|
|
||||||
return Err("use either --server or --url, not both".into());
|
|
||||||
}
|
|
||||||
let remotes = git_remotes().unwrap_or_default();
|
|
||||||
|
|
||||||
if let Some(name) = name {
|
|
||||||
let server = self
|
|
||||||
.servers
|
|
||||||
.get(name)
|
|
||||||
.ok_or_else(|| format!("server profile {name:?} does not exist"))?;
|
|
||||||
return Ok(selection(Some(name), server, &remotes));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(url) = url {
|
|
||||||
let matches: Vec<_> = self
|
|
||||||
.servers
|
|
||||||
.iter()
|
|
||||||
.filter(|(_, server)| same_instance(&server.url, url))
|
|
||||||
.collect();
|
|
||||||
return match matches.as_slice() {
|
|
||||||
[] => Ok(selection(
|
|
||||||
None,
|
|
||||||
&Server {
|
|
||||||
url: url.into(),
|
|
||||||
token: String::new(),
|
|
||||||
},
|
|
||||||
&remotes,
|
|
||||||
)),
|
|
||||||
[(name, server)] => Ok(selection(Some(name.as_str()), server, &remotes)),
|
|
||||||
_ => Err("multiple profiles use that URL; select one with --server".into()),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut matches = BTreeMap::new();
|
|
||||||
for (name, server) in &self.servers {
|
|
||||||
if let Some(scope) = remotes
|
|
||||||
.iter()
|
|
||||||
.find_map(|remote| repository_scope(&server.url, remote))
|
|
||||||
{
|
|
||||||
matches.insert(name, (server, scope));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match matches.into_iter().collect::<Vec<_>>().as_slice() {
|
|
||||||
[] if self.servers.is_empty() => {
|
|
||||||
Err("no servers configured; run `gotcha auth login SERVER`".into())
|
|
||||||
}
|
|
||||||
[] => Err("no configured server matches this Git repository; use --server NAME".into()),
|
|
||||||
[(name, (server, scope))] => Ok(Selection {
|
|
||||||
name: Some((*name).clone()),
|
|
||||||
url: server.url.clone(),
|
|
||||||
token: Some(server.token.clone()),
|
|
||||||
repository: Some(scope.clone()),
|
|
||||||
}),
|
|
||||||
_ => Err("multiple server profiles match this Git repository; use --server".into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn save(&self) -> Result<()> {
|
|
||||||
let parent = self
|
|
||||||
.path
|
|
||||||
.parent()
|
|
||||||
.ok_or_else(|| format!("invalid config path: {}", self.path.display()))?;
|
|
||||||
fs::create_dir_all(parent)
|
|
||||||
.map_err(|error| format!("cannot create {}: {error}", parent.display()))?;
|
|
||||||
|
|
||||||
let nonce = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.map_err(|error| error.to_string())?
|
|
||||||
.as_nanos();
|
|
||||||
let temporary = parent.join(format!(".config.{}.{nonce}.tmp", process::id()));
|
|
||||||
let text = serde_yaml::to_string(self).map_err(|error| error.to_string())?;
|
|
||||||
let result = (|| -> std::io::Result<()> {
|
|
||||||
let mut options = fs::OpenOptions::new();
|
|
||||||
options.write(true).create_new(true);
|
|
||||||
#[cfg(unix)]
|
|
||||||
options.mode(0o600);
|
|
||||||
let mut file = options.open(&temporary)?;
|
|
||||||
file.write_all(text.as_bytes())?;
|
|
||||||
file.sync_all()?;
|
|
||||||
fs::rename(&temporary, &self.path)?;
|
|
||||||
#[cfg(unix)]
|
|
||||||
fs::set_permissions(&self.path, fs::Permissions::from_mode(0o600))?;
|
|
||||||
Ok(())
|
|
||||||
})();
|
|
||||||
if result.is_err() {
|
|
||||||
let _ = fs::remove_file(&temporary);
|
|
||||||
}
|
|
||||||
result.map_err(|error| format!("cannot write {}: {error}", self.path.display()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn selection(name: Option<&str>, server: &Server, remotes: &[String]) -> Selection {
|
|
||||||
Selection {
|
|
||||||
name: name.map(str::to_owned),
|
|
||||||
url: server.url.clone(),
|
|
||||||
token: (!server.token.is_empty()).then(|| server.token.clone()),
|
|
||||||
repository: remotes
|
|
||||||
.iter()
|
|
||||||
.find_map(|remote| repository_scope(&server.url, remote)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_name(name: &str) -> Result<()> {
|
|
||||||
server_url(name)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn server_url(name: &str) -> Result<String> {
|
|
||||||
if name.is_empty()
|
|
||||||
|| name.contains('/')
|
|
||||||
|| name.contains('@')
|
|
||||||
|| name.chars().any(char::is_whitespace)
|
|
||||||
{
|
|
||||||
return Err("server name must be a hostname, optionally followed by a port".into());
|
|
||||||
}
|
|
||||||
let url = format!("https://{name}");
|
|
||||||
let parsed = Url::parse(&url).map_err(|_| "invalid server name")?;
|
|
||||||
if parsed.host_str().is_none() || parsed.path() != "/" {
|
|
||||||
return Err("server name must be a hostname, optionally followed by a port".into());
|
|
||||||
}
|
|
||||||
if parsed.query().is_some() || parsed.fragment().is_some() {
|
|
||||||
return Err("server name must be a hostname, optionally followed by a port".into());
|
|
||||||
}
|
|
||||||
Ok(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn same_instance(left: &str, right: &str) -> bool {
|
|
||||||
let api_url = |url| {
|
|
||||||
Client::new(url, None)
|
|
||||||
.ok()
|
|
||||||
.map(|client| client.api_url().clone())
|
|
||||||
};
|
|
||||||
api_url(left) == api_url(right)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn git_remotes() -> Result<Vec<String>> {
|
|
||||||
let output = Command::new("git")
|
|
||||||
.args(["config", "--get-regexp", r"^remote\..*\.url$"])
|
|
||||||
.output()
|
|
||||||
.map_err(|error| format!("cannot inspect Git remotes: {error}"))?;
|
|
||||||
if !output.status.success() {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(String::from_utf8_lossy(&output.stdout)
|
|
||||||
.lines()
|
|
||||||
.filter_map(|line| line.split_once(char::is_whitespace))
|
|
||||||
.map(|(_, url)| url.trim().to_owned())
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn repository_scope(server_url: &str, remote: &str) -> Option<RepositoryScope> {
|
|
||||||
let server = Url::parse(server_url).ok()?;
|
|
||||||
let server_host = server.host_str()?;
|
|
||||||
let (remote_host, mut remote_path, is_http) = if let Ok(url) = Url::parse(remote) {
|
|
||||||
(
|
|
||||||
url.host_str()?.to_owned(),
|
|
||||||
url.path().trim_matches('/').to_owned(),
|
|
||||||
matches!(url.scheme(), "http" | "https"),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
let remote = remote.rsplit_once('@').map_or(remote, |(_, rest)| rest);
|
|
||||||
let (host, path) = remote.split_once(':')?;
|
|
||||||
(host.into(), path.trim_matches('/').into(), false)
|
|
||||||
};
|
|
||||||
if !server_host.eq_ignore_ascii_case(&remote_host) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let prefix = server.path().trim_matches('/');
|
|
||||||
if is_http && !prefix.is_empty() {
|
|
||||||
remote_path = remote_path
|
|
||||||
.strip_prefix(prefix)?
|
|
||||||
.strip_prefix('/')?
|
|
||||||
.to_owned();
|
|
||||||
}
|
|
||||||
let parts: Vec<_> = remote_path
|
|
||||||
.split('/')
|
|
||||||
.filter(|part| !part.is_empty())
|
|
||||||
.collect();
|
|
||||||
let [.., owner, repository] = parts.as_slice() else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
let repository = repository.strip_suffix(".git").unwrap_or(repository);
|
|
||||||
(!owner.is_empty() && !repository.is_empty()).then(|| RepositoryScope {
|
|
||||||
owner: (*owner).into(),
|
|
||||||
repository: repository.into(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn config_round_trip_and_remote_scope() {
|
|
||||||
let directory = env::temp_dir().join(format!(
|
|
||||||
"gotcha-config-test-{}-{}",
|
|
||||||
process::id(),
|
|
||||||
SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_nanos()
|
|
||||||
));
|
|
||||||
let path = directory.join("config");
|
|
||||||
let mut config = Config {
|
|
||||||
path: path.clone(),
|
|
||||||
..Config::default()
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
config.select(None, None).err().unwrap(),
|
|
||||||
"no servers configured; run `gotcha auth login SERVER`"
|
|
||||||
);
|
|
||||||
config.login("code.example", "secret").unwrap();
|
|
||||||
|
|
||||||
let loaded = Config::load_from(path).unwrap();
|
|
||||||
assert_eq!(loaded.servers["code.example"].token, "secret");
|
|
||||||
assert!(
|
|
||||||
fs::read_to_string(&loaded.path)
|
|
||||||
.unwrap()
|
|
||||||
.contains("token: secret")
|
|
||||||
);
|
|
||||||
let scope = repository_scope(
|
|
||||||
"https://code.example/gitea",
|
|
||||||
"https://code.example/gitea/alice/project.git",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
(scope.owner.as_str(), scope.repository.as_str()),
|
|
||||||
("alice", "project")
|
|
||||||
);
|
|
||||||
#[cfg(unix)]
|
|
||||||
assert_eq!(
|
|
||||||
fs::metadata(&loaded.path).unwrap().permissions().mode() & 0o777,
|
|
||||||
0o600
|
|
||||||
);
|
|
||||||
|
|
||||||
fs::remove_dir_all(directory).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
mod actions;
|
||||||
mod config;
|
mod config;
|
||||||
mod work_items;
|
mod work_items;
|
||||||
|
|
||||||
use std::{env, error::Error, process};
|
use std::{env, error::Error, process};
|
||||||
|
|
||||||
use config::{Config, RepositoryScope, Selection, server_url};
|
use config::{Config, RepositoryScope, Selection, server_url};
|
||||||
use gotcha_gitea::{Client, Method, Repository, ServerVersion, User};
|
use gotcha_gitea::{Client, Method, Provider, Repository, ServerVersion, User};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
const ROOT_HELP: &str = "\
|
const ROOT_HELP: &str = "\
|
||||||
@@ -15,13 +16,14 @@ Usage:
|
|||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
auth Manage server authentication
|
auth Manage server authentication
|
||||||
server Inspect the selected Gitea server
|
server Inspect the selected Gitea or Forgejo server
|
||||||
user Work with the authenticated user
|
user Work with the authenticated user
|
||||||
repo Work with repositories
|
repo Work with repositories
|
||||||
issue Work with issues and comments
|
issue Work with issues and comments
|
||||||
milestone Work with repository milestones
|
milestone Work with repository milestones
|
||||||
pull Work with pull requests
|
pull Work with pull requests
|
||||||
api Send a raw Gitea API request
|
action Work with Actions workflows and runs
|
||||||
|
api Send a raw server API request
|
||||||
|
|
||||||
Run `gotcha COMMAND` to list that command's subcommands.
|
Run `gotcha COMMAND` to list that command's subcommands.
|
||||||
|
|
||||||
@@ -33,7 +35,8 @@ const AUTH_HELP: &str = "\
|
|||||||
Usage: gotcha auth SUBCOMMAND
|
Usage: gotcha auth SUBCOMMAND
|
||||||
|
|
||||||
Subcommands:
|
Subcommands:
|
||||||
login SERVER Verify and store a token read from standard input
|
login SERVER [--provider gitea|forgejo]
|
||||||
|
Verify and store a token read from standard input
|
||||||
list List configured servers without showing tokens
|
list List configured servers without showing tokens
|
||||||
status Show the selected server and repository scope
|
status Show the selected server and repository scope
|
||||||
logout SERVER Remove a stored server and token";
|
logout SERVER Remove a stored server and token";
|
||||||
@@ -42,7 +45,7 @@ const SERVER_HELP: &str = "\
|
|||||||
Usage: gotcha server SUBCOMMAND
|
Usage: gotcha server SUBCOMMAND
|
||||||
|
|
||||||
Subcommands:
|
Subcommands:
|
||||||
version Show the selected Gitea server version";
|
version Show the selected server version";
|
||||||
|
|
||||||
const USER_HELP: &str = "\
|
const USER_HELP: &str = "\
|
||||||
Usage: gotcha user SUBCOMMAND
|
Usage: gotcha user SUBCOMMAND
|
||||||
@@ -82,24 +85,26 @@ async fn run() -> Result<(), Box<dyn Error>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut config = Config::load()?;
|
let mut config = Config::load()?;
|
||||||
match args.command.as_slice() {
|
if let Some((name, fallback)) = login_command(&args.command)? {
|
||||||
[domain, command, name] if domain == "auth" && command == "login" => {
|
let url = server_url(name)?;
|
||||||
let url = server_url(name)?;
|
let token = rpassword::prompt_password("Token: ")?;
|
||||||
let token = rpassword::prompt_password("Token: ")?;
|
if token.is_empty() {
|
||||||
if token.is_empty() {
|
return Err("token must not be empty".into());
|
||||||
return Err("token must not be empty".into());
|
|
||||||
}
|
|
||||||
let user = Client::new(&url, Some(&token))?.current_user().await?;
|
|
||||||
config.login(name, &token)?;
|
|
||||||
println!(
|
|
||||||
"Logged in to {url} as {} ({name}).",
|
|
||||||
user.login.as_deref().unwrap_or("unknown")
|
|
||||||
);
|
|
||||||
return Ok(());
|
|
||||||
}
|
}
|
||||||
|
let client = Client::discover(&url, Some(&token), fallback).await?;
|
||||||
|
let user = client.current_user().await?;
|
||||||
|
config.login(name, &token, client.provider())?;
|
||||||
|
println!(
|
||||||
|
"Logged in to {url} as {} ({name}, {}).",
|
||||||
|
user.login.as_deref().unwrap_or("unknown"),
|
||||||
|
client.provider()
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
match args.command.as_slice() {
|
||||||
[domain, command] if domain == "auth" && command == "list" => {
|
[domain, command] if domain == "auth" && command == "list" => {
|
||||||
for (name, server) in &config.servers {
|
for (name, server) in &config.servers {
|
||||||
println!("{name}\t{}", server.url);
|
println!("{name}\t{}\t{}", server.provider, server.url);
|
||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -118,7 +123,14 @@ async fn run() -> Result<(), Box<dyn Error>> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let client = Client::new(&selection.url, selection.token.as_deref())?;
|
let client = Client::with_provider(
|
||||||
|
&selection.url,
|
||||||
|
selection.token.as_deref(),
|
||||||
|
selection.provider,
|
||||||
|
)?;
|
||||||
|
if actions::run(&args.command, &selection, &client).await? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
if work_items::run(&args.command, &selection, &client).await? {
|
if work_items::run(&args.command, &selection, &client).await? {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -160,6 +172,20 @@ fn print_version(version: &ServerVersion) {
|
|||||||
println!("{}", version.version.as_deref().unwrap_or("unknown"));
|
println!("{}", version.version.as_deref().unwrap_or("unknown"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn login_command(command: &[String]) -> Result<Option<(&str, Provider)>, Box<dyn Error>> {
|
||||||
|
match command {
|
||||||
|
[domain, action, name] if domain == "auth" && action == "login" => {
|
||||||
|
Ok(Some((name, Provider::Gitea)))
|
||||||
|
}
|
||||||
|
[domain, action, name, option, provider]
|
||||||
|
if domain == "auth" && action == "login" && option == "--provider" =>
|
||||||
|
{
|
||||||
|
Ok(Some((name, provider.parse()?)))
|
||||||
|
}
|
||||||
|
_ => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn print_user(user: &User) {
|
fn print_user(user: &User) {
|
||||||
field("login", user.login.as_deref());
|
field("login", user.login.as_deref());
|
||||||
field("name", user.full_name.as_deref());
|
field("name", user.full_name.as_deref());
|
||||||
@@ -169,20 +195,30 @@ fn print_user(user: &User) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn print_repositories(repositories: &[Repository]) {
|
fn print_repositories(repositories: &[Repository]) {
|
||||||
println!("REPOSITORY\tVISIBILITY\tUPDATED\tDESCRIPTION");
|
print_table(
|
||||||
for repository in repositories {
|
&[
|
||||||
println!(
|
("REPOSITORY", 40),
|
||||||
"{}\t{}\t{}\t{}",
|
("VISIBILITY", 10),
|
||||||
repository.full_name.as_deref().unwrap_or("unknown"),
|
("UPDATED", 25),
|
||||||
if repository.private.unwrap_or(false) {
|
("DESCRIPTION", 60),
|
||||||
"private"
|
],
|
||||||
} else {
|
repositories
|
||||||
"public"
|
.iter()
|
||||||
},
|
.map(|repository| {
|
||||||
repository.updated_at.as_deref().unwrap_or("-"),
|
vec![
|
||||||
repository.description.as_deref().unwrap_or("")
|
repository.full_name.as_deref().unwrap_or("unknown").into(),
|
||||||
);
|
if repository.private.unwrap_or(false) {
|
||||||
}
|
"private"
|
||||||
|
} else {
|
||||||
|
"public"
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
repository.updated_at.as_deref().unwrap_or("-").into(),
|
||||||
|
repository.description.as_deref().unwrap_or("").into(),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_repository(repository: &Repository) {
|
fn print_repository(repository: &Repository) {
|
||||||
@@ -216,6 +252,93 @@ fn print_json(value: &Value) -> Result<(), serde_json::Error> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn print_table(columns: &[(&str, usize)], rows: Vec<Vec<String>>) {
|
||||||
|
println!("{}", format_table(columns, &rows, terminal_width()));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_table(columns: &[(&str, usize)], rows: &[Vec<String>], width: usize) -> String {
|
||||||
|
let minimums = columns
|
||||||
|
.iter()
|
||||||
|
.map(|(header, _)| header.chars().count())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut widths = minimums.clone();
|
||||||
|
for (column, (_, maximum)) in columns.iter().enumerate() {
|
||||||
|
widths[column] = rows
|
||||||
|
.iter()
|
||||||
|
.filter_map(|row| row.get(column))
|
||||||
|
.map(|value| value.chars().count())
|
||||||
|
.max()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.max(widths[column])
|
||||||
|
.min(*maximum);
|
||||||
|
}
|
||||||
|
let separator_width = columns.len().saturating_sub(1) * 2;
|
||||||
|
while widths.iter().sum::<usize>() + separator_width > width {
|
||||||
|
let Some(column) = widths
|
||||||
|
.iter()
|
||||||
|
.zip(&minimums)
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, (current, minimum))| current > minimum)
|
||||||
|
.max_by_key(|(_, (current, minimum))| *current - *minimum)
|
||||||
|
.map(|(column, _)| column)
|
||||||
|
else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
widths[column] -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut lines = Vec::with_capacity(rows.len() + 2);
|
||||||
|
lines.push(table_row(
|
||||||
|
&columns
|
||||||
|
.iter()
|
||||||
|
.map(|(header, _)| (*header).to_owned())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
&widths,
|
||||||
|
));
|
||||||
|
lines.push(
|
||||||
|
widths
|
||||||
|
.iter()
|
||||||
|
.map(|width| "-".repeat(*width))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" "),
|
||||||
|
);
|
||||||
|
lines.extend(rows.iter().map(|row| table_row(row, &widths)));
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn table_row(values: &[String], widths: &[usize]) -> String {
|
||||||
|
values
|
||||||
|
.iter()
|
||||||
|
.zip(widths)
|
||||||
|
.map(|(value, width)| table_cell(value, *width))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
.trim_end()
|
||||||
|
.to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn table_cell(value: &str, width: usize) -> String {
|
||||||
|
let value = value.lines().next().unwrap_or_default();
|
||||||
|
let length = value.chars().count();
|
||||||
|
if length <= width {
|
||||||
|
return format!("{value}{}", " ".repeat(width - length));
|
||||||
|
}
|
||||||
|
let mut shortened = value
|
||||||
|
.chars()
|
||||||
|
.take(width.saturating_sub(1))
|
||||||
|
.collect::<String>();
|
||||||
|
shortened.push('…');
|
||||||
|
shortened
|
||||||
|
}
|
||||||
|
|
||||||
|
fn terminal_width() -> usize {
|
||||||
|
env::var("COLUMNS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.parse().ok())
|
||||||
|
.filter(|width| *width >= 40)
|
||||||
|
.unwrap_or(100)
|
||||||
|
}
|
||||||
|
|
||||||
fn requested_help(command: &[String]) -> Result<Option<&'static str>, String> {
|
fn requested_help(command: &[String]) -> Result<Option<&'static str>, String> {
|
||||||
match command {
|
match command {
|
||||||
[] => Ok(Some(ROOT_HELP)),
|
[] => Ok(Some(ROOT_HELP)),
|
||||||
@@ -250,14 +373,14 @@ fn domain_help(domain: &str) -> Option<&'static str> {
|
|||||||
"user" => Some(USER_HELP),
|
"user" => Some(USER_HELP),
|
||||||
"repo" => Some(REPO_HELP),
|
"repo" => Some(REPO_HELP),
|
||||||
"api" => Some(API_HELP),
|
"api" => Some(API_HELP),
|
||||||
_ => work_items::domain_help(domain),
|
_ => actions::domain_help(domain).or_else(|| work_items::domain_help(domain)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn subcommand_help(domain: &str, subcommand: &str) -> Option<&'static str> {
|
fn subcommand_help(domain: &str, subcommand: &str) -> Option<&'static str> {
|
||||||
match (domain, subcommand) {
|
match (domain, subcommand) {
|
||||||
("auth", "login") => Some(
|
("auth", "login") => Some(
|
||||||
"Usage: gotcha auth login SERVER\n\nPrompts for a token, verifies it, and stores the server in the YAML config.",
|
"Usage: gotcha auth login SERVER [--provider gitea|forgejo]\n\nPrompts for a token, discovers and verifies the provider, and stores the server in the YAML config.",
|
||||||
),
|
),
|
||||||
("auth", "list") => {
|
("auth", "list") => {
|
||||||
Some("Usage: gotcha auth list\n\nLists configured servers without tokens.")
|
Some("Usage: gotcha auth list\n\nLists configured servers without tokens.")
|
||||||
@@ -268,7 +391,7 @@ fn subcommand_help(domain: &str, subcommand: &str) -> Option<&'static str> {
|
|||||||
("auth", "logout") => Some(
|
("auth", "logout") => Some(
|
||||||
"Usage: gotcha auth logout SERVER\n\nRemoves the server and its token from the YAML config.",
|
"Usage: gotcha auth logout SERVER\n\nRemoves the server and its token from the YAML config.",
|
||||||
),
|
),
|
||||||
("server", "version") => Some("Usage: gotcha server version\n\nShows the Gitea version."),
|
("server", "version") => Some("Usage: gotcha server version\n\nShows the server version."),
|
||||||
("user", "show") => Some("Usage: gotcha user show\n\nShows the authenticated user."),
|
("user", "show") => Some("Usage: gotcha user show\n\nShows the authenticated user."),
|
||||||
("repo", "list") => Some(
|
("repo", "list") => Some(
|
||||||
"Usage: gotcha repo list\n\nLists repositories belonging to the authenticated user.",
|
"Usage: gotcha repo list\n\nLists repositories belonging to the authenticated user.",
|
||||||
@@ -279,7 +402,8 @@ fn subcommand_help(domain: &str, subcommand: &str) -> Option<&'static str> {
|
|||||||
("api", "request") => Some(
|
("api", "request") => Some(
|
||||||
"Usage: gotcha api request METHOD ENDPOINT [JSON]\n\nSends an API-relative request using the selected server and token.",
|
"Usage: gotcha api request METHOD ENDPOINT [JSON]\n\nSends an API-relative request using the selected server and token.",
|
||||||
),
|
),
|
||||||
_ => work_items::subcommand_help(domain, subcommand),
|
_ => actions::subcommand_help(domain, subcommand)
|
||||||
|
.or_else(|| work_items::subcommand_help(domain, subcommand)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,6 +429,7 @@ fn print_status(selection: &Selection) {
|
|||||||
"no"
|
"no"
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
println!("provider: {}", selection.provider);
|
||||||
if let Some(scope) = &selection.repository {
|
if let Some(scope) = &selection.repository {
|
||||||
println!("repository: {}/{}", scope.owner, scope.repository);
|
println!("repository: {}/{}", scope.owner, scope.repository);
|
||||||
}
|
}
|
||||||
@@ -366,31 +491,4 @@ impl Args {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests;
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_server_selection_and_repository_scope() {
|
|
||||||
let args = Args::parse(
|
|
||||||
["--server", "work", "repo", "show"].map(str::to_owned),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let repository = RepositoryScope::parse("alice/project").unwrap();
|
|
||||||
|
|
||||||
assert_eq!(args.server.as_deref(), Some("work"));
|
|
||||||
assert_eq!(args.command, ["repo", "show"]);
|
|
||||||
assert_eq!(
|
|
||||||
(repository.owner.as_str(), repository.repository.as_str()),
|
|
||||||
("alice", "project")
|
|
||||||
);
|
|
||||||
assert_eq!(requested_help(&[]).unwrap(), Some(ROOT_HELP));
|
|
||||||
assert_eq!(requested_help(&["repo".into()]).unwrap(), Some(REPO_HELP));
|
|
||||||
assert!(
|
|
||||||
requested_help(&["repo".into(), "show".into(), "--help".into()])
|
|
||||||
.unwrap()
|
|
||||||
.unwrap()
|
|
||||||
.starts_with("Usage: gotcha repo show")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
65
crates/cli/src/tests.rs
Normal file
65
crates/cli/src/tests.rs
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_server_selection_and_repository_scope() {
|
||||||
|
let args = Args::parse(
|
||||||
|
["--server", "work", "repo", "show"].map(str::to_owned),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let repository = RepositoryScope::parse("alice/project").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(args.server.as_deref(), Some("work"));
|
||||||
|
assert_eq!(args.command, ["repo", "show"]);
|
||||||
|
assert_eq!(
|
||||||
|
(repository.owner.as_str(), repository.repository.as_str()),
|
||||||
|
("alice", "project")
|
||||||
|
);
|
||||||
|
assert_eq!(requested_help(&[]).unwrap(), Some(ROOT_HELP));
|
||||||
|
assert_eq!(requested_help(&["repo".into()]).unwrap(), Some(REPO_HELP));
|
||||||
|
assert!(
|
||||||
|
requested_help(&["repo".into(), "show".into(), "--help".into()])
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("Usage: gotcha repo show")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
login_command(&["auth".into(), "login".into(), "code.example".into()])
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.1,
|
||||||
|
Provider::Gitea
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
login_command(&[
|
||||||
|
"auth".into(),
|
||||||
|
"login".into(),
|
||||||
|
"code.example".into(),
|
||||||
|
"--provider".into(),
|
||||||
|
"forgejo".into(),
|
||||||
|
])
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.1,
|
||||||
|
Provider::Forgejo
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn formats_bounded_aligned_tables_without_tabs() {
|
||||||
|
let table = format_table(
|
||||||
|
&[("INDEX", 8), ("STATE", 10), ("TITLE", 60)],
|
||||||
|
&[vec![
|
||||||
|
"22".into(),
|
||||||
|
"open".into(),
|
||||||
|
"A deliberately long issue title that must be shortened".into(),
|
||||||
|
]],
|
||||||
|
40,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(!table.contains('\t'));
|
||||||
|
assert!(table.contains('…'));
|
||||||
|
assert_eq!(table.lines().count(), 3);
|
||||||
|
assert!(table.lines().all(|line| line.chars().count() <= 40));
|
||||||
|
assert!(table.lines().next().unwrap().starts_with("INDEX STATE"));
|
||||||
|
}
|
||||||
@@ -1,320 +1,165 @@
|
|||||||
use std::{error::Error, io::Read};
|
use std::{error::Error, io::Read};
|
||||||
|
|
||||||
use gotcha_gitea::{
|
use gotcha_gitea::{
|
||||||
Client, apis,
|
Client, CreateIssue, EditIssue, IssueQuery,
|
||||||
models::{
|
models::{
|
||||||
ChangedFile, Comment, Commit, CreateIssueCommentOption, CreateIssueOption,
|
CreateMilestoneOption, CreatePullRequestOption, EditMilestoneOption, EditPullRequestOption,
|
||||||
CreateMilestoneOption, CreatePullRequestOption, EditIssueOption, EditMilestoneOption,
|
MergePullRequestOption,
|
||||||
EditPullRequestOption, Issue, MergePullRequestOption, Milestone, PullRequest, PullReview,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
|
|
||||||
use crate::config::{RepositoryScope, Selection};
|
use crate::config::{RepositoryScope, Selection};
|
||||||
|
|
||||||
const ISSUE_HELP: &str = "\
|
|
||||||
Usage: gotcha issue SUBCOMMAND
|
|
||||||
|
|
||||||
Subcommands:
|
|
||||||
list [OWNER/REPOSITORY] List issues
|
|
||||||
show INDEX [OWNER/REPOSITORY] Show an issue
|
|
||||||
create [OWNER/REPOSITORY] Create from CreateIssueOption YAML on stdin
|
|
||||||
edit INDEX [OWNER/REPOSITORY] Edit from EditIssueOption YAML on stdin
|
|
||||||
delete INDEX [OWNER/REPOSITORY] Delete an issue
|
|
||||||
comments INDEX [OWNER/REPOSITORY]
|
|
||||||
List comments
|
|
||||||
comment INDEX [OWNER/REPOSITORY] Add a comment read as text from stdin";
|
|
||||||
|
|
||||||
const MILESTONE_HELP: &str = "\
|
|
||||||
Usage: gotcha milestone SUBCOMMAND
|
|
||||||
|
|
||||||
Subcommands:
|
|
||||||
list [OWNER/REPOSITORY] List milestones
|
|
||||||
show ID [OWNER/REPOSITORY] Show a milestone
|
|
||||||
create [OWNER/REPOSITORY] Create from CreateMilestoneOption YAML on stdin
|
|
||||||
edit ID [OWNER/REPOSITORY] Edit from EditMilestoneOption YAML on stdin
|
|
||||||
delete ID [OWNER/REPOSITORY] Delete a milestone";
|
|
||||||
|
|
||||||
const PULL_HELP: &str = "\
|
|
||||||
Usage: gotcha pull SUBCOMMAND
|
|
||||||
|
|
||||||
Subcommands:
|
|
||||||
list [OWNER/REPOSITORY] List pull requests
|
|
||||||
show INDEX [OWNER/REPOSITORY] Show a pull request
|
|
||||||
create [OWNER/REPOSITORY] Create from CreatePullRequestOption YAML on stdin
|
|
||||||
edit INDEX [OWNER/REPOSITORY] Edit from EditPullRequestOption YAML on stdin
|
|
||||||
merge INDEX [OWNER/REPOSITORY] Merge from MergePullRequestOption YAML on stdin
|
|
||||||
commits INDEX [OWNER/REPOSITORY] List commits
|
|
||||||
files INDEX [OWNER/REPOSITORY] List changed files
|
|
||||||
reviews INDEX [OWNER/REPOSITORY] List reviews";
|
|
||||||
|
|
||||||
pub fn domain_help(domain: &str) -> Option<&'static str> {
|
|
||||||
match domain {
|
|
||||||
"issue" => Some(ISSUE_HELP),
|
|
||||||
"milestone" => Some(MILESTONE_HELP),
|
|
||||||
"pull" => Some(PULL_HELP),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn subcommand_help(domain: &str, command: &str) -> Option<&'static str> {
|
|
||||||
match (domain, command) {
|
|
||||||
("issue", "list") => Some("Usage: gotcha issue list [OWNER/REPOSITORY]"),
|
|
||||||
("issue", "show") => Some("Usage: gotcha issue show INDEX [OWNER/REPOSITORY]"),
|
|
||||||
("issue", "create") => Some(
|
|
||||||
"Usage: gotcha issue create [OWNER/REPOSITORY] < issue.yaml\n\nReads CreateIssueOption YAML from stdin, for example:\n title: Fix the bug\n body: Reproduction steps",
|
|
||||||
),
|
|
||||||
("issue", "edit") => Some(
|
|
||||||
"Usage: gotcha issue edit INDEX [OWNER/REPOSITORY] < issue.yaml\n\nReads EditIssueOption YAML from stdin.",
|
|
||||||
),
|
|
||||||
("issue", "delete") => Some("Usage: gotcha issue delete INDEX [OWNER/REPOSITORY]"),
|
|
||||||
("issue", "comments") => Some("Usage: gotcha issue comments INDEX [OWNER/REPOSITORY]"),
|
|
||||||
("issue", "comment") => Some(
|
|
||||||
"Usage: gotcha issue comment INDEX [OWNER/REPOSITORY] < comment.txt\n\nReads the comment body as text from stdin.",
|
|
||||||
),
|
|
||||||
("milestone", "list") => Some("Usage: gotcha milestone list [OWNER/REPOSITORY]"),
|
|
||||||
("milestone", "show") => Some("Usage: gotcha milestone show ID [OWNER/REPOSITORY]"),
|
|
||||||
("milestone", "create") => Some(
|
|
||||||
"Usage: gotcha milestone create [OWNER/REPOSITORY] < milestone.yaml\n\nReads CreateMilestoneOption YAML from stdin, for example:\n title: Version 1.0\n due_on: 2026-09-01T00:00:00Z",
|
|
||||||
),
|
|
||||||
("milestone", "edit") => Some(
|
|
||||||
"Usage: gotcha milestone edit ID [OWNER/REPOSITORY] < milestone.yaml\n\nReads EditMilestoneOption YAML from stdin.",
|
|
||||||
),
|
|
||||||
("milestone", "delete") => Some("Usage: gotcha milestone delete ID [OWNER/REPOSITORY]"),
|
|
||||||
("pull", "list") => Some("Usage: gotcha pull list [OWNER/REPOSITORY]"),
|
|
||||||
("pull", "show") => Some("Usage: gotcha pull show INDEX [OWNER/REPOSITORY]"),
|
|
||||||
("pull", "create") => Some(
|
|
||||||
"Usage: gotcha pull create [OWNER/REPOSITORY] < pull.yaml\n\nReads CreatePullRequestOption YAML from stdin, for example:\n title: Add feature\n head: feature\n base: main",
|
|
||||||
),
|
|
||||||
("pull", "edit") => Some(
|
|
||||||
"Usage: gotcha pull edit INDEX [OWNER/REPOSITORY] < pull.yaml\n\nReads EditPullRequestOption YAML from stdin.",
|
|
||||||
),
|
|
||||||
("pull", "merge") => Some(
|
|
||||||
"Usage: gotcha pull merge INDEX [OWNER/REPOSITORY] < merge.yaml\n\nReads MergePullRequestOption YAML from stdin, for example:\n Do: squash\n delete_branch_after_merge: true",
|
|
||||||
),
|
|
||||||
("pull", "commits") => Some("Usage: gotcha pull commits INDEX [OWNER/REPOSITORY]"),
|
|
||||||
("pull", "files") => Some("Usage: gotcha pull files INDEX [OWNER/REPOSITORY]"),
|
|
||||||
("pull", "reviews") => Some("Usage: gotcha pull reviews INDEX [OWNER/REPOSITORY]"),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn run(
|
pub async fn run(
|
||||||
command: &[String],
|
command: &[String],
|
||||||
selection: &Selection,
|
selection: &Selection,
|
||||||
client: &Client,
|
client: &Client,
|
||||||
) -> Result<bool, Box<dyn Error>> {
|
) -> Result<bool, Box<dyn Error>> {
|
||||||
let configuration = client.configuration();
|
|
||||||
match command {
|
match command {
|
||||||
[domain, action] if domain == "issue" && action == "list" => {
|
[domain, action, arguments @ ..] if domain == "issue" && action == "list" => {
|
||||||
let scope = scope(selection, None)?;
|
let options = parse_issue_list(arguments)?;
|
||||||
let issues = apis::issue_api::issue_list_issues(
|
let scope = scope(selection, options.repository.as_deref())?;
|
||||||
&configuration,
|
let issues = client
|
||||||
&scope.owner,
|
.issues(
|
||||||
&scope.repository,
|
&scope,
|
||||||
None,
|
&IssueQuery {
|
||||||
None,
|
state: options.state,
|
||||||
None,
|
labels: options.labels,
|
||||||
None,
|
keyword: options.keyword,
|
||||||
None,
|
kind: options.kind,
|
||||||
None,
|
milestones: options.milestones,
|
||||||
None,
|
from: options.from,
|
||||||
None,
|
until: options.until,
|
||||||
None,
|
author: options.author,
|
||||||
None,
|
assignee: options.assignee,
|
||||||
None,
|
mentions: options.mentions,
|
||||||
None,
|
page: options.page,
|
||||||
)
|
limit: options.limit,
|
||||||
.await?;
|
},
|
||||||
print_issues(&issues);
|
)
|
||||||
}
|
.await?;
|
||||||
[domain, action, repository] if domain == "issue" && action == "list" => {
|
print_issues(&issues.items);
|
||||||
let scope = scope(selection, Some(repository))?;
|
|
||||||
let issues = apis::issue_api::issue_list_issues(
|
|
||||||
&configuration,
|
|
||||||
&scope.owner,
|
|
||||||
&scope.repository,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
print_issues(&issues);
|
|
||||||
}
|
}
|
||||||
[domain, action, index] if domain == "issue" && action == "show" => {
|
[domain, action, index] if domain == "issue" && action == "show" => {
|
||||||
let scope = scope(selection, None)?;
|
let scope = scope(selection, None)?;
|
||||||
print_issue(
|
print_issue(&client.issue(&scope, number(index)?).await?);
|
||||||
&apis::issue_api::issue_get_issue(
|
|
||||||
&configuration,
|
|
||||||
&scope.owner,
|
|
||||||
&scope.repository,
|
|
||||||
number(index)?,
|
|
||||||
)
|
|
||||||
.await?,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
[domain, action, index, repository] if domain == "issue" && action == "show" => {
|
[domain, action, index, repository] if domain == "issue" && action == "show" => {
|
||||||
let scope = scope(selection, Some(repository))?;
|
let scope = scope(selection, Some(repository))?;
|
||||||
print_issue(
|
print_issue(&client.issue(&scope, number(index)?).await?);
|
||||||
&apis::issue_api::issue_get_issue(
|
|
||||||
&configuration,
|
|
||||||
&scope.owner,
|
|
||||||
&scope.repository,
|
|
||||||
number(index)?,
|
|
||||||
)
|
|
||||||
.await?,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
[domain, action] if domain == "issue" && action == "create" => {
|
[domain, action] if domain == "issue" && action == "create" => {
|
||||||
create_issue(&configuration, &scope(selection, None)?).await?;
|
create_issue(client, &scope(selection, None)?).await?;
|
||||||
}
|
}
|
||||||
[domain, action, repository] if domain == "issue" && action == "create" => {
|
[domain, action, repository] if domain == "issue" && action == "create" => {
|
||||||
create_issue(&configuration, &scope(selection, Some(repository))?).await?;
|
create_issue(client, &scope(selection, Some(repository))?).await?;
|
||||||
}
|
}
|
||||||
[domain, action, index] if domain == "issue" && action == "edit" => {
|
[domain, action, arguments @ ..] if domain == "issue" && action == "edit" => {
|
||||||
edit_issue(&configuration, &scope(selection, None)?, number(index)?).await?;
|
let (indexes, repository) = issue_targets(arguments)?;
|
||||||
|
edit_issues(client, &scope(selection, repository.as_deref())?, &indexes).await?;
|
||||||
}
|
}
|
||||||
[domain, action, index, repository] if domain == "issue" && action == "edit" => {
|
[domain, action, arguments @ ..]
|
||||||
edit_issue(
|
if domain == "issue" && matches!(action.as_str(), "close" | "reopen") =>
|
||||||
&configuration,
|
{
|
||||||
&scope(selection, Some(repository))?,
|
let (indexes, repository) = issue_targets(arguments)?;
|
||||||
number(index)?,
|
let scope = scope(selection, repository.as_deref())?;
|
||||||
|
set_issue_state(
|
||||||
|
client,
|
||||||
|
&scope,
|
||||||
|
&indexes,
|
||||||
|
if action == "close" { "closed" } else { "open" },
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
[domain, action, index] if domain == "issue" && action == "delete" => {
|
[domain, action, index] if domain == "issue" && action == "delete" => {
|
||||||
delete_issue(&configuration, &scope(selection, None)?, number(index)?).await?;
|
delete_issue(client, &scope(selection, None)?, number(index)?).await?;
|
||||||
}
|
}
|
||||||
[domain, action, index, repository] if domain == "issue" && action == "delete" => {
|
[domain, action, index, repository] if domain == "issue" && action == "delete" => {
|
||||||
delete_issue(
|
delete_issue(client, &scope(selection, Some(repository))?, number(index)?).await?;
|
||||||
&configuration,
|
|
||||||
&scope(selection, Some(repository))?,
|
|
||||||
number(index)?,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
}
|
}
|
||||||
[domain, action, index] if domain == "issue" && action == "comments" => {
|
[domain, action, index] if domain == "issue" && action == "comments" => {
|
||||||
list_comments(&configuration, &scope(selection, None)?, number(index)?).await?;
|
list_comments(client, &scope(selection, None)?, number(index)?).await?;
|
||||||
}
|
}
|
||||||
[domain, action, index, repository] if domain == "issue" && action == "comments" => {
|
[domain, action, index, repository] if domain == "issue" && action == "comments" => {
|
||||||
list_comments(
|
list_comments(client, &scope(selection, Some(repository))?, number(index)?).await?;
|
||||||
&configuration,
|
|
||||||
&scope(selection, Some(repository))?,
|
|
||||||
number(index)?,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
}
|
}
|
||||||
[domain, action, index] if domain == "issue" && action == "comment" => {
|
[domain, action, index] if domain == "issue" && action == "comment" => {
|
||||||
create_comment(&configuration, &scope(selection, None)?, number(index)?).await?;
|
create_comment(client, &scope(selection, None)?, number(index)?).await?;
|
||||||
}
|
}
|
||||||
[domain, action, index, repository] if domain == "issue" && action == "comment" => {
|
[domain, action, index, repository] if domain == "issue" && action == "comment" => {
|
||||||
create_comment(
|
create_comment(client, &scope(selection, Some(repository))?, number(index)?).await?;
|
||||||
&configuration,
|
|
||||||
&scope(selection, Some(repository))?,
|
|
||||||
number(index)?,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
}
|
}
|
||||||
[domain, action] if domain == "milestone" && action == "list" => {
|
[domain, action] if domain == "milestone" && action == "list" => {
|
||||||
list_milestones(&configuration, &scope(selection, None)?).await?;
|
list_milestones(client, &scope(selection, None)?).await?;
|
||||||
}
|
}
|
||||||
[domain, action, repository] if domain == "milestone" && action == "list" => {
|
[domain, action, repository] if domain == "milestone" && action == "list" => {
|
||||||
list_milestones(&configuration, &scope(selection, Some(repository))?).await?;
|
list_milestones(client, &scope(selection, Some(repository))?).await?;
|
||||||
}
|
}
|
||||||
[domain, action, id] if domain == "milestone" && action == "show" => {
|
[domain, action, id] if domain == "milestone" && action == "show" => {
|
||||||
show_milestone(&configuration, &scope(selection, None)?, id).await?;
|
show_milestone(client, &scope(selection, None)?, id).await?;
|
||||||
}
|
}
|
||||||
[domain, action, id, repository] if domain == "milestone" && action == "show" => {
|
[domain, action, id, repository] if domain == "milestone" && action == "show" => {
|
||||||
show_milestone(&configuration, &scope(selection, Some(repository))?, id).await?;
|
show_milestone(client, &scope(selection, Some(repository))?, id).await?;
|
||||||
}
|
}
|
||||||
[domain, action] if domain == "milestone" && action == "create" => {
|
[domain, action] if domain == "milestone" && action == "create" => {
|
||||||
create_milestone(&configuration, &scope(selection, None)?).await?;
|
create_milestone(client, &scope(selection, None)?).await?;
|
||||||
}
|
}
|
||||||
[domain, action, repository] if domain == "milestone" && action == "create" => {
|
[domain, action, repository] if domain == "milestone" && action == "create" => {
|
||||||
create_milestone(&configuration, &scope(selection, Some(repository))?).await?;
|
create_milestone(client, &scope(selection, Some(repository))?).await?;
|
||||||
}
|
}
|
||||||
[domain, action, id] if domain == "milestone" && action == "edit" => {
|
[domain, action, id] if domain == "milestone" && action == "edit" => {
|
||||||
edit_milestone(&configuration, &scope(selection, None)?, id).await?;
|
edit_milestone(client, &scope(selection, None)?, id).await?;
|
||||||
}
|
}
|
||||||
[domain, action, id, repository] if domain == "milestone" && action == "edit" => {
|
[domain, action, id, repository] if domain == "milestone" && action == "edit" => {
|
||||||
edit_milestone(&configuration, &scope(selection, Some(repository))?, id).await?;
|
edit_milestone(client, &scope(selection, Some(repository))?, id).await?;
|
||||||
}
|
}
|
||||||
[domain, action, id] if domain == "milestone" && action == "delete" => {
|
[domain, action, id] if domain == "milestone" && action == "delete" => {
|
||||||
delete_milestone(&configuration, &scope(selection, None)?, id).await?;
|
delete_milestone(client, &scope(selection, None)?, id).await?;
|
||||||
}
|
}
|
||||||
[domain, action, id, repository] if domain == "milestone" && action == "delete" => {
|
[domain, action, id, repository] if domain == "milestone" && action == "delete" => {
|
||||||
delete_milestone(&configuration, &scope(selection, Some(repository))?, id).await?;
|
delete_milestone(client, &scope(selection, Some(repository))?, id).await?;
|
||||||
}
|
}
|
||||||
[domain, action] if domain == "pull" && action == "list" => {
|
[domain, action] if domain == "pull" && action == "list" => {
|
||||||
list_pulls(&configuration, &scope(selection, None)?).await?;
|
list_pulls(client, &scope(selection, None)?).await?;
|
||||||
}
|
}
|
||||||
[domain, action, repository] if domain == "pull" && action == "list" => {
|
[domain, action, repository] if domain == "pull" && action == "list" => {
|
||||||
list_pulls(&configuration, &scope(selection, Some(repository))?).await?;
|
list_pulls(client, &scope(selection, Some(repository))?).await?;
|
||||||
}
|
}
|
||||||
[domain, action, index] if domain == "pull" && action == "show" => {
|
[domain, action, index] if domain == "pull" && action == "show" => {
|
||||||
show_pull(&configuration, &scope(selection, None)?, number(index)?).await?;
|
show_pull(client, &scope(selection, None)?, number(index)?).await?;
|
||||||
}
|
}
|
||||||
[domain, action, index, repository] if domain == "pull" && action == "show" => {
|
[domain, action, index, repository] if domain == "pull" && action == "show" => {
|
||||||
show_pull(
|
show_pull(client, &scope(selection, Some(repository))?, number(index)?).await?;
|
||||||
&configuration,
|
|
||||||
&scope(selection, Some(repository))?,
|
|
||||||
number(index)?,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
}
|
}
|
||||||
[domain, action] if domain == "pull" && action == "create" => {
|
[domain, action] if domain == "pull" && action == "create" => {
|
||||||
create_pull(&configuration, &scope(selection, None)?).await?;
|
create_pull(client, &scope(selection, None)?).await?;
|
||||||
}
|
}
|
||||||
[domain, action, repository] if domain == "pull" && action == "create" => {
|
[domain, action, repository] if domain == "pull" && action == "create" => {
|
||||||
create_pull(&configuration, &scope(selection, Some(repository))?).await?;
|
create_pull(client, &scope(selection, Some(repository))?).await?;
|
||||||
}
|
}
|
||||||
[domain, action, index] if domain == "pull" && action == "edit" => {
|
[domain, action, index] if domain == "pull" && action == "edit" => {
|
||||||
edit_pull(&configuration, &scope(selection, None)?, number(index)?).await?;
|
edit_pull(client, &scope(selection, None)?, number(index)?).await?;
|
||||||
}
|
}
|
||||||
[domain, action, index, repository] if domain == "pull" && action == "edit" => {
|
[domain, action, index, repository] if domain == "pull" && action == "edit" => {
|
||||||
edit_pull(
|
edit_pull(client, &scope(selection, Some(repository))?, number(index)?).await?;
|
||||||
&configuration,
|
|
||||||
&scope(selection, Some(repository))?,
|
|
||||||
number(index)?,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
}
|
}
|
||||||
[domain, action, index] if domain == "pull" && action == "merge" => {
|
[domain, action, index] if domain == "pull" && action == "merge" => {
|
||||||
merge_pull(&configuration, &scope(selection, None)?, number(index)?).await?;
|
merge_pull(client, &scope(selection, None)?, number(index)?).await?;
|
||||||
}
|
}
|
||||||
[domain, action, index, repository] if domain == "pull" && action == "merge" => {
|
[domain, action, index, repository] if domain == "pull" && action == "merge" => {
|
||||||
merge_pull(
|
merge_pull(client, &scope(selection, Some(repository))?, number(index)?).await?;
|
||||||
&configuration,
|
|
||||||
&scope(selection, Some(repository))?,
|
|
||||||
number(index)?,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
}
|
}
|
||||||
[domain, action, index]
|
[domain, action, index]
|
||||||
if domain == "pull" && matches!(action.as_str(), "commits" | "files" | "reviews") =>
|
if domain == "pull" && matches!(action.as_str(), "commits" | "files" | "reviews") =>
|
||||||
{
|
{
|
||||||
pull_details(
|
pull_details(client, &scope(selection, None)?, number(index)?, action).await?;
|
||||||
&configuration,
|
|
||||||
&scope(selection, None)?,
|
|
||||||
number(index)?,
|
|
||||||
action,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
}
|
}
|
||||||
[domain, action, index, repository]
|
[domain, action, index, repository]
|
||||||
if domain == "pull" && matches!(action.as_str(), "commits" | "files" | "reviews") =>
|
if domain == "pull" && matches!(action.as_str(), "commits" | "files" | "reviews") =>
|
||||||
{
|
{
|
||||||
pull_details(
|
pull_details(
|
||||||
&configuration,
|
client,
|
||||||
&scope(selection, Some(repository))?,
|
&scope(selection, Some(repository))?,
|
||||||
number(index)?,
|
number(index)?,
|
||||||
action,
|
action,
|
||||||
@@ -363,491 +208,205 @@ fn parse_yaml<T: DeserializeOwned>(input: &str) -> Result<T, Box<dyn Error>> {
|
|||||||
Ok(serde_yaml::from_str(input)?)
|
Ok(serde_yaml::from_str(input)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_issue(
|
async fn create_issue(client: &Client, scope: &RepositoryScope) -> Result<(), Box<dyn Error>> {
|
||||||
configuration: &apis::configuration::Configuration,
|
let input: CreateIssueInput = read_yaml()?;
|
||||||
scope: &RepositoryScope,
|
|
||||||
) -> Result<(), Box<dyn Error>> {
|
|
||||||
let body: CreateIssueOption = read_yaml()?;
|
|
||||||
if body.title.trim().is_empty() {
|
|
||||||
return Err("issue title must not be empty".into());
|
|
||||||
}
|
|
||||||
print_issue(
|
print_issue(
|
||||||
&apis::issue_api::issue_create_issue(
|
&client
|
||||||
configuration,
|
.create_issue(
|
||||||
&scope.owner,
|
scope,
|
||||||
&scope.repository,
|
CreateIssue {
|
||||||
Some(body),
|
option: input.issue,
|
||||||
)
|
label_names: input.label_names,
|
||||||
.await?,
|
milestone_name: input.milestone_name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?,
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn edit_issue(
|
async fn edit_issues(
|
||||||
configuration: &apis::configuration::Configuration,
|
client: &Client,
|
||||||
scope: &RepositoryScope,
|
scope: &RepositoryScope,
|
||||||
index: i64,
|
indexes: &[i64],
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
let issue = apis::issue_api::issue_edit_issue(
|
let input: EditIssueInput = read_yaml()?;
|
||||||
configuration,
|
for issue in client
|
||||||
&scope.owner,
|
.edit_issues(
|
||||||
&scope.repository,
|
scope,
|
||||||
index,
|
indexes,
|
||||||
Some(read_yaml::<EditIssueOption>()?),
|
EditIssue {
|
||||||
)
|
option: input.issue,
|
||||||
.await?;
|
replace_labels: None,
|
||||||
print_issue(&issue);
|
add_labels: input.add_labels,
|
||||||
|
remove_labels: input.remove_labels,
|
||||||
|
add_assignees: input.add_assignees,
|
||||||
|
milestone_name: input.milestone_name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
print_issue(&issue);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_issue_state(
|
||||||
|
client: &Client,
|
||||||
|
scope: &RepositoryScope,
|
||||||
|
indexes: &[i64],
|
||||||
|
state: &str,
|
||||||
|
) -> Result<(), Box<dyn Error>> {
|
||||||
|
for issue in client.set_issue_state(scope, indexes, state).await? {
|
||||||
|
print_issue(&issue);
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_issue(
|
async fn delete_issue(
|
||||||
configuration: &apis::configuration::Configuration,
|
client: &Client,
|
||||||
scope: &RepositoryScope,
|
scope: &RepositoryScope,
|
||||||
index: i64,
|
index: i64,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
apis::issue_api::issue_delete(configuration, &scope.owner, &scope.repository, index).await?;
|
client.delete_issue(scope, index).await?;
|
||||||
println!("Deleted issue #{index}.");
|
println!("Deleted issue #{index}.");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_comments(
|
async fn list_comments(
|
||||||
configuration: &apis::configuration::Configuration,
|
client: &Client,
|
||||||
scope: &RepositoryScope,
|
scope: &RepositoryScope,
|
||||||
index: i64,
|
index: i64,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
let comments = apis::issue_api::issue_get_comments(
|
let comments = client.issue_comments(scope, index).await?;
|
||||||
configuration,
|
|
||||||
&scope.owner,
|
|
||||||
&scope.repository,
|
|
||||||
index,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
print_comments(&comments);
|
print_comments(&comments);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_comment(
|
async fn create_comment(
|
||||||
configuration: &apis::configuration::Configuration,
|
client: &Client,
|
||||||
scope: &RepositoryScope,
|
scope: &RepositoryScope,
|
||||||
index: i64,
|
index: i64,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
let body = read_stdin()?.trim_end().to_owned();
|
let body = read_stdin()?.trim_end().to_owned();
|
||||||
let comment = apis::issue_api::issue_create_comment(
|
let comment = client.create_issue_comment(scope, index, body).await?;
|
||||||
configuration,
|
|
||||||
&scope.owner,
|
|
||||||
&scope.repository,
|
|
||||||
index,
|
|
||||||
Some(CreateIssueCommentOption::new(body)),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
print_comments(&[comment]);
|
print_comments(&[comment]);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_milestones(
|
async fn list_milestones(client: &Client, scope: &RepositoryScope) -> Result<(), Box<dyn Error>> {
|
||||||
configuration: &apis::configuration::Configuration,
|
let milestones = client.milestones(scope).await?;
|
||||||
scope: &RepositoryScope,
|
|
||||||
) -> Result<(), Box<dyn Error>> {
|
|
||||||
let milestones = apis::issue_api::issue_get_milestones_list(
|
|
||||||
configuration,
|
|
||||||
&scope.owner,
|
|
||||||
&scope.repository,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
print_milestones(&milestones);
|
print_milestones(&milestones);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn show_milestone(
|
async fn show_milestone(
|
||||||
configuration: &apis::configuration::Configuration,
|
client: &Client,
|
||||||
scope: &RepositoryScope,
|
scope: &RepositoryScope,
|
||||||
id: &str,
|
id: &str,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
let milestone =
|
let milestone = client.milestone(scope, number(id)?).await?;
|
||||||
apis::issue_api::issue_get_milestone(configuration, &scope.owner, &scope.repository, id)
|
|
||||||
.await?;
|
|
||||||
print_milestone(&milestone);
|
print_milestone(&milestone);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_milestone(
|
async fn create_milestone(client: &Client, scope: &RepositoryScope) -> Result<(), Box<dyn Error>> {
|
||||||
configuration: &apis::configuration::Configuration,
|
|
||||||
scope: &RepositoryScope,
|
|
||||||
) -> Result<(), Box<dyn Error>> {
|
|
||||||
let body: CreateMilestoneOption = read_yaml()?;
|
let body: CreateMilestoneOption = read_yaml()?;
|
||||||
if body.title.as_deref().unwrap_or_default().trim().is_empty() {
|
let milestone = client.create_milestone(scope, body).await?;
|
||||||
return Err("milestone title must not be empty".into());
|
|
||||||
}
|
|
||||||
let milestone = apis::issue_api::issue_create_milestone(
|
|
||||||
configuration,
|
|
||||||
&scope.owner,
|
|
||||||
&scope.repository,
|
|
||||||
Some(body),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
print_milestone(&milestone);
|
print_milestone(&milestone);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn edit_milestone(
|
async fn edit_milestone(
|
||||||
configuration: &apis::configuration::Configuration,
|
client: &Client,
|
||||||
scope: &RepositoryScope,
|
scope: &RepositoryScope,
|
||||||
id: &str,
|
id: &str,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
let milestone = apis::issue_api::issue_edit_milestone(
|
let milestone = client
|
||||||
configuration,
|
.edit_milestone(scope, id, read_yaml::<EditMilestoneOption>()?)
|
||||||
&scope.owner,
|
.await?;
|
||||||
&scope.repository,
|
|
||||||
id,
|
|
||||||
Some(read_yaml::<EditMilestoneOption>()?),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
print_milestone(&milestone);
|
print_milestone(&milestone);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_milestone(
|
async fn delete_milestone(
|
||||||
configuration: &apis::configuration::Configuration,
|
client: &Client,
|
||||||
scope: &RepositoryScope,
|
scope: &RepositoryScope,
|
||||||
id: &str,
|
id: &str,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
apis::issue_api::issue_delete_milestone(configuration, &scope.owner, &scope.repository, id)
|
client.delete_milestone(scope, id).await?;
|
||||||
.await?;
|
|
||||||
println!("Deleted milestone {id}.");
|
println!("Deleted milestone {id}.");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_pulls(
|
async fn list_pulls(client: &Client, scope: &RepositoryScope) -> Result<(), Box<dyn Error>> {
|
||||||
configuration: &apis::configuration::Configuration,
|
print_pulls(&client.repository_pulls(scope, "open", 1, 30).await?.items);
|
||||||
scope: &RepositoryScope,
|
|
||||||
) -> Result<(), Box<dyn Error>> {
|
|
||||||
let pulls = apis::repository_api::repo_list_pull_requests(
|
|
||||||
configuration,
|
|
||||||
&scope.owner,
|
|
||||||
&scope.repository,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
print_pulls(&pulls);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn show_pull(
|
async fn show_pull(
|
||||||
configuration: &apis::configuration::Configuration,
|
client: &Client,
|
||||||
scope: &RepositoryScope,
|
scope: &RepositoryScope,
|
||||||
index: i64,
|
index: i64,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
let pull = apis::repository_api::repo_get_pull_request(
|
let pull = client.pull(scope, index).await?;
|
||||||
configuration,
|
|
||||||
&scope.owner,
|
|
||||||
&scope.repository,
|
|
||||||
index,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
print_pull(&pull);
|
print_pull(&pull);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_pull(
|
async fn create_pull(client: &Client, scope: &RepositoryScope) -> Result<(), Box<dyn Error>> {
|
||||||
configuration: &apis::configuration::Configuration,
|
|
||||||
scope: &RepositoryScope,
|
|
||||||
) -> Result<(), Box<dyn Error>> {
|
|
||||||
let body: CreatePullRequestOption = read_yaml()?;
|
let body: CreatePullRequestOption = read_yaml()?;
|
||||||
if [
|
let pull = client.create_pull(scope, body).await?;
|
||||||
body.title.as_deref(),
|
|
||||||
body.head.as_deref(),
|
|
||||||
body.base.as_deref(),
|
|
||||||
]
|
|
||||||
.into_iter()
|
|
||||||
.any(|value| value.unwrap_or_default().trim().is_empty())
|
|
||||||
{
|
|
||||||
return Err("pull request title, head, and base must not be empty".into());
|
|
||||||
}
|
|
||||||
let pull = apis::repository_api::repo_create_pull_request(
|
|
||||||
configuration,
|
|
||||||
&scope.owner,
|
|
||||||
&scope.repository,
|
|
||||||
Some(body),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
print_pull(&pull);
|
print_pull(&pull);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn edit_pull(
|
async fn edit_pull(
|
||||||
configuration: &apis::configuration::Configuration,
|
client: &Client,
|
||||||
scope: &RepositoryScope,
|
scope: &RepositoryScope,
|
||||||
index: i64,
|
index: i64,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
let pull = apis::repository_api::repo_edit_pull_request(
|
let pull = client
|
||||||
configuration,
|
.edit_pull(scope, index, read_yaml::<EditPullRequestOption>()?)
|
||||||
&scope.owner,
|
.await?;
|
||||||
&scope.repository,
|
|
||||||
index,
|
|
||||||
Some(read_yaml::<EditPullRequestOption>()?),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
print_pull(&pull);
|
print_pull(&pull);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn merge_pull(
|
async fn merge_pull(
|
||||||
configuration: &apis::configuration::Configuration,
|
client: &Client,
|
||||||
scope: &RepositoryScope,
|
scope: &RepositoryScope,
|
||||||
index: i64,
|
index: i64,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
apis::repository_api::repo_merge_pull_request(
|
client
|
||||||
configuration,
|
.merge_pull(scope, index, read_yaml::<MergePullRequestOption>()?)
|
||||||
&scope.owner,
|
.await?;
|
||||||
&scope.repository,
|
|
||||||
index,
|
|
||||||
Some(read_yaml::<MergePullRequestOption>()?),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
println!("Merged pull request #{index}.");
|
println!("Merged pull request #{index}.");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn pull_details(
|
async fn pull_details(
|
||||||
configuration: &apis::configuration::Configuration,
|
client: &Client,
|
||||||
scope: &RepositoryScope,
|
scope: &RepositoryScope,
|
||||||
index: i64,
|
index: i64,
|
||||||
action: &str,
|
action: &str,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
match action {
|
match action {
|
||||||
"commits" => print_commits(
|
"commits" => print_commits(&client.pull_commits(scope, index).await?),
|
||||||
&apis::repository_api::repo_get_pull_request_commits(
|
"files" => print_files(&client.pull_files(scope, index).await?),
|
||||||
configuration,
|
"reviews" => print_reviews(&client.pull_reviews(scope, index).await?),
|
||||||
&scope.owner,
|
|
||||||
&scope.repository,
|
|
||||||
index,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await?,
|
|
||||||
),
|
|
||||||
"files" => print_files(
|
|
||||||
&apis::repository_api::repo_get_pull_request_files(
|
|
||||||
configuration,
|
|
||||||
&scope.owner,
|
|
||||||
&scope.repository,
|
|
||||||
index,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await?,
|
|
||||||
),
|
|
||||||
"reviews" => print_reviews(
|
|
||||||
&apis::repository_api::repo_list_pull_reviews(
|
|
||||||
configuration,
|
|
||||||
&scope.owner,
|
|
||||||
&scope.repository,
|
|
||||||
index,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await?,
|
|
||||||
),
|
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_issues(issues: &[Issue]) {
|
mod arguments;
|
||||||
println!("INDEX\tSTATE\tTITLE\tUPDATED");
|
mod help;
|
||||||
for issue in issues {
|
mod output;
|
||||||
println!(
|
|
||||||
"{}\t{}\t{}\t{}",
|
|
||||||
issue.number.unwrap_or_default(),
|
|
||||||
issue.state.as_deref().unwrap_or("unknown"),
|
|
||||||
one_line(issue.title.as_deref().unwrap_or("")),
|
|
||||||
issue.updated_at.as_deref().unwrap_or("-")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_issue(issue: &Issue) {
|
use arguments::*;
|
||||||
println!(
|
pub(crate) use help::{domain_help, subcommand_help};
|
||||||
"#{} [{}] {}",
|
use output::*;
|
||||||
issue.number.unwrap_or_default(),
|
|
||||||
issue.state.as_deref().unwrap_or("unknown"),
|
|
||||||
issue.title.as_deref().unwrap_or("")
|
|
||||||
);
|
|
||||||
field("url", issue.html_url.as_deref());
|
|
||||||
field(
|
|
||||||
"author",
|
|
||||||
issue.user.as_deref().and_then(|user| user.login.as_deref()),
|
|
||||||
);
|
|
||||||
field("updated", issue.updated_at.as_deref());
|
|
||||||
body(issue.body.as_deref());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_comments(comments: &[Comment]) {
|
|
||||||
for comment in comments {
|
|
||||||
println!(
|
|
||||||
"{} · {}",
|
|
||||||
comment
|
|
||||||
.user
|
|
||||||
.as_deref()
|
|
||||||
.and_then(|user| user.login.as_deref())
|
|
||||||
.unwrap_or("unknown"),
|
|
||||||
comment.created_at.as_deref().unwrap_or("-")
|
|
||||||
);
|
|
||||||
body(comment.body.as_deref());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_milestones(milestones: &[Milestone]) {
|
|
||||||
println!("ID\tSTATE\tTITLE\tDUE\tOPEN/CLOSED");
|
|
||||||
for milestone in milestones {
|
|
||||||
println!(
|
|
||||||
"{}\t{}\t{}\t{}\t{}/{}",
|
|
||||||
milestone.id.unwrap_or_default(),
|
|
||||||
milestone.state.as_deref().unwrap_or("unknown"),
|
|
||||||
one_line(milestone.title.as_deref().unwrap_or("")),
|
|
||||||
milestone.due_on.as_deref().unwrap_or("-"),
|
|
||||||
milestone.open_issues.unwrap_or_default(),
|
|
||||||
milestone.closed_issues.unwrap_or_default()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_milestone(milestone: &Milestone) {
|
|
||||||
println!(
|
|
||||||
"{} [{}] {}",
|
|
||||||
milestone.id.unwrap_or_default(),
|
|
||||||
milestone.state.as_deref().unwrap_or("unknown"),
|
|
||||||
milestone.title.as_deref().unwrap_or("")
|
|
||||||
);
|
|
||||||
field("due", milestone.due_on.as_deref());
|
|
||||||
println!(
|
|
||||||
"issues: {} open, {} closed",
|
|
||||||
milestone.open_issues.unwrap_or_default(),
|
|
||||||
milestone.closed_issues.unwrap_or_default()
|
|
||||||
);
|
|
||||||
body(milestone.description.as_deref());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_pulls(pulls: &[PullRequest]) {
|
|
||||||
println!("INDEX\tSTATE\tTITLE\tUPDATED");
|
|
||||||
for pull in pulls {
|
|
||||||
println!(
|
|
||||||
"{}\t{}\t{}\t{}",
|
|
||||||
pull.number.unwrap_or_default(),
|
|
||||||
pull.state.as_deref().unwrap_or("unknown"),
|
|
||||||
one_line(pull.title.as_deref().unwrap_or("")),
|
|
||||||
pull.updated_at.as_deref().unwrap_or("-")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_pull(pull: &PullRequest) {
|
|
||||||
println!(
|
|
||||||
"#{} [{}] {}",
|
|
||||||
pull.number.unwrap_or_default(),
|
|
||||||
pull.state.as_deref().unwrap_or("unknown"),
|
|
||||||
pull.title.as_deref().unwrap_or("")
|
|
||||||
);
|
|
||||||
field("url", pull.html_url.as_deref());
|
|
||||||
field(
|
|
||||||
"author",
|
|
||||||
pull.user.as_deref().and_then(|user| user.login.as_deref()),
|
|
||||||
);
|
|
||||||
field("updated", pull.updated_at.as_deref());
|
|
||||||
println!("mergeable: {}", pull.mergeable.unwrap_or(false));
|
|
||||||
body(pull.body.as_deref());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_commits(commits: &[Commit]) {
|
|
||||||
println!("SHA\tMESSAGE");
|
|
||||||
for commit in commits {
|
|
||||||
println!(
|
|
||||||
"{}\t{}",
|
|
||||||
commit.sha.as_deref().unwrap_or("unknown"),
|
|
||||||
one_line(
|
|
||||||
commit
|
|
||||||
.commit
|
|
||||||
.as_deref()
|
|
||||||
.and_then(|commit| commit.message.as_deref())
|
|
||||||
.unwrap_or("")
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_files(files: &[ChangedFile]) {
|
|
||||||
println!("STATUS\tCHANGES\tFILE");
|
|
||||||
for file in files {
|
|
||||||
println!(
|
|
||||||
"{}\t{}\t{}",
|
|
||||||
file.status.as_deref().unwrap_or("unknown"),
|
|
||||||
file.changes.unwrap_or_default(),
|
|
||||||
file.filename.as_deref().unwrap_or("")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_reviews(reviews: &[PullReview]) {
|
|
||||||
println!("ID\tSTATE\tREVIEWER\tSUBMITTED");
|
|
||||||
for review in reviews {
|
|
||||||
println!(
|
|
||||||
"{}\t{}\t{}\t{}",
|
|
||||||
review.id.unwrap_or_default(),
|
|
||||||
review.state.as_deref().unwrap_or("unknown"),
|
|
||||||
review
|
|
||||||
.user
|
|
||||||
.as_deref()
|
|
||||||
.and_then(|user| user.login.as_deref())
|
|
||||||
.unwrap_or("unknown"),
|
|
||||||
review.submitted_at.as_deref().unwrap_or("-")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn field(name: &str, value: Option<&str>) {
|
|
||||||
if let Some(value) = value.filter(|value| !value.is_empty()) {
|
|
||||||
println!("{name}: {value}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn body(value: Option<&str>) {
|
|
||||||
if let Some(value) = value.filter(|value| !value.is_empty()) {
|
|
||||||
println!("\n{value}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn one_line(value: &str) -> &str {
|
|
||||||
value.lines().next().unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests;
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_typed_yaml_and_rejects_bad_indexes() {
|
|
||||||
let issue: CreateIssueOption = parse_yaml("title: Fix it\nbody: Details\n").unwrap();
|
|
||||||
|
|
||||||
assert_eq!(issue.title, "Fix it");
|
|
||||||
assert_eq!(issue.body.as_deref(), Some("Details"));
|
|
||||||
assert!(number("0").is_err());
|
|
||||||
assert!(number("7").is_ok());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
142
crates/cli/src/work_items/arguments.rs
Normal file
142
crates/cli/src/work_items/arguments.rs
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
use std::error::Error;
|
||||||
|
|
||||||
|
use gotcha_gitea::models::{CreateIssueOption, EditIssueOption};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::config::RepositoryScope;
|
||||||
|
|
||||||
|
use super::number;
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
pub(super) struct IssueListOptions {
|
||||||
|
pub(super) repository: Option<String>,
|
||||||
|
pub(super) state: String,
|
||||||
|
pub(super) kind: String,
|
||||||
|
pub(super) keyword: Option<String>,
|
||||||
|
pub(super) labels: Option<String>,
|
||||||
|
pub(super) milestones: Option<String>,
|
||||||
|
pub(super) author: Option<String>,
|
||||||
|
pub(super) assignee: Option<String>,
|
||||||
|
pub(super) mentions: Option<String>,
|
||||||
|
pub(super) from: Option<String>,
|
||||||
|
pub(super) until: Option<String>,
|
||||||
|
pub(super) page: i32,
|
||||||
|
pub(super) limit: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(super) struct CreateIssueInput {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub(super) issue: CreateIssueOption,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(super) label_names: Vec<String>,
|
||||||
|
pub(super) milestone_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize)]
|
||||||
|
pub(super) struct EditIssueInput {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub(super) issue: EditIssueOption,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(super) add_labels: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(super) remove_labels: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(super) add_assignees: Vec<String>,
|
||||||
|
pub(super) milestone_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for IssueListOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
repository: None,
|
||||||
|
state: "open".into(),
|
||||||
|
kind: "issues".into(),
|
||||||
|
keyword: None,
|
||||||
|
labels: None,
|
||||||
|
milestones: None,
|
||||||
|
author: None,
|
||||||
|
assignee: None,
|
||||||
|
mentions: None,
|
||||||
|
from: None,
|
||||||
|
until: None,
|
||||||
|
page: 1,
|
||||||
|
limit: 30,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn parse_issue_list(arguments: &[String]) -> Result<IssueListOptions, Box<dyn Error>> {
|
||||||
|
let mut options = IssueListOptions::default();
|
||||||
|
let mut index = 0;
|
||||||
|
while index < arguments.len() {
|
||||||
|
let argument = arguments[index].as_str();
|
||||||
|
let value = |index: &mut usize| -> Result<String, Box<dyn Error>> {
|
||||||
|
*index += 1;
|
||||||
|
arguments
|
||||||
|
.get(*index)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| format!("{argument} requires a value").into())
|
||||||
|
};
|
||||||
|
match argument {
|
||||||
|
"--state" => options.state = value(&mut index)?,
|
||||||
|
"-K" | "--kind" => options.kind = value(&mut index)?,
|
||||||
|
"-k" | "--keyword" => options.keyword = Some(value(&mut index)?),
|
||||||
|
"-L" | "--labels" => options.labels = Some(value(&mut index)?),
|
||||||
|
"-m" | "--milestones" => options.milestones = Some(value(&mut index)?),
|
||||||
|
"-A" | "--author" => options.author = Some(value(&mut index)?),
|
||||||
|
"-a" | "--assignee" => options.assignee = Some(value(&mut index)?),
|
||||||
|
"-M" | "--mentions" => options.mentions = Some(value(&mut index)?),
|
||||||
|
"-F" | "--from" => options.from = Some(value(&mut index)?),
|
||||||
|
"-u" | "--until" => options.until = Some(value(&mut index)?),
|
||||||
|
"-p" | "--page" => options.page = positive_i32(&value(&mut index)?, "page")?,
|
||||||
|
"--limit" | "--lm" => options.limit = positive_i32(&value(&mut index)?, "limit")?,
|
||||||
|
unknown if unknown.starts_with('-') => {
|
||||||
|
return Err(format!("unknown issue list option {unknown:?}").into());
|
||||||
|
}
|
||||||
|
repository if options.repository.is_none() => {
|
||||||
|
RepositoryScope::parse(repository)?;
|
||||||
|
options.repository = Some(repository.into());
|
||||||
|
}
|
||||||
|
value => return Err(format!("unexpected issue list argument {value:?}").into()),
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
if !matches!(options.state.as_str(), "open" | "closed" | "all") {
|
||||||
|
return Err("--state must be open, closed, or all".into());
|
||||||
|
}
|
||||||
|
if !matches!(options.kind.as_str(), "issues" | "pulls" | "all") {
|
||||||
|
return Err("--kind must be issues, pulls, or all".into());
|
||||||
|
}
|
||||||
|
Ok(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn positive_i32(value: &str, name: &str) -> Result<i32, Box<dyn Error>> {
|
||||||
|
let value: i32 = value.parse()?;
|
||||||
|
if value < 1 {
|
||||||
|
return Err(format!("{name} must be a positive integer").into());
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn issue_targets(
|
||||||
|
arguments: &[String],
|
||||||
|
) -> Result<(Vec<i64>, Option<String>), Box<dyn Error>> {
|
||||||
|
let mut indexes = Vec::new();
|
||||||
|
let mut repository = None;
|
||||||
|
for argument in arguments {
|
||||||
|
if argument.contains('/') {
|
||||||
|
if repository.is_some() {
|
||||||
|
return Err("only one OWNER/REPOSITORY may be provided".into());
|
||||||
|
}
|
||||||
|
RepositoryScope::parse(argument)?;
|
||||||
|
repository = Some(argument.clone());
|
||||||
|
} else {
|
||||||
|
indexes.push(number(argument)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if indexes.is_empty() {
|
||||||
|
return Err("at least one issue index is required".into());
|
||||||
|
}
|
||||||
|
Ok((indexes, repository))
|
||||||
|
}
|
||||||
95
crates/cli/src/work_items/help.rs
Normal file
95
crates/cli/src/work_items/help.rs
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
const ISSUE_HELP: &str = "\
|
||||||
|
Usage: gotcha issue SUBCOMMAND
|
||||||
|
|
||||||
|
Subcommands:
|
||||||
|
list [OWNER/REPOSITORY] [OPTIONS]
|
||||||
|
List and filter issues
|
||||||
|
show INDEX [OWNER/REPOSITORY] Show an issue
|
||||||
|
create [OWNER/REPOSITORY] Create from CreateIssueOption YAML on stdin
|
||||||
|
edit INDEX... [OWNER/REPOSITORY] Edit issues from EditIssueOption YAML on stdin
|
||||||
|
close INDEX... [OWNER/REPOSITORY]
|
||||||
|
Close one or more issues
|
||||||
|
reopen INDEX... [OWNER/REPOSITORY]
|
||||||
|
Reopen one or more issues
|
||||||
|
delete INDEX [OWNER/REPOSITORY] Delete an issue
|
||||||
|
comments INDEX [OWNER/REPOSITORY]
|
||||||
|
List comments
|
||||||
|
comment INDEX [OWNER/REPOSITORY] Add a comment read as text from stdin";
|
||||||
|
|
||||||
|
const MILESTONE_HELP: &str = "\
|
||||||
|
Usage: gotcha milestone SUBCOMMAND
|
||||||
|
|
||||||
|
Subcommands:
|
||||||
|
list [OWNER/REPOSITORY] List milestones
|
||||||
|
show ID [OWNER/REPOSITORY] Show a milestone
|
||||||
|
create [OWNER/REPOSITORY] Create from CreateMilestoneOption YAML on stdin
|
||||||
|
edit ID [OWNER/REPOSITORY] Edit from EditMilestoneOption YAML on stdin
|
||||||
|
delete ID [OWNER/REPOSITORY] Delete a milestone";
|
||||||
|
|
||||||
|
const PULL_HELP: &str = "\
|
||||||
|
Usage: gotcha pull SUBCOMMAND
|
||||||
|
|
||||||
|
Subcommands:
|
||||||
|
list [OWNER/REPOSITORY] List pull requests
|
||||||
|
show INDEX [OWNER/REPOSITORY] Show a pull request
|
||||||
|
create [OWNER/REPOSITORY] Create from CreatePullRequestOption YAML on stdin
|
||||||
|
edit INDEX [OWNER/REPOSITORY] Edit from EditPullRequestOption YAML on stdin
|
||||||
|
merge INDEX [OWNER/REPOSITORY] Merge from MergePullRequestOption YAML on stdin
|
||||||
|
commits INDEX [OWNER/REPOSITORY] List commits
|
||||||
|
files INDEX [OWNER/REPOSITORY] List changed files
|
||||||
|
reviews INDEX [OWNER/REPOSITORY] List reviews";
|
||||||
|
|
||||||
|
pub fn domain_help(domain: &str) -> Option<&'static str> {
|
||||||
|
match domain {
|
||||||
|
"issue" => Some(ISSUE_HELP),
|
||||||
|
"milestone" => Some(MILESTONE_HELP),
|
||||||
|
"pull" => Some(PULL_HELP),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn subcommand_help(domain: &str, command: &str) -> Option<&'static str> {
|
||||||
|
match (domain, command) {
|
||||||
|
("issue", "list") => Some(
|
||||||
|
"Usage: gotcha issue list [OWNER/REPOSITORY] [OPTIONS]\n\nOptions:\n --state open|closed|all\n -K, --kind issues|pulls|all\n -k, --keyword TEXT\n -L, --labels NAMES\n -m, --milestones NAMES\n -A, --author USER\n -a, --assignee USER\n -M, --mentions USER\n -F, --from TIMESTAMP\n -u, --until TIMESTAMP\n -p, --page NUMBER\n --limit NUMBER",
|
||||||
|
),
|
||||||
|
("issue", "show") => Some("Usage: gotcha issue show INDEX [OWNER/REPOSITORY]"),
|
||||||
|
("issue", "create") => Some(
|
||||||
|
"Usage: gotcha issue create [OWNER/REPOSITORY] < issue.yaml\n\nReads CreateIssueOption YAML from stdin. label_names and milestone_name accept display names, for example:\n title: Fix the bug\n body: Reproduction steps\n label_names: [bug, critical]\n milestone_name: Version 1.0",
|
||||||
|
),
|
||||||
|
("issue", "edit") => Some(
|
||||||
|
"Usage: gotcha issue edit INDEX... [OWNER/REPOSITORY] < issue.yaml\n\nReads EditIssueOption YAML from stdin and applies it to every index. milestone_name resolves a display name; add_labels and remove_labels accept label names; add_assignees adds users without replacing existing assignees.",
|
||||||
|
),
|
||||||
|
("issue", "close") => Some("Usage: gotcha issue close INDEX... [OWNER/REPOSITORY]"),
|
||||||
|
("issue", "reopen") => Some("Usage: gotcha issue reopen INDEX... [OWNER/REPOSITORY]"),
|
||||||
|
("issue", "delete") => Some("Usage: gotcha issue delete INDEX [OWNER/REPOSITORY]"),
|
||||||
|
("issue", "comments") => Some("Usage: gotcha issue comments INDEX [OWNER/REPOSITORY]"),
|
||||||
|
("issue", "comment") => Some(
|
||||||
|
"Usage: gotcha issue comment INDEX [OWNER/REPOSITORY] < comment.txt\n\nReads the comment body as text from stdin.",
|
||||||
|
),
|
||||||
|
("milestone", "list") => Some("Usage: gotcha milestone list [OWNER/REPOSITORY]"),
|
||||||
|
("milestone", "show") => Some("Usage: gotcha milestone show ID [OWNER/REPOSITORY]"),
|
||||||
|
("milestone", "create") => Some(
|
||||||
|
"Usage: gotcha milestone create [OWNER/REPOSITORY] < milestone.yaml\n\nReads CreateMilestoneOption YAML from stdin, for example:\n title: Version 1.0\n due_on: 2026-09-01T00:00:00Z",
|
||||||
|
),
|
||||||
|
("milestone", "edit") => Some(
|
||||||
|
"Usage: gotcha milestone edit ID [OWNER/REPOSITORY] < milestone.yaml\n\nReads EditMilestoneOption YAML from stdin.",
|
||||||
|
),
|
||||||
|
("milestone", "delete") => Some("Usage: gotcha milestone delete ID [OWNER/REPOSITORY]"),
|
||||||
|
("pull", "list") => Some("Usage: gotcha pull list [OWNER/REPOSITORY]"),
|
||||||
|
("pull", "show") => Some("Usage: gotcha pull show INDEX [OWNER/REPOSITORY]"),
|
||||||
|
("pull", "create") => Some(
|
||||||
|
"Usage: gotcha pull create [OWNER/REPOSITORY] < pull.yaml\n\nReads CreatePullRequestOption YAML from stdin, for example:\n title: Add feature\n head: feature\n base: main",
|
||||||
|
),
|
||||||
|
("pull", "edit") => Some(
|
||||||
|
"Usage: gotcha pull edit INDEX [OWNER/REPOSITORY] < pull.yaml\n\nReads EditPullRequestOption YAML from stdin.",
|
||||||
|
),
|
||||||
|
("pull", "merge") => Some(
|
||||||
|
"Usage: gotcha pull merge INDEX [OWNER/REPOSITORY] < merge.yaml\n\nReads MergePullRequestOption YAML from stdin, for example:\n Do: squash\n delete_branch_after_merge: true",
|
||||||
|
),
|
||||||
|
("pull", "commits") => Some("Usage: gotcha pull commits INDEX [OWNER/REPOSITORY]"),
|
||||||
|
("pull", "files") => Some("Usage: gotcha pull files INDEX [OWNER/REPOSITORY]"),
|
||||||
|
("pull", "reviews") => Some("Usage: gotcha pull reviews INDEX [OWNER/REPOSITORY]"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
258
crates/cli/src/work_items/output.rs
Normal file
258
crates/cli/src/work_items/output.rs
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
use gotcha_gitea::models::{
|
||||||
|
ChangedFile, Comment, Commit, Issue, Milestone, PullRequest, PullReview,
|
||||||
|
};
|
||||||
|
use gotcha_gitea::pull_state;
|
||||||
|
|
||||||
|
use crate::print_table;
|
||||||
|
|
||||||
|
pub(super) fn print_issues(issues: &[Issue]) {
|
||||||
|
print_table(
|
||||||
|
&[
|
||||||
|
("INDEX", 8),
|
||||||
|
("STATE", 10),
|
||||||
|
("TITLE", 60),
|
||||||
|
("MILESTONE", 30),
|
||||||
|
("LABELS", 30),
|
||||||
|
],
|
||||||
|
issues
|
||||||
|
.iter()
|
||||||
|
.map(|issue| {
|
||||||
|
vec![
|
||||||
|
issue.number.unwrap_or_default().to_string(),
|
||||||
|
issue.state.as_deref().unwrap_or("unknown").into(),
|
||||||
|
one_line(issue.title.as_deref().unwrap_or("")).into(),
|
||||||
|
issue
|
||||||
|
.milestone
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|milestone| milestone.title.as_deref())
|
||||||
|
.unwrap_or("-")
|
||||||
|
.into(),
|
||||||
|
issue_labels(issue),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn print_issue(issue: &Issue) {
|
||||||
|
println!(
|
||||||
|
"#{} [{}] {}",
|
||||||
|
issue.number.unwrap_or_default(),
|
||||||
|
issue.state.as_deref().unwrap_or("unknown"),
|
||||||
|
issue.title.as_deref().unwrap_or("")
|
||||||
|
);
|
||||||
|
field("url", issue.html_url.as_deref());
|
||||||
|
field(
|
||||||
|
"author",
|
||||||
|
issue.user.as_deref().and_then(|user| user.login.as_deref()),
|
||||||
|
);
|
||||||
|
let milestone = issue
|
||||||
|
.milestone
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|milestone| milestone.title.as_deref());
|
||||||
|
field("milestone", milestone);
|
||||||
|
let labels = issue_labels(issue);
|
||||||
|
field("labels", (!labels.is_empty()).then_some(labels.as_str()));
|
||||||
|
let assignees = issue
|
||||||
|
.assignees
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|user| user.login.as_deref())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",");
|
||||||
|
field(
|
||||||
|
"assignees",
|
||||||
|
(!assignees.is_empty()).then_some(assignees.as_str()),
|
||||||
|
);
|
||||||
|
field("due", issue.due_date.as_deref());
|
||||||
|
field("created", issue.created_at.as_deref());
|
||||||
|
field("updated", issue.updated_at.as_deref());
|
||||||
|
println!("comments: {}", issue.comments.unwrap_or_default());
|
||||||
|
body(issue.body.as_deref());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn issue_labels(issue: &Issue) -> String {
|
||||||
|
issue
|
||||||
|
.labels
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|label| label.name.as_deref())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn print_comments(comments: &[Comment]) {
|
||||||
|
for comment in comments {
|
||||||
|
println!(
|
||||||
|
"{} · {}",
|
||||||
|
comment
|
||||||
|
.user
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|user| user.login.as_deref())
|
||||||
|
.unwrap_or("unknown"),
|
||||||
|
comment.created_at.as_deref().unwrap_or("-")
|
||||||
|
);
|
||||||
|
body(comment.body.as_deref());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn print_milestones(milestones: &[Milestone]) {
|
||||||
|
print_table(
|
||||||
|
&[
|
||||||
|
("ID", 8),
|
||||||
|
("STATE", 10),
|
||||||
|
("TITLE", 60),
|
||||||
|
("DUE", 25),
|
||||||
|
("OPEN/CLOSED", 12),
|
||||||
|
],
|
||||||
|
milestones
|
||||||
|
.iter()
|
||||||
|
.map(|milestone| {
|
||||||
|
vec![
|
||||||
|
milestone.id.unwrap_or_default().to_string(),
|
||||||
|
milestone.state.as_deref().unwrap_or("unknown").into(),
|
||||||
|
one_line(milestone.title.as_deref().unwrap_or("")).into(),
|
||||||
|
milestone.due_on.as_deref().unwrap_or("-").into(),
|
||||||
|
format!(
|
||||||
|
"{}/{}",
|
||||||
|
milestone.open_issues.unwrap_or_default(),
|
||||||
|
milestone.closed_issues.unwrap_or_default()
|
||||||
|
),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn print_milestone(milestone: &Milestone) {
|
||||||
|
println!(
|
||||||
|
"{} [{}] {}",
|
||||||
|
milestone.id.unwrap_or_default(),
|
||||||
|
milestone.state.as_deref().unwrap_or("unknown"),
|
||||||
|
milestone.title.as_deref().unwrap_or("")
|
||||||
|
);
|
||||||
|
field("due", milestone.due_on.as_deref());
|
||||||
|
println!(
|
||||||
|
"issues: {} open, {} closed",
|
||||||
|
milestone.open_issues.unwrap_or_default(),
|
||||||
|
milestone.closed_issues.unwrap_or_default()
|
||||||
|
);
|
||||||
|
body(milestone.description.as_deref());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn print_pulls(pulls: &[PullRequest]) {
|
||||||
|
print_table(
|
||||||
|
&[("INDEX", 8), ("STATE", 10), ("TITLE", 70), ("UPDATED", 25)],
|
||||||
|
pulls
|
||||||
|
.iter()
|
||||||
|
.map(|pull| {
|
||||||
|
vec![
|
||||||
|
pull.number.unwrap_or_default().to_string(),
|
||||||
|
pull.state.as_deref().unwrap_or("unknown").into(),
|
||||||
|
one_line(pull.title.as_deref().unwrap_or("")).into(),
|
||||||
|
pull.updated_at.as_deref().unwrap_or("-").into(),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn print_pull(pull: &PullRequest) {
|
||||||
|
println!(
|
||||||
|
"#{} [{}] {}",
|
||||||
|
pull.number.unwrap_or_default(),
|
||||||
|
pull_state(pull),
|
||||||
|
pull.title.as_deref().unwrap_or("")
|
||||||
|
);
|
||||||
|
field("url", pull.html_url.as_deref());
|
||||||
|
field(
|
||||||
|
"author",
|
||||||
|
pull.user.as_deref().and_then(|user| user.login.as_deref()),
|
||||||
|
);
|
||||||
|
field("updated", pull.updated_at.as_deref());
|
||||||
|
println!("mergeable: {}", pull.mergeable.unwrap_or(false));
|
||||||
|
body(pull.body.as_deref());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn print_commits(commits: &[Commit]) {
|
||||||
|
print_table(
|
||||||
|
&[("SHA", 40), ("MESSAGE", 80)],
|
||||||
|
commits
|
||||||
|
.iter()
|
||||||
|
.map(|commit| {
|
||||||
|
vec![
|
||||||
|
commit.sha.as_deref().unwrap_or("unknown").into(),
|
||||||
|
one_line(
|
||||||
|
commit
|
||||||
|
.commit
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|commit| commit.message.as_deref())
|
||||||
|
.unwrap_or(""),
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn print_files(files: &[ChangedFile]) {
|
||||||
|
print_table(
|
||||||
|
&[("STATUS", 12), ("CHANGES", 10), ("FILE", 90)],
|
||||||
|
files
|
||||||
|
.iter()
|
||||||
|
.map(|file| {
|
||||||
|
vec![
|
||||||
|
file.status.as_deref().unwrap_or("unknown").into(),
|
||||||
|
file.changes.unwrap_or_default().to_string(),
|
||||||
|
file.filename.as_deref().unwrap_or("").into(),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn print_reviews(reviews: &[PullReview]) {
|
||||||
|
print_table(
|
||||||
|
&[
|
||||||
|
("ID", 12),
|
||||||
|
("STATE", 20),
|
||||||
|
("REVIEWER", 25),
|
||||||
|
("SUBMITTED", 25),
|
||||||
|
],
|
||||||
|
reviews
|
||||||
|
.iter()
|
||||||
|
.map(|review| {
|
||||||
|
vec![
|
||||||
|
review.id.unwrap_or_default().to_string(),
|
||||||
|
review.state.as_deref().unwrap_or("unknown").into(),
|
||||||
|
review
|
||||||
|
.user
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|user| user.login.as_deref())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.into(),
|
||||||
|
review.submitted_at.as_deref().unwrap_or("-").into(),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field(name: &str, value: Option<&str>) {
|
||||||
|
if let Some(value) = value.filter(|value| !value.is_empty()) {
|
||||||
|
println!("{name}: {value}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn body(value: Option<&str>) {
|
||||||
|
if let Some(value) = value.filter(|value| !value.is_empty()) {
|
||||||
|
println!("\n{value}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn one_line(value: &str) -> &str {
|
||||||
|
value.lines().next().unwrap_or_default()
|
||||||
|
}
|
||||||
59
crates/cli/src/work_items/tests.rs
Normal file
59
crates/cli/src/work_items/tests.rs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_typed_yaml_and_rejects_bad_indexes() {
|
||||||
|
let issue: CreateIssueInput =
|
||||||
|
parse_yaml("title: Fix it\nbody: Details\nlabel_names: [bug]\nmilestone_name: Version 1\n")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(issue.issue.title, "Fix it");
|
||||||
|
assert_eq!(issue.issue.body.as_deref(), Some("Details"));
|
||||||
|
assert_eq!(issue.label_names, ["bug"]);
|
||||||
|
assert_eq!(issue.milestone_name.as_deref(), Some("Version 1"));
|
||||||
|
let edit: EditIssueInput = parse_yaml(
|
||||||
|
"title: Updated\nadd_labels: [critical]\nremove_labels: [bug]\nadd_assignees: [alice]\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(edit.issue.title.as_deref(), Some("Updated"));
|
||||||
|
assert_eq!(edit.add_labels, ["critical"]);
|
||||||
|
assert_eq!(edit.remove_labels, ["bug"]);
|
||||||
|
assert_eq!(edit.add_assignees, ["alice"]);
|
||||||
|
assert!(number("0").is_err());
|
||||||
|
assert!(number("7").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_issue_workflow_filters_and_bulk_state_targets() {
|
||||||
|
let options = parse_issue_list(
|
||||||
|
&[
|
||||||
|
"hugo/Gotcha",
|
||||||
|
"--state",
|
||||||
|
"all",
|
||||||
|
"--milestones",
|
||||||
|
"first feature complete release",
|
||||||
|
"-L",
|
||||||
|
"bug,critical",
|
||||||
|
"--page",
|
||||||
|
"2",
|
||||||
|
"--limit",
|
||||||
|
"50",
|
||||||
|
]
|
||||||
|
.map(str::to_owned),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(options.repository.as_deref(), Some("hugo/Gotcha"));
|
||||||
|
assert_eq!(options.state, "all");
|
||||||
|
assert_eq!(
|
||||||
|
options.milestones.as_deref(),
|
||||||
|
Some("first feature complete release")
|
||||||
|
);
|
||||||
|
assert_eq!(options.labels.as_deref(), Some("bug,critical"));
|
||||||
|
assert_eq!((options.page, options.limit), (2, 50));
|
||||||
|
|
||||||
|
let (indexes, repository) =
|
||||||
|
issue_targets(&["4", "16", "hugo/Gotcha"].map(str::to_owned)).unwrap();
|
||||||
|
assert_eq!(indexes, [4, 16]);
|
||||||
|
assert_eq!(repository.as_deref(), Some("hugo/Gotcha"));
|
||||||
|
assert!(parse_issue_list(&["--state".into(), "invalid".into()]).is_err());
|
||||||
|
assert!(issue_targets(&[]).is_err());
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "gotcha_gitea"
|
name = "gotcha_gitea"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
description = "A small, reusable Rust client for the Gitea API"
|
description = "A small, reusable Rust client for the Gitea API"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
@@ -11,3 +11,5 @@ gitea-openapi = { package = "gitea-client", version = "=1.25.2", default-feature
|
|||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
serde_yaml.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
|||||||
609
crates/gitea/src/actions.rs
Normal file
609
crates/gitea/src/actions.rs
Normal file
@@ -0,0 +1,609 @@
|
|||||||
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
|
||||||
|
use reqwest::header::ACCEPT;
|
||||||
|
|
||||||
|
use crate::{Client, Error, Method, Page, RepositoryId, Result, models, positive, validate_page};
|
||||||
|
use gitea_openapi::apis;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ActionRunQuery {
|
||||||
|
pub event: Option<String>,
|
||||||
|
pub branch: Option<String>,
|
||||||
|
pub status: Option<String>,
|
||||||
|
pub actor: Option<String>,
|
||||||
|
pub head_sha: Option<String>,
|
||||||
|
pub page: i32,
|
||||||
|
pub limit: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ActionRunQuery {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
event: None,
|
||||||
|
branch: None,
|
||||||
|
status: None,
|
||||||
|
actor: None,
|
||||||
|
head_sha: None,
|
||||||
|
page: 1,
|
||||||
|
limit: crate::DEFAULT_PAGE_SIZE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ActionRunDetails {
|
||||||
|
pub run: models::ActionWorkflowRun,
|
||||||
|
pub jobs: Vec<models::ActionWorkflowJob>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ActionJobLog {
|
||||||
|
pub job: models::ActionWorkflowJob,
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ActionJobDetails {
|
||||||
|
pub job: models::ActionWorkflowJob,
|
||||||
|
pub text: String,
|
||||||
|
pub groups: Vec<ActionLogGroup>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ActionLogGroup {
|
||||||
|
pub name: String,
|
||||||
|
pub text: String,
|
||||||
|
pub duration_seconds: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
pub async fn action_workflows(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
) -> Result<Vec<models::ActionWorkflow>> {
|
||||||
|
apis::repository_api::actions_list_repository_workflows(
|
||||||
|
&self.configuration(),
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|response| response.workflows.unwrap_or_default())
|
||||||
|
.map_err(Error::generated)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn dispatch_action_workflow(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
workflow: &str,
|
||||||
|
reference: &str,
|
||||||
|
inputs: &BTreeMap<String, String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let workflow = workflow.trim();
|
||||||
|
let reference = reference.trim();
|
||||||
|
if workflow.is_empty() || reference.is_empty() {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"workflow and reference must not be empty".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut dispatch = models::CreateActionWorkflowDispatch::new(reference.into());
|
||||||
|
dispatch.inputs = (!inputs.is_empty()).then(|| {
|
||||||
|
inputs
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| (key.clone(), value.clone()))
|
||||||
|
.collect::<HashMap<_, _>>()
|
||||||
|
});
|
||||||
|
apis::repository_api::actions_dispatch_workflow(
|
||||||
|
&self.configuration(),
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
workflow,
|
||||||
|
Some(dispatch),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Error::generated)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn action_runs(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
query: &ActionRunQuery,
|
||||||
|
) -> Result<Page<models::ActionWorkflowRun>> {
|
||||||
|
validate_page(query.page, query.limit)?;
|
||||||
|
apis::repository_api::get_workflow_runs(
|
||||||
|
&self.configuration(),
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
query.event.as_deref(),
|
||||||
|
query.branch.as_deref(),
|
||||||
|
query.status.as_deref(),
|
||||||
|
query.actor.as_deref(),
|
||||||
|
query.head_sha.as_deref(),
|
||||||
|
Some(query.page),
|
||||||
|
Some(query.limit),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|response| Page::from_items(response.workflow_runs.unwrap_or_default(), query.limit))
|
||||||
|
.map_err(Error::generated)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn action_run_details(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
run: i64,
|
||||||
|
) -> Result<ActionRunDetails> {
|
||||||
|
positive(run, "run ID")?;
|
||||||
|
let run_id = action_run_id(run)?;
|
||||||
|
let (run, jobs) = tokio::try_join!(
|
||||||
|
async {
|
||||||
|
apis::repository_api::get_workflow_run(
|
||||||
|
&self.configuration(),
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
&run.to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Error::generated)
|
||||||
|
},
|
||||||
|
async { self.action_jobs(repository, run_id).await }
|
||||||
|
)?;
|
||||||
|
Ok(ActionRunDetails { run, jobs })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn action_run_logs(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
run: i64,
|
||||||
|
) -> Result<Vec<ActionJobLog>> {
|
||||||
|
let run = action_run_id(run)?;
|
||||||
|
let jobs = self.action_jobs(repository, run).await?;
|
||||||
|
let mut logs = Vec::with_capacity(jobs.len());
|
||||||
|
for job in jobs {
|
||||||
|
let id = job
|
||||||
|
.id
|
||||||
|
.ok_or_else(|| Error::Generated("Action job has no ID.".into()))?;
|
||||||
|
let text = self.action_job_log(repository, id).await?;
|
||||||
|
logs.push(ActionJobLog { job, text });
|
||||||
|
}
|
||||||
|
Ok(logs)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn action_job_log(&self, repository: &RepositoryId, job: i64) -> Result<String> {
|
||||||
|
positive(job, "job ID")?;
|
||||||
|
let endpoint = format!(
|
||||||
|
"repos/{}/{}/actions/jobs/{job}/logs",
|
||||||
|
apis::urlencode(&repository.owner),
|
||||||
|
apis::urlencode(&repository.repository),
|
||||||
|
);
|
||||||
|
self.execute(
|
||||||
|
self.request(Method::GET, &endpoint)?
|
||||||
|
.header(ACCEPT, "text/plain"),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(Error::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn action_job(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
job: i64,
|
||||||
|
) -> Result<models::ActionWorkflowJob> {
|
||||||
|
positive(job, "job ID")?;
|
||||||
|
apis::repository_api::get_workflow_job(
|
||||||
|
&self.configuration(),
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
&job.to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Error::generated)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn action_run(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
run: i64,
|
||||||
|
) -> Result<models::ActionWorkflowRun> {
|
||||||
|
positive(run, "run ID")?;
|
||||||
|
apis::repository_api::get_workflow_run(
|
||||||
|
&self.configuration(),
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
&run.to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Error::generated)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn action_job_details(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
run: i64,
|
||||||
|
job: i64,
|
||||||
|
) -> Result<ActionJobDetails> {
|
||||||
|
let (run, job, text) = tokio::try_join!(
|
||||||
|
self.action_run(repository, run),
|
||||||
|
self.action_job(repository, job),
|
||||||
|
self.action_job_log(repository, job),
|
||||||
|
)?;
|
||||||
|
let workflow = run
|
||||||
|
.path
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|path| path.split('@').next())
|
||||||
|
.filter(|path| !path.is_empty())
|
||||||
|
.map(|path| {
|
||||||
|
if path.starts_with(".gitea/workflows/") {
|
||||||
|
path.to_owned()
|
||||||
|
} else {
|
||||||
|
format!(".gitea/workflows/{path}")
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let steps = if let (Some(path), Some(reference), Some(job_name)) = (
|
||||||
|
workflow.as_deref(),
|
||||||
|
run.head_sha.as_deref(),
|
||||||
|
job.name.as_deref(),
|
||||||
|
) {
|
||||||
|
match self
|
||||||
|
.repository_file_at_ref(repository, path, Some(reference))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(yaml) => action_workflow_step_names(&yaml, job_name),
|
||||||
|
Err(_) => Vec::new(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
let groups = group_action_log_with_steps(&text, &steps);
|
||||||
|
Ok(ActionJobDetails { job, text, groups })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn action_jobs(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
run: i32,
|
||||||
|
) -> Result<Vec<models::ActionWorkflowJob>> {
|
||||||
|
let mut jobs = Vec::new();
|
||||||
|
for page in 1.. {
|
||||||
|
let mut batch = apis::repository_api::list_workflow_run_jobs(
|
||||||
|
&self.configuration(),
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
run,
|
||||||
|
None,
|
||||||
|
Some(page),
|
||||||
|
Some(100),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Error::generated)?
|
||||||
|
.jobs
|
||||||
|
.unwrap_or_default();
|
||||||
|
let complete = batch.len() < 100;
|
||||||
|
jobs.append(&mut batch);
|
||||||
|
if complete {
|
||||||
|
return Ok(jobs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unreachable!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_action_inputs(values: &[String]) -> Result<BTreeMap<String, String>> {
|
||||||
|
let mut inputs = BTreeMap::new();
|
||||||
|
for value in values {
|
||||||
|
for line in value.lines().map(str::trim).filter(|line| !line.is_empty()) {
|
||||||
|
let (key, value) = line.split_once('=').ok_or_else(|| {
|
||||||
|
Error::InvalidInput(format!("workflow input must be KEY=VALUE: {line}"))
|
||||||
|
})?;
|
||||||
|
let key = key.trim();
|
||||||
|
if key.is_empty() {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"workflow input key must not be empty".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if inputs.insert(key.into(), value.trim().into()).is_some() {
|
||||||
|
return Err(Error::InvalidInput(format!(
|
||||||
|
"workflow input {key} was provided more than once"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(inputs)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn group_action_log(log: &str) -> Vec<ActionLogGroup> {
|
||||||
|
group_action_log_with_steps(log, &[])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn group_action_log_with_steps(log: &str, steps: &[String]) -> Vec<ActionLogGroup> {
|
||||||
|
let mut groups = Vec::new();
|
||||||
|
let mut name = "Set up job".to_string();
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
let mut depth = 0_u32;
|
||||||
|
let mut step_index = None;
|
||||||
|
for line in log.lines() {
|
||||||
|
if let Some((prefix, label)) = log_group_marker(line) {
|
||||||
|
if depth == 0
|
||||||
|
&& let Some(step) = label
|
||||||
|
.strip_prefix("Run ")
|
||||||
|
.or_else(|| label.strip_prefix("Post "))
|
||||||
|
{
|
||||||
|
push_log_group(&mut groups, name, lines);
|
||||||
|
name = if label.starts_with("Post ") || step.starts_with("Post ") {
|
||||||
|
step_index = None;
|
||||||
|
"Complete job".into()
|
||||||
|
} else {
|
||||||
|
step_index = steps.iter().position(|name| name == step);
|
||||||
|
step.into()
|
||||||
|
};
|
||||||
|
lines = Vec::new();
|
||||||
|
} else {
|
||||||
|
lines.push(format!("{prefix}{label}"));
|
||||||
|
}
|
||||||
|
depth += 1;
|
||||||
|
} else if is_log_group_end(line) {
|
||||||
|
depth = depth.saturating_sub(1);
|
||||||
|
if depth == 0
|
||||||
|
&& let Some(index) = step_index
|
||||||
|
&& let Some(next) = steps.get(index + 1)
|
||||||
|
{
|
||||||
|
push_log_group(&mut groups, name, lines);
|
||||||
|
name = next.clone();
|
||||||
|
lines = Vec::new();
|
||||||
|
step_index = Some(index + 1);
|
||||||
|
}
|
||||||
|
} else if depth == 0 && log_message(line).starts_with("Run Post ") {
|
||||||
|
push_log_group(&mut groups, name, lines);
|
||||||
|
name = "Complete job".into();
|
||||||
|
lines = vec![line.to_string()];
|
||||||
|
step_index = None;
|
||||||
|
} else {
|
||||||
|
lines.push(line.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
push_log_group(&mut groups, name, lines);
|
||||||
|
if groups.len() == 1 && groups[0].name == "Set up job" {
|
||||||
|
groups[0].name = "Job log".into();
|
||||||
|
}
|
||||||
|
groups
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_group_marker(line: &str) -> Option<(&str, &str)> {
|
||||||
|
["::group::", "##[group]"]
|
||||||
|
.into_iter()
|
||||||
|
.find_map(|marker| line.split_once(marker))
|
||||||
|
.map(|(prefix, label)| (prefix, label.trim()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_log_group_end(line: &str) -> bool {
|
||||||
|
line.contains("::endgroup::") || line.contains("##[endgroup]")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_log_group(groups: &mut Vec<ActionLogGroup>, name: String, lines: Vec<String>) {
|
||||||
|
if !lines.is_empty() {
|
||||||
|
let duration_seconds = log_duration(&lines);
|
||||||
|
if let Some(group) = groups.last_mut()
|
||||||
|
&& group.name == name
|
||||||
|
{
|
||||||
|
group.text.push('\n');
|
||||||
|
group.text.push_str(&lines.join("\n"));
|
||||||
|
group.duration_seconds =
|
||||||
|
log_duration(&group.text.lines().map(str::to_owned).collect::<Vec<_>>());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
groups.push(ActionLogGroup {
|
||||||
|
name,
|
||||||
|
text: lines.join("\n"),
|
||||||
|
duration_seconds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn action_workflow_step_names(yaml: &[u8], job: &str) -> Vec<String> {
|
||||||
|
let Ok(value) = serde_yaml::from_slice::<serde_yaml::Value>(yaml) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
value["jobs"][job]["steps"]
|
||||||
|
.as_sequence()
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|step| step["name"].as_str().map(str::to_owned))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_message(line: &str) -> &str {
|
||||||
|
line.split_once("Z ").map_or(line, |(_, message)| message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_duration(lines: &[String]) -> Option<u64> {
|
||||||
|
let start = lines.iter().find_map(|line| log_timestamp(line))?;
|
||||||
|
let end = lines.iter().rev().find_map(|line| log_timestamp(line))?;
|
||||||
|
Some(end.saturating_sub(start).max(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_timestamp(line: &str) -> Option<u64> {
|
||||||
|
crate::parse_api_timestamp(line).and_then(|value| value.try_into().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn action_run_id(run: i64) -> Result<i32> {
|
||||||
|
positive(run, "run ID")?;
|
||||||
|
i32::try_from(run).map_err(|_| Error::InvalidInput("run ID is too large".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::{
|
||||||
|
io::{Read, Write},
|
||||||
|
net::TcpListener,
|
||||||
|
thread,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::Provider;
|
||||||
|
|
||||||
|
fn read_request(stream: &mut std::net::TcpStream) -> String {
|
||||||
|
let mut request = Vec::new();
|
||||||
|
loop {
|
||||||
|
let mut buffer = [0; 4096];
|
||||||
|
let length = stream.read(&mut buffer).unwrap();
|
||||||
|
request.extend_from_slice(&buffer[..length]);
|
||||||
|
let Some(header_end) = request.windows(4).position(|bytes| bytes == b"\r\n\r\n") else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let headers = String::from_utf8_lossy(&request[..header_end]);
|
||||||
|
let content_length = headers
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| {
|
||||||
|
line.to_ascii_lowercase()
|
||||||
|
.strip_prefix("content-length: ")
|
||||||
|
.map(str::to_owned)
|
||||||
|
})
|
||||||
|
.and_then(|length| length.parse::<usize>().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
if request.len() >= header_end + 4 + content_length {
|
||||||
|
return String::from_utf8(request).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gets_every_job_log_for_a_run() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let address = listener.local_addr().unwrap();
|
||||||
|
let server = thread::spawn(move || {
|
||||||
|
let mut requests = Vec::new();
|
||||||
|
for (content_type, body) in [
|
||||||
|
(
|
||||||
|
"application/json",
|
||||||
|
r#"{"total_count":1,"jobs":[{"id":139,"name":"audit"}]}"#,
|
||||||
|
),
|
||||||
|
("text/plain", "scanner output\nexit code 1\n"),
|
||||||
|
] {
|
||||||
|
let (mut stream, _) = listener.accept().unwrap();
|
||||||
|
let mut buffer = [0; 4096];
|
||||||
|
let length = stream.read(&mut buffer).unwrap();
|
||||||
|
requests.push(String::from_utf8_lossy(&buffer[..length]).into_owned());
|
||||||
|
write!(
|
||||||
|
stream,
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||||
|
body.len()
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
requests
|
||||||
|
});
|
||||||
|
|
||||||
|
let client =
|
||||||
|
Client::with_provider(&format!("http://{address}"), None, Provider::Gitea).unwrap();
|
||||||
|
let repository = RepositoryId::new("hugo", "Gotcha").unwrap();
|
||||||
|
let logs = client.action_run_logs(&repository, 95).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(logs.len(), 1);
|
||||||
|
assert_eq!(logs[0].job.name.as_deref(), Some("audit"));
|
||||||
|
assert_eq!(logs[0].text, "scanner output\nexit code 1\n");
|
||||||
|
let requests = server.join().unwrap();
|
||||||
|
assert!(requests[0].starts_with("GET /api/v1/repos/hugo/Gotcha/actions/runs/95/jobs?"));
|
||||||
|
assert!(requests[1].starts_with("GET /api/v1/repos/hugo/Gotcha/actions/jobs/139/logs "));
|
||||||
|
assert!(requests[1].contains("accept: text/plain"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dispatches_reference_and_inputs() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let address = listener.local_addr().unwrap();
|
||||||
|
let server = thread::spawn(move || {
|
||||||
|
let (mut stream, _) = listener.accept().unwrap();
|
||||||
|
let request = read_request(&mut stream);
|
||||||
|
write!(
|
||||||
|
stream,
|
||||||
|
"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
request
|
||||||
|
});
|
||||||
|
|
||||||
|
let client =
|
||||||
|
Client::with_provider(&format!("http://{address}"), None, Provider::Gitea).unwrap();
|
||||||
|
let repository = RepositoryId::new("hugo", "Gotcha").unwrap();
|
||||||
|
client
|
||||||
|
.dispatch_action_workflow(
|
||||||
|
&repository,
|
||||||
|
"release.yml",
|
||||||
|
"main",
|
||||||
|
&BTreeMap::from([
|
||||||
|
("channel".into(), "stable".into()),
|
||||||
|
("dry_run".into(), "false".into()),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let request = server.join().unwrap();
|
||||||
|
assert!(request.starts_with(
|
||||||
|
"POST /api/v1/repos/hugo/Gotcha/actions/workflows/release.yml/dispatches "
|
||||||
|
));
|
||||||
|
assert!(request.contains(r#""ref":"main""#));
|
||||||
|
assert!(request.contains(r#""channel":"stable""#));
|
||||||
|
assert!(request.contains(r#""dry_run":"false""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_dispatch_inputs_once() {
|
||||||
|
let inputs = parse_action_inputs(&[
|
||||||
|
"channel=stable".into(),
|
||||||
|
"notes=public update\ndry_run=false".into(),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(inputs["channel"], "stable");
|
||||||
|
assert_eq!(inputs["notes"], "public update");
|
||||||
|
assert!(parse_action_inputs(&["channel".into()]).is_err());
|
||||||
|
assert!(parse_action_inputs(&["channel=a\nchannel=b".into()]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn groups_runner_logs_into_setup_steps_and_completion() {
|
||||||
|
let groups = group_action_log(
|
||||||
|
"00 ::group::Runner Information\n01 runner\n02 ::endgroup::\n03 ::group::Run Check out repository\n04 checkout\n05 ::endgroup::\n06 ::group::Run Post Check out repository\n07 cleanup\n08 ::endgroup::\n",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
groups
|
||||||
|
.iter()
|
||||||
|
.map(|group| group.name.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
["Set up job", "Check out repository", "Complete job"]
|
||||||
|
);
|
||||||
|
assert_eq!(groups[0].text, "00 Runner Information\n01 runner");
|
||||||
|
assert_eq!(groups[1].text, "04 checkout");
|
||||||
|
assert_eq!(groups[2].text, "07 cleanup");
|
||||||
|
|
||||||
|
let completion = group_action_log(
|
||||||
|
"::group::Run Post first\none\n::endgroup::\n::group::Run Post second\ntwo\n::endgroup::",
|
||||||
|
);
|
||||||
|
assert_eq!(completion.len(), 1);
|
||||||
|
assert_eq!(completion[0].name, "Complete job");
|
||||||
|
assert_eq!(completion[0].text, "one\ntwo");
|
||||||
|
|
||||||
|
let steps = action_workflow_step_names(
|
||||||
|
b"jobs:\n audit:\n steps:\n - name: Check out repository\n - name: Scan dependencies\n",
|
||||||
|
"audit",
|
||||||
|
);
|
||||||
|
let groups = group_action_log_with_steps(
|
||||||
|
"2026-08-10T03:17:25Z setup\n2026-08-10T03:17:26Z ::group::Run Check out repository\n2026-08-10T03:17:27Z checkout\n2026-08-10T03:17:28Z ::endgroup::\n2026-08-10T03:17:29Z scanning\n2026-08-10T03:17:32Z Run Post Check out repository\n2026-08-10T03:17:33Z cleanup\n",
|
||||||
|
&steps,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
groups
|
||||||
|
.iter()
|
||||||
|
.map(|group| group.name.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
[
|
||||||
|
"Set up job",
|
||||||
|
"Check out repository",
|
||||||
|
"Scan dependencies",
|
||||||
|
"Complete job"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(groups[2].duration_seconds, Some(1));
|
||||||
|
assert_eq!(groups[3].duration_seconds, Some(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
569
crates/gitea/src/activity.rs
Normal file
569
crates/gitea/src/activity.rs
Normal file
@@ -0,0 +1,569 @@
|
|||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
Client, Error, Result,
|
||||||
|
domain::{DEFAULT_PAGE_SIZE, HomeData, Page},
|
||||||
|
models,
|
||||||
|
};
|
||||||
|
use gitea_openapi::apis;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub enum ActivityFilter {
|
||||||
|
#[default]
|
||||||
|
All,
|
||||||
|
Issues,
|
||||||
|
PullRequests,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActivityFilter {
|
||||||
|
fn matches(self, activity: &models::Activity) -> bool {
|
||||||
|
use models::activity::OpType;
|
||||||
|
|
||||||
|
match self {
|
||||||
|
Self::All => true,
|
||||||
|
Self::Issues => matches!(
|
||||||
|
activity.op_type,
|
||||||
|
Some(
|
||||||
|
OpType::CreateIssue
|
||||||
|
| OpType::CloseIssue
|
||||||
|
| OpType::ReopenIssue
|
||||||
|
| OpType::CommentIssue
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Self::PullRequests => matches!(
|
||||||
|
activity.op_type,
|
||||||
|
Some(
|
||||||
|
OpType::CreatePullRequest
|
||||||
|
| OpType::MergePullRequest
|
||||||
|
| OpType::AutoMergePullRequest
|
||||||
|
| OpType::ClosePullRequest
|
||||||
|
| OpType::ReopenPullRequest
|
||||||
|
| OpType::CommentPull
|
||||||
|
| OpType::ApprovePullRequest
|
||||||
|
| OpType::RejectPullRequest
|
||||||
|
| OpType::PullReviewDismissed
|
||||||
|
| OpType::PullRequestReadyForReview
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Eq, PartialEq)]
|
||||||
|
pub enum Target {
|
||||||
|
Repository {
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
},
|
||||||
|
Commit {
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
sha: String,
|
||||||
|
},
|
||||||
|
Issue {
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
number: i64,
|
||||||
|
},
|
||||||
|
Pull {
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
number: i64,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct CommitActivity {
|
||||||
|
pub count: usize,
|
||||||
|
pub commits: Vec<ActivityCommit>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ActivityCommit {
|
||||||
|
pub sha: String,
|
||||||
|
pub message: String,
|
||||||
|
pub author: String,
|
||||||
|
pub timestamp: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ServerActivityPager {
|
||||||
|
configuration: apis::configuration::Configuration,
|
||||||
|
feeds: Vec<UserActivityFeed>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UserActivityFeed {
|
||||||
|
login: String,
|
||||||
|
activities: VecDeque<models::Activity>,
|
||||||
|
next_page: i32,
|
||||||
|
complete: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
pub async fn activities(
|
||||||
|
&self,
|
||||||
|
page: i32,
|
||||||
|
filter: ActivityFilter,
|
||||||
|
) -> Result<Page<models::Activity>> {
|
||||||
|
if page < 1 {
|
||||||
|
return Err(Error::InvalidInput("page must be positive".into()));
|
||||||
|
}
|
||||||
|
let configuration = self.configuration();
|
||||||
|
let login = self
|
||||||
|
.current_user()
|
||||||
|
.await?
|
||||||
|
.login
|
||||||
|
.ok_or_else(|| Error::Generated("The server account has no username.".into()))?;
|
||||||
|
filtered_activity_page(&configuration, &login, page, filter).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn home(&self, page: i32, filter: ActivityFilter) -> Result<HomeData> {
|
||||||
|
if page < 1 {
|
||||||
|
return Err(Error::InvalidInput("page must be positive".into()));
|
||||||
|
}
|
||||||
|
let configuration = self.configuration();
|
||||||
|
let login = self
|
||||||
|
.current_user()
|
||||||
|
.await?
|
||||||
|
.login
|
||||||
|
.ok_or_else(|| Error::Generated("The server account has no username.".into()))?;
|
||||||
|
let (activities, heatmap) = tokio::join!(
|
||||||
|
filtered_activity_page(&configuration, &login, page, filter),
|
||||||
|
apis::user_api::user_get_heatmap_data(&configuration, &login),
|
||||||
|
);
|
||||||
|
let activities = activities?;
|
||||||
|
Ok(HomeData {
|
||||||
|
activities: activities.items,
|
||||||
|
heatmap: heatmap.map_err(Error::generated)?,
|
||||||
|
next_page: activities.has_more.then_some(page + 1),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn server_activity_pager(&self) -> Result<ServerActivityPager> {
|
||||||
|
let configuration = self.configuration();
|
||||||
|
let users = activity_users(&configuration).await?;
|
||||||
|
Ok(ServerActivityPager {
|
||||||
|
configuration,
|
||||||
|
feeds: users
|
||||||
|
.into_iter()
|
||||||
|
.map(|login| UserActivityFeed {
|
||||||
|
login,
|
||||||
|
activities: VecDeque::new(),
|
||||||
|
next_page: 1,
|
||||||
|
complete: false,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerActivityPager {
|
||||||
|
pub async fn next_page(&mut self) -> Result<Page<models::Activity>> {
|
||||||
|
let mut items = Vec::with_capacity(DEFAULT_PAGE_SIZE as usize);
|
||||||
|
while items.len() < DEFAULT_PAGE_SIZE as usize {
|
||||||
|
for feed in &mut self.feeds {
|
||||||
|
feed.fill(&self.configuration).await?;
|
||||||
|
}
|
||||||
|
let Some(feed) = self.feeds.iter_mut().max_by(|left, right| {
|
||||||
|
activity_order(left.activities.front(), right.activities.front())
|
||||||
|
}) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let Some(activity) = feed.activities.pop_front() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
items.push(activity);
|
||||||
|
}
|
||||||
|
let has_more = self
|
||||||
|
.feeds
|
||||||
|
.iter()
|
||||||
|
.any(|feed| !feed.complete || !feed.activities.is_empty());
|
||||||
|
Ok(Page { items, has_more })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserActivityFeed {
|
||||||
|
async fn fill(&mut self, configuration: &apis::configuration::Configuration) -> Result<()> {
|
||||||
|
if self.complete || !self.activities.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let mut activities = activity_page(configuration, &self.login, self.next_page).await?;
|
||||||
|
self.complete = activities.len() < DEFAULT_PAGE_SIZE as usize;
|
||||||
|
self.next_page = self
|
||||||
|
.next_page
|
||||||
|
.checked_add(1)
|
||||||
|
.ok_or_else(|| Error::InvalidInput("activity feed has too many pages".into()))?;
|
||||||
|
activities.sort_unstable_by(|left, right| activity_order(Some(right), Some(left)));
|
||||||
|
self.activities = activities.into();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn activity_order(
|
||||||
|
left: Option<&models::Activity>,
|
||||||
|
right: Option<&models::Activity>,
|
||||||
|
) -> std::cmp::Ordering {
|
||||||
|
left.map(|activity| (&activity.created, &activity.id))
|
||||||
|
.cmp(&right.map(|activity| (&activity.created, &activity.id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn activity_users(configuration: &apis::configuration::Configuration) -> Result<Vec<String>> {
|
||||||
|
let mut users = Vec::new();
|
||||||
|
let mut page = 1;
|
||||||
|
loop {
|
||||||
|
let response = apis::user_api::user_search(
|
||||||
|
configuration,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(page),
|
||||||
|
Some(DEFAULT_PAGE_SIZE),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Error::generated)?;
|
||||||
|
if response.ok == Some(false) {
|
||||||
|
return Err(Error::Generated("The server user search failed.".into()));
|
||||||
|
}
|
||||||
|
let batch = response.data.unwrap_or_default();
|
||||||
|
let complete = batch.len() < DEFAULT_PAGE_SIZE as usize;
|
||||||
|
users.extend(batch.into_iter().filter_map(|user| user.login));
|
||||||
|
if complete {
|
||||||
|
return Ok(users);
|
||||||
|
}
|
||||||
|
page = page
|
||||||
|
.checked_add(1)
|
||||||
|
.ok_or_else(|| Error::InvalidInput("user search has too many pages".into()))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn filtered_activity_page(
|
||||||
|
configuration: &apis::configuration::Configuration,
|
||||||
|
login: &str,
|
||||||
|
mut page: i32,
|
||||||
|
filter: ActivityFilter,
|
||||||
|
) -> Result<Page<models::Activity>> {
|
||||||
|
let mut activities = activity_page(configuration, login, page).await?;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let has_more = activities.len() == DEFAULT_PAGE_SIZE as usize;
|
||||||
|
let filtered: Vec<_> = activities
|
||||||
|
.into_iter()
|
||||||
|
.filter(|activity| filter.matches(activity))
|
||||||
|
.collect();
|
||||||
|
if filter == ActivityFilter::All || !filtered.is_empty() || !has_more {
|
||||||
|
return Ok(Page {
|
||||||
|
items: filtered,
|
||||||
|
has_more,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
page += 1;
|
||||||
|
activities = activity_page(configuration, login, page).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn activity_page(
|
||||||
|
configuration: &apis::configuration::Configuration,
|
||||||
|
login: &str,
|
||||||
|
page: i32,
|
||||||
|
) -> Result<Vec<models::Activity>> {
|
||||||
|
apis::user_api::user_list_activity_feeds(
|
||||||
|
configuration,
|
||||||
|
login,
|
||||||
|
Some(true),
|
||||||
|
None,
|
||||||
|
Some(page),
|
||||||
|
Some(DEFAULT_PAGE_SIZE),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Error::generated)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn target(activity: &models::Activity) -> Option<Target> {
|
||||||
|
use models::activity::OpType;
|
||||||
|
|
||||||
|
let (owner, repository) = activity
|
||||||
|
.repo
|
||||||
|
.as_ref()?
|
||||||
|
.full_name
|
||||||
|
.as_deref()?
|
||||||
|
.split_once('/')?;
|
||||||
|
let pair = || (owner.to_string(), repository.to_string());
|
||||||
|
match activity.op_type? {
|
||||||
|
OpType::CreateRepo => {
|
||||||
|
let (owner, repository) = pair();
|
||||||
|
Some(Target::Repository { owner, repository })
|
||||||
|
}
|
||||||
|
OpType::CommitRepo | OpType::MirrorSyncPush => {
|
||||||
|
let (owner, repository) = pair();
|
||||||
|
Some(Target::Commit {
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
sha: commit_sha(activity.content.as_deref()?)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
OpType::CreateIssue | OpType::CloseIssue | OpType::ReopenIssue | OpType::CommentIssue => {
|
||||||
|
let (owner, repository) = pair();
|
||||||
|
Some(Target::Issue {
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
number: issue_number(activity)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
OpType::CreatePullRequest
|
||||||
|
| OpType::MergePullRequest
|
||||||
|
| OpType::AutoMergePullRequest
|
||||||
|
| OpType::ClosePullRequest
|
||||||
|
| OpType::ReopenPullRequest
|
||||||
|
| OpType::CommentPull
|
||||||
|
| OpType::ApprovePullRequest
|
||||||
|
| OpType::RejectPullRequest
|
||||||
|
| OpType::PullReviewDismissed
|
||||||
|
| OpType::PullRequestReadyForReview => {
|
||||||
|
let (owner, repository) = pair();
|
||||||
|
Some(Target::Pull {
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
number: issue_number(activity)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn commit_activity(activity: &models::Activity) -> Option<CommitActivity> {
|
||||||
|
let payload: serde_json::Value = serde_json::from_str(activity.content.as_deref()?).ok()?;
|
||||||
|
let mut commits = field(&payload, "Commits")
|
||||||
|
.and_then(serde_json::Value::as_array)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(activity_commit)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if commits.is_empty()
|
||||||
|
&& let Some(commit) = field(&payload, "HeadCommit").and_then(activity_commit)
|
||||||
|
{
|
||||||
|
commits.push(commit);
|
||||||
|
}
|
||||||
|
(!commits.is_empty()).then(|| CommitActivity {
|
||||||
|
count: field(&payload, "Len")
|
||||||
|
.and_then(serde_json::Value::as_u64)
|
||||||
|
.and_then(|count| usize::try_from(count).ok())
|
||||||
|
.unwrap_or(commits.len()),
|
||||||
|
commits,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn issue_number(activity: &models::Activity) -> Option<i64> {
|
||||||
|
activity
|
||||||
|
.comment
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|comment| {
|
||||||
|
comment
|
||||||
|
.pull_request_url
|
||||||
|
.as_deref()
|
||||||
|
.or(comment.issue_url.as_deref())
|
||||||
|
})
|
||||||
|
.and_then(url_number)
|
||||||
|
.or_else(|| {
|
||||||
|
activity
|
||||||
|
.content
|
||||||
|
.as_deref()?
|
||||||
|
.split(|character: char| !character.is_ascii_digit())
|
||||||
|
.find(|part| !part.is_empty())?
|
||||||
|
.parse()
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn url_number(url: &str) -> Option<i64> {
|
||||||
|
url.trim_end_matches('/').rsplit('/').next()?.parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commit_sha(content: &str) -> Option<String> {
|
||||||
|
let payload: serde_json::Value = serde_json::from_str(content).ok()?;
|
||||||
|
field(&payload, "HeadCommit")
|
||||||
|
.and_then(find_sha)
|
||||||
|
.or_else(|| {
|
||||||
|
field(&payload, "Commits")?
|
||||||
|
.as_array()?
|
||||||
|
.last()
|
||||||
|
.and_then(find_sha)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_sha(value: &serde_json::Value) -> Option<String> {
|
||||||
|
value.as_object()?.iter().find_map(|(key, value)| {
|
||||||
|
(key.to_ascii_lowercase().contains("sha"))
|
||||||
|
.then(|| value.as_str())
|
||||||
|
.flatten()
|
||||||
|
.filter(|sha| sha.len() >= 7)
|
||||||
|
.map(str::to_string)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn activity_commit(value: &serde_json::Value) -> Option<ActivityCommit> {
|
||||||
|
let sha = find_sha(value).unwrap_or_default();
|
||||||
|
let message = string_field(value, "Message")
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
(!sha.is_empty() || !message.is_empty()).then(|| ActivityCommit {
|
||||||
|
sha,
|
||||||
|
message,
|
||||||
|
author: string_field(value, "AuthorName")
|
||||||
|
.or_else(|| string_field(value, "CommitterName"))
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string(),
|
||||||
|
timestamp: string_field(value, "Timestamp")
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string_field<'a>(value: &'a serde_json::Value, name: &str) -> Option<&'a str> {
|
||||||
|
field(value, name).and_then(serde_json::Value::as_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field<'a>(value: &'a serde_json::Value, name: &str) -> Option<&'a serde_json::Value> {
|
||||||
|
value
|
||||||
|
.as_object()?
|
||||||
|
.iter()
|
||||||
|
.find_map(|(key, value)| key.eq_ignore_ascii_case(name).then_some(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use models::activity::OpType;
|
||||||
|
|
||||||
|
fn activity(op_type: OpType, content: &str) -> models::Activity {
|
||||||
|
models::Activity {
|
||||||
|
op_type: Some(op_type),
|
||||||
|
content: Some(content.into()),
|
||||||
|
repo: Some(Box::new(models::Repository {
|
||||||
|
full_name: Some("octo/demo".into()),
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decodes_linkable_activity_targets() {
|
||||||
|
assert_eq!(
|
||||||
|
target(&activity(OpType::CreateRepo, "")),
|
||||||
|
Some(Target::Repository {
|
||||||
|
owner: "octo".into(),
|
||||||
|
repository: "demo".into(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
target(&activity(OpType::CreatePullRequest, "42|feature")),
|
||||||
|
Some(Target::Pull {
|
||||||
|
owner: "octo".into(),
|
||||||
|
repository: "demo".into(),
|
||||||
|
number: 42,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
target(&activity(
|
||||||
|
OpType::CommitRepo,
|
||||||
|
r#"{"HeadCommit":{"Sha1":"0123456789abcdef"}}"#,
|
||||||
|
)),
|
||||||
|
Some(Target::Commit {
|
||||||
|
owner: "octo".into(),
|
||||||
|
repository: "demo".into(),
|
||||||
|
sha: "0123456789abcdef".into(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decodes_forgejo_commit_activity_for_presentation() {
|
||||||
|
let activity = activity(
|
||||||
|
OpType::CommitRepo,
|
||||||
|
r#"{
|
||||||
|
"Commits": [{
|
||||||
|
"Sha1": "0123456789abcdef",
|
||||||
|
"Message": "Fix the preview\n\nDetails",
|
||||||
|
"AuthorName": "Octo Cat",
|
||||||
|
"Timestamp": "2026-08-04T19:04:30+02:00"
|
||||||
|
}],
|
||||||
|
"HeadCommit": {"Sha1": "0123456789abcdef"},
|
||||||
|
"Len": 1
|
||||||
|
}"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
commit_activity(&activity),
|
||||||
|
Some(CommitActivity {
|
||||||
|
count: 1,
|
||||||
|
commits: vec![ActivityCommit {
|
||||||
|
sha: "0123456789abcdef".into(),
|
||||||
|
message: "Fix the preview\n\nDetails".into(),
|
||||||
|
author: "Octo Cat".into(),
|
||||||
|
timestamp: "2026-08-04T19:04:30+02:00".into(),
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filters_issue_and_pull_request_activity() {
|
||||||
|
let activities = [
|
||||||
|
activity(OpType::CreateIssue, ""),
|
||||||
|
activity(OpType::CreatePullRequest, ""),
|
||||||
|
activity(OpType::PullRequestReadyForReview, ""),
|
||||||
|
activity(OpType::CreateRepo, ""),
|
||||||
|
];
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
activities
|
||||||
|
.iter()
|
||||||
|
.filter(|activity| ActivityFilter::Issues.matches(activity))
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
activities
|
||||||
|
.iter()
|
||||||
|
.filter(|activity| ActivityFilter::PullRequests.matches(activity))
|
||||||
|
.count(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn merges_server_activity_incrementally_in_global_pages() {
|
||||||
|
let mut feeds = [VecDeque::new(), VecDeque::new()];
|
||||||
|
for id in 1..=65 {
|
||||||
|
feeds[id as usize % 2].push_front(models::Activity {
|
||||||
|
id: Some(id),
|
||||||
|
created: Some(format!("2026-08-15T12:{:02}:{:02}Z", id / 60, id % 60)),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut pager = ServerActivityPager {
|
||||||
|
configuration: Default::default(),
|
||||||
|
feeds: feeds
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, activities)| UserActivityFeed {
|
||||||
|
login: format!("user-{index}"),
|
||||||
|
activities,
|
||||||
|
next_page: 1,
|
||||||
|
complete: true,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let first = pager.next_page().await.unwrap();
|
||||||
|
let second = pager.next_page().await.unwrap();
|
||||||
|
let third = pager.next_page().await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(first.items.first().and_then(|row| row.id), Some(65));
|
||||||
|
assert_eq!(first.items.last().and_then(|row| row.id), Some(36));
|
||||||
|
assert!(first.has_more);
|
||||||
|
assert_eq!(second.items.first().and_then(|row| row.id), Some(35));
|
||||||
|
assert_eq!(second.items.last().and_then(|row| row.id), Some(6));
|
||||||
|
assert!(second.has_more);
|
||||||
|
assert_eq!(third.items.len(), 5);
|
||||||
|
assert!(!third.has_more);
|
||||||
|
}
|
||||||
|
}
|
||||||
542
crates/gitea/src/config.rs
Normal file
542
crates/gitea/src/config.rs
Normal file
@@ -0,0 +1,542 @@
|
|||||||
|
use std::{
|
||||||
|
collections::{BTreeMap, BTreeSet},
|
||||||
|
env, fs,
|
||||||
|
io::Write,
|
||||||
|
path::PathBuf,
|
||||||
|
process::{self, Command},
|
||||||
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{Client, Provider, RepositoryId, Url};
|
||||||
|
|
||||||
|
type Result<T> = std::result::Result<T, String>;
|
||||||
|
|
||||||
|
#[derive(Clone, Default, Deserialize, Serialize)]
|
||||||
|
pub struct ServerProfile {
|
||||||
|
pub url: String,
|
||||||
|
pub token: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub provider: Provider,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize, Serialize)]
|
||||||
|
pub struct TuiPreferences {
|
||||||
|
#[serde(default = "default_refresh_seconds")]
|
||||||
|
pub refresh_seconds: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub favorites: BTreeSet<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub last_server: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TuiPreferences {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
refresh_seconds: default_refresh_seconds(),
|
||||||
|
favorites: BTreeSet::new(),
|
||||||
|
last_server: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn default_refresh_seconds() -> u64 {
|
||||||
|
5
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Default, Deserialize, Serialize)]
|
||||||
|
pub struct Config {
|
||||||
|
#[serde(skip)]
|
||||||
|
path: PathBuf,
|
||||||
|
#[serde(default)]
|
||||||
|
pub servers: BTreeMap<String, ServerProfile>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tui: TuiPreferences,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Selection {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub url: String,
|
||||||
|
pub token: Option<String>,
|
||||||
|
pub provider: Provider,
|
||||||
|
pub repository: Option<RepositoryId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn load() -> Result<Self> {
|
||||||
|
let home = env::var_os("HOME").ok_or("HOME is not set")?;
|
||||||
|
Self::load_from(PathBuf::from(home).join(".config/gotcha/config"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_from(path: PathBuf) -> Result<Self> {
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(Self {
|
||||||
|
path,
|
||||||
|
..Self::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = fs::read_to_string(&path)
|
||||||
|
.map_err(|error| format!("cannot read {}: {error}", path.display()))?;
|
||||||
|
let mut config: Self = serde_yaml::from_str(&text)
|
||||||
|
.map_err(|error| format!("invalid {}: {error}", path.display()))?;
|
||||||
|
config.path = path;
|
||||||
|
for (name, server) in &config.servers {
|
||||||
|
validate_name(name)?;
|
||||||
|
Client::with_provider(&server.url, Some(&server.token), server.provider)
|
||||||
|
.map_err(|error| format!("invalid server {name}: {error}"))?;
|
||||||
|
}
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn login(&mut self, name: &str, token: &str, provider: Provider) -> Result<()> {
|
||||||
|
let url = server_url(name)?;
|
||||||
|
self.save_server(name, &url, token, provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_server(
|
||||||
|
&mut self,
|
||||||
|
name: &str,
|
||||||
|
url: &str,
|
||||||
|
token: &str,
|
||||||
|
provider: Provider,
|
||||||
|
) -> Result<()> {
|
||||||
|
validate_name(name)?;
|
||||||
|
Client::with_provider(url, Some(token), provider).map_err(|error| error.to_string())?;
|
||||||
|
let mut updated = self.clone();
|
||||||
|
updated.servers.insert(
|
||||||
|
name.into(),
|
||||||
|
ServerProfile {
|
||||||
|
url: url.trim_end_matches('/').into(),
|
||||||
|
token: token.into(),
|
||||||
|
provider,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
updated.save()?;
|
||||||
|
*self = updated;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn replace_server(
|
||||||
|
&mut self,
|
||||||
|
original_name: &str,
|
||||||
|
name: &str,
|
||||||
|
url: &str,
|
||||||
|
token: &str,
|
||||||
|
provider: Provider,
|
||||||
|
) -> Result<()> {
|
||||||
|
if !self.servers.contains_key(original_name) {
|
||||||
|
return Err(format!("server profile {original_name:?} does not exist"));
|
||||||
|
}
|
||||||
|
validate_name(name)?;
|
||||||
|
Client::with_provider(url, Some(token), provider).map_err(|error| error.to_string())?;
|
||||||
|
let mut updated = self.clone();
|
||||||
|
let old_url = updated
|
||||||
|
.servers
|
||||||
|
.get(original_name)
|
||||||
|
.map(|server| server.url.clone())
|
||||||
|
.expect("profile existence checked above");
|
||||||
|
let new_url = url.trim_end_matches('/');
|
||||||
|
updated.servers.remove(original_name);
|
||||||
|
updated.servers.insert(
|
||||||
|
name.into(),
|
||||||
|
ServerProfile {
|
||||||
|
url: new_url.into(),
|
||||||
|
token: token.into(),
|
||||||
|
provider,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if old_url != new_url {
|
||||||
|
migrate_favorites(&mut updated.tui.favorites, &old_url, new_url);
|
||||||
|
}
|
||||||
|
if updated.tui.last_server.as_deref() == Some(original_name) {
|
||||||
|
updated.tui.last_server = Some(name.into());
|
||||||
|
}
|
||||||
|
updated.save()?;
|
||||||
|
*self = updated;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn logout(&mut self, name: &str) -> Result<()> {
|
||||||
|
if !self.servers.contains_key(name) {
|
||||||
|
return Err(format!("server profile {name:?} does not exist"));
|
||||||
|
}
|
||||||
|
let mut updated = self.clone();
|
||||||
|
let removed_url = updated.servers[name].url.clone();
|
||||||
|
updated.servers.remove(name);
|
||||||
|
if !updated
|
||||||
|
.servers
|
||||||
|
.values()
|
||||||
|
.any(|server| server.url == removed_url)
|
||||||
|
{
|
||||||
|
remove_favorites(&mut updated.tui.favorites, &removed_url);
|
||||||
|
}
|
||||||
|
if updated.tui.last_server.as_deref() == Some(name) {
|
||||||
|
updated.tui.last_server = updated.servers.keys().next().cloned();
|
||||||
|
}
|
||||||
|
updated.save()?;
|
||||||
|
*self = updated;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_tui_refresh_seconds(&mut self, seconds: u64) -> Result<()> {
|
||||||
|
let mut updated = self.clone();
|
||||||
|
updated.tui.refresh_seconds = seconds;
|
||||||
|
updated.save()?;
|
||||||
|
*self = updated;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_tui_last_server(&mut self, name: Option<&str>) -> Result<()> {
|
||||||
|
if let Some(name) = name
|
||||||
|
&& !self.servers.contains_key(name)
|
||||||
|
{
|
||||||
|
return Err(format!("server profile {name:?} does not exist"));
|
||||||
|
}
|
||||||
|
if self.tui.last_server.as_deref() == name {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let mut updated = self.clone();
|
||||||
|
updated.tui.last_server = name.map(str::to_owned);
|
||||||
|
updated.save()?;
|
||||||
|
*self = updated;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_tui_favorite(&self, pane: &str, server_url: &str, repository: &RepositoryId) -> bool {
|
||||||
|
self.tui
|
||||||
|
.favorites
|
||||||
|
.contains(&favorite_key(pane, server_url, repository))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_tui_favorite(
|
||||||
|
&mut self,
|
||||||
|
pane: &str,
|
||||||
|
server_url: &str,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
) -> Result<()> {
|
||||||
|
let key = favorite_key(pane, server_url, repository);
|
||||||
|
let mut updated = self.clone();
|
||||||
|
if !updated.tui.favorites.remove(&key) {
|
||||||
|
updated.tui.favorites.insert(key);
|
||||||
|
}
|
||||||
|
updated.save()?;
|
||||||
|
*self = updated;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select(&self, name: Option<&str>, url: Option<&str>) -> Result<Selection> {
|
||||||
|
if name.is_some() && url.is_some() {
|
||||||
|
return Err("use either --server or --url, not both".into());
|
||||||
|
}
|
||||||
|
let remotes = git_remotes().unwrap_or_default();
|
||||||
|
|
||||||
|
if let Some(name) = name {
|
||||||
|
let server = self
|
||||||
|
.servers
|
||||||
|
.get(name)
|
||||||
|
.ok_or_else(|| format!("server profile {name:?} does not exist"))?;
|
||||||
|
return Ok(selection(Some(name), server, &remotes));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(url) = url {
|
||||||
|
let matches: Vec<_> = self
|
||||||
|
.servers
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, server)| same_instance(&server.url, url))
|
||||||
|
.collect();
|
||||||
|
return match matches.as_slice() {
|
||||||
|
[] => Ok(selection(
|
||||||
|
None,
|
||||||
|
&ServerProfile {
|
||||||
|
url: url.into(),
|
||||||
|
token: String::new(),
|
||||||
|
provider: Provider::Gitea,
|
||||||
|
},
|
||||||
|
&remotes,
|
||||||
|
)),
|
||||||
|
[(name, server)] => Ok(selection(Some(name.as_str()), server, &remotes)),
|
||||||
|
_ => Err("multiple profiles use that URL; select one with --server".into()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut matches = BTreeMap::new();
|
||||||
|
for (name, server) in &self.servers {
|
||||||
|
if let Some(scope) = remotes
|
||||||
|
.iter()
|
||||||
|
.find_map(|remote| repository_scope(&server.url, remote))
|
||||||
|
{
|
||||||
|
matches.insert(name, (server, scope));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match matches.into_iter().collect::<Vec<_>>().as_slice() {
|
||||||
|
[] if self.servers.is_empty() => {
|
||||||
|
Err("no servers configured; run `gotcha auth login SERVER`".into())
|
||||||
|
}
|
||||||
|
[] => Err("no configured server matches this Git repository; use --server NAME".into()),
|
||||||
|
[(name, (server, scope))] => Ok(Selection {
|
||||||
|
name: Some((*name).clone()),
|
||||||
|
url: server.url.clone(),
|
||||||
|
token: Some(server.token.clone()),
|
||||||
|
provider: server.provider,
|
||||||
|
repository: Some(scope.clone()),
|
||||||
|
}),
|
||||||
|
_ => Err("multiple server profiles match this Git repository; use --server".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save(&self) -> Result<()> {
|
||||||
|
let parent = self
|
||||||
|
.path
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| format!("invalid config path: {}", self.path.display()))?;
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.map_err(|error| format!("cannot create {}: {error}", parent.display()))?;
|
||||||
|
|
||||||
|
let nonce = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map_err(|error| error.to_string())?
|
||||||
|
.as_nanos();
|
||||||
|
let temporary = parent.join(format!(".config.{}.{nonce}.tmp", process::id()));
|
||||||
|
let text = serde_yaml::to_string(self).map_err(|error| error.to_string())?;
|
||||||
|
let result = (|| -> std::io::Result<()> {
|
||||||
|
let mut options = fs::OpenOptions::new();
|
||||||
|
options.write(true).create_new(true);
|
||||||
|
#[cfg(unix)]
|
||||||
|
options.mode(0o600);
|
||||||
|
let mut file = options.open(&temporary)?;
|
||||||
|
file.write_all(text.as_bytes())?;
|
||||||
|
file.sync_all()?;
|
||||||
|
fs::rename(&temporary, &self.path)?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
fs::set_permissions(&self.path, fs::Permissions::from_mode(0o600))?;
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
if result.is_err() {
|
||||||
|
let _ = fs::remove_file(&temporary);
|
||||||
|
}
|
||||||
|
result.map_err(|error| format!("cannot write {}: {error}", self.path.display()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn favorite_key(pane: &str, server_url: &str, repository: &RepositoryId) -> String {
|
||||||
|
format!(
|
||||||
|
"{pane}|{}|{}/{}",
|
||||||
|
server_url.trim_end_matches('/'),
|
||||||
|
repository.owner,
|
||||||
|
repository.repository
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn migrate_favorites(favorites: &mut BTreeSet<String>, old_url: &str, new_url: &str) {
|
||||||
|
let old = format!("|{}|", old_url.trim_end_matches('/'));
|
||||||
|
let new = format!("|{}|", new_url.trim_end_matches('/'));
|
||||||
|
let migrations: Vec<_> = favorites
|
||||||
|
.iter()
|
||||||
|
.filter(|key| key.contains(&old))
|
||||||
|
.map(|key| (key.clone(), key.replacen(&old, &new, 1)))
|
||||||
|
.collect();
|
||||||
|
for (old, new) in migrations {
|
||||||
|
favorites.remove(&old);
|
||||||
|
favorites.insert(new);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_favorites(favorites: &mut BTreeSet<String>, server_url: &str) {
|
||||||
|
let server = format!("|{}|", server_url.trim_end_matches('/'));
|
||||||
|
favorites.retain(|key| !key.contains(&server));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selection(name: Option<&str>, server: &ServerProfile, remotes: &[String]) -> Selection {
|
||||||
|
Selection {
|
||||||
|
name: name.map(str::to_owned),
|
||||||
|
url: server.url.clone(),
|
||||||
|
token: (!server.token.is_empty()).then(|| server.token.clone()),
|
||||||
|
provider: server.provider,
|
||||||
|
repository: remotes
|
||||||
|
.iter()
|
||||||
|
.find_map(|remote| repository_scope(&server.url, remote)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_name(name: &str) -> Result<()> {
|
||||||
|
server_url(name)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn server_url(name: &str) -> Result<String> {
|
||||||
|
if name.is_empty()
|
||||||
|
|| name.contains('/')
|
||||||
|
|| name.contains('@')
|
||||||
|
|| name.chars().any(char::is_whitespace)
|
||||||
|
{
|
||||||
|
return Err("server name must be a hostname, optionally followed by a port".into());
|
||||||
|
}
|
||||||
|
let url = format!("https://{name}");
|
||||||
|
let parsed = Url::parse(&url).map_err(|_| "invalid server name")?;
|
||||||
|
if parsed.host_str().is_none() || parsed.path() != "/" {
|
||||||
|
return Err("server name must be a hostname, optionally followed by a port".into());
|
||||||
|
}
|
||||||
|
if parsed.query().is_some() || parsed.fragment().is_some() {
|
||||||
|
return Err("server name must be a hostname, optionally followed by a port".into());
|
||||||
|
}
|
||||||
|
Ok(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn same_instance(left: &str, right: &str) -> bool {
|
||||||
|
let api_url = |url| {
|
||||||
|
Client::new(url, None)
|
||||||
|
.ok()
|
||||||
|
.map(|client| client.api_url().clone())
|
||||||
|
};
|
||||||
|
api_url(left) == api_url(right)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git_remotes() -> Result<Vec<String>> {
|
||||||
|
let output = Command::new("git")
|
||||||
|
.args(["config", "--get-regexp", r"^remote\..*\.url$"])
|
||||||
|
.output()
|
||||||
|
.map_err(|error| format!("cannot inspect Git remotes: {error}"))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(String::from_utf8_lossy(&output.stdout)
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| line.split_once(char::is_whitespace))
|
||||||
|
.map(|(_, url)| url.trim().to_owned())
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn repository_scope(server_url: &str, remote: &str) -> Option<RepositoryId> {
|
||||||
|
let server = Url::parse(server_url).ok()?;
|
||||||
|
let server_host = server.host_str()?;
|
||||||
|
let (remote_host, mut remote_path, is_http) = if let Ok(url) = Url::parse(remote) {
|
||||||
|
(
|
||||||
|
url.host_str()?.to_owned(),
|
||||||
|
url.path().trim_matches('/').to_owned(),
|
||||||
|
matches!(url.scheme(), "http" | "https"),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let remote = remote.rsplit_once('@').map_or(remote, |(_, rest)| rest);
|
||||||
|
let (host, path) = remote.split_once(':')?;
|
||||||
|
(host.into(), path.trim_matches('/').into(), false)
|
||||||
|
};
|
||||||
|
if !server_host.eq_ignore_ascii_case(&remote_host) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let prefix = server.path().trim_matches('/');
|
||||||
|
if is_http && !prefix.is_empty() {
|
||||||
|
remote_path = remote_path
|
||||||
|
.strip_prefix(prefix)?
|
||||||
|
.strip_prefix('/')?
|
||||||
|
.to_owned();
|
||||||
|
}
|
||||||
|
let parts: Vec<_> = remote_path
|
||||||
|
.split('/')
|
||||||
|
.filter(|part| !part.is_empty())
|
||||||
|
.collect();
|
||||||
|
let [.., owner, repository] = parts.as_slice() else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let repository = repository.strip_suffix(".git").unwrap_or(repository);
|
||||||
|
(!owner.is_empty() && !repository.is_empty()).then(|| RepositoryId {
|
||||||
|
owner: (*owner).into(),
|
||||||
|
repository: repository.into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_round_trip_and_remote_scope() {
|
||||||
|
let directory = env::temp_dir().join(format!(
|
||||||
|
"gotcha-config-test-{}-{}",
|
||||||
|
process::id(),
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos()
|
||||||
|
));
|
||||||
|
let path = directory.join("config");
|
||||||
|
let mut config = Config {
|
||||||
|
path: path.clone(),
|
||||||
|
..Config::default()
|
||||||
|
};
|
||||||
|
assert_eq!(config.tui.refresh_seconds, 5);
|
||||||
|
assert_eq!(
|
||||||
|
config.select(None, None).err().unwrap(),
|
||||||
|
"no servers configured; run `gotcha auth login SERVER`"
|
||||||
|
);
|
||||||
|
config
|
||||||
|
.login("code.example", "secret", Provider::Forgejo)
|
||||||
|
.unwrap();
|
||||||
|
config.set_tui_refresh_seconds(9).unwrap();
|
||||||
|
config.set_tui_last_server(Some("code.example")).unwrap();
|
||||||
|
|
||||||
|
let mut loaded = Config::load_from(path).unwrap();
|
||||||
|
assert_eq!(loaded.tui.refresh_seconds, 9);
|
||||||
|
assert_eq!(loaded.tui.last_server.as_deref(), Some("code.example"));
|
||||||
|
assert_eq!(loaded.servers["code.example"].token, "secret");
|
||||||
|
assert_eq!(loaded.servers["code.example"].provider, Provider::Forgejo);
|
||||||
|
assert_eq!(
|
||||||
|
loaded.select(Some("code.example"), None).unwrap().provider,
|
||||||
|
Provider::Forgejo
|
||||||
|
);
|
||||||
|
let repository = RepositoryId {
|
||||||
|
owner: "alice".into(),
|
||||||
|
repository: "project".into(),
|
||||||
|
};
|
||||||
|
loaded
|
||||||
|
.toggle_tui_favorite("issues", "https://code.example", &repository)
|
||||||
|
.unwrap();
|
||||||
|
assert!(loaded.is_tui_favorite("issues", "https://code.example", &repository));
|
||||||
|
loaded
|
||||||
|
.replace_server(
|
||||||
|
"code.example",
|
||||||
|
"new.example",
|
||||||
|
"https://new.example",
|
||||||
|
"new-secret",
|
||||||
|
Provider::Gitea,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(loaded.is_tui_favorite("issues", "https://new.example", &repository));
|
||||||
|
assert!(!loaded.servers.contains_key("code.example"));
|
||||||
|
assert_eq!(loaded.servers["new.example"].token, "new-secret");
|
||||||
|
assert_eq!(loaded.tui.last_server.as_deref(), Some("new.example"));
|
||||||
|
assert!(
|
||||||
|
fs::read_to_string(&loaded.path)
|
||||||
|
.unwrap()
|
||||||
|
.contains("provider: gitea")
|
||||||
|
);
|
||||||
|
let legacy: ServerProfile =
|
||||||
|
serde_yaml::from_str("url: https://gitea.example.com\ntoken: secret\n").unwrap();
|
||||||
|
assert_eq!(legacy.provider, Provider::Gitea);
|
||||||
|
let scope = repository_scope(
|
||||||
|
"https://code.example/gitea",
|
||||||
|
"https://code.example/gitea/alice/project.git",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(scope.owner.as_str(), scope.repository.as_str()),
|
||||||
|
("alice", "project")
|
||||||
|
);
|
||||||
|
#[cfg(unix)]
|
||||||
|
assert_eq!(
|
||||||
|
fs::metadata(&loaded.path).unwrap().permissions().mode() & 0o777,
|
||||||
|
0o600
|
||||||
|
);
|
||||||
|
loaded.logout("new.example").unwrap();
|
||||||
|
assert_eq!(loaded.tui.last_server, None);
|
||||||
|
|
||||||
|
fs::remove_dir_all(directory).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
190
crates/gitea/src/diff.rs
Normal file
190
crates/gitea/src/diff.rs
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
#[derive(Debug, Eq, PartialEq)]
|
||||||
|
pub struct Line {
|
||||||
|
pub old_number: String,
|
||||||
|
pub new_number: String,
|
||||||
|
pub text: String,
|
||||||
|
pub kind: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Parsed {
|
||||||
|
pub lines: Vec<Line>,
|
||||||
|
pub columns: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_file(diff: &str, path: &str) -> Parsed {
|
||||||
|
let section = sections(diff)
|
||||||
|
.into_iter()
|
||||||
|
.find(|section| section_path(section).as_deref() == Some(path))
|
||||||
|
.unwrap_or("");
|
||||||
|
if section.is_empty() {
|
||||||
|
let text = format!("No diff data is available for {path}.");
|
||||||
|
return Parsed {
|
||||||
|
columns: text.chars().count(),
|
||||||
|
lines: vec![Line {
|
||||||
|
old_number: String::new(),
|
||||||
|
new_number: String::new(),
|
||||||
|
text,
|
||||||
|
kind: "header",
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let mut old = 0;
|
||||||
|
let mut new = 0;
|
||||||
|
let mut columns = 0;
|
||||||
|
let lines = section
|
||||||
|
.lines()
|
||||||
|
.map(|text| {
|
||||||
|
columns = columns.max(display_columns(text));
|
||||||
|
let (old_number, new_number, kind) = if text.starts_with("@@") {
|
||||||
|
if let Some((old_start, new_start)) = hunk_starts(text) {
|
||||||
|
old = old_start;
|
||||||
|
new = new_start;
|
||||||
|
}
|
||||||
|
(String::new(), String::new(), "hunk")
|
||||||
|
} else if text.starts_with('+') && !text.starts_with("+++") {
|
||||||
|
let number = new.to_string();
|
||||||
|
new += 1;
|
||||||
|
(String::new(), number, "addition")
|
||||||
|
} else if text.starts_with('-') && !text.starts_with("---") {
|
||||||
|
let number = old.to_string();
|
||||||
|
old += 1;
|
||||||
|
(number, String::new(), "removal")
|
||||||
|
} else if text.starts_with(' ') {
|
||||||
|
let numbers = (old.to_string(), new.to_string());
|
||||||
|
old += 1;
|
||||||
|
new += 1;
|
||||||
|
(numbers.0, numbers.1, "context")
|
||||||
|
} else {
|
||||||
|
(String::new(), String::new(), "header")
|
||||||
|
};
|
||||||
|
Line {
|
||||||
|
old_number,
|
||||||
|
new_number,
|
||||||
|
text: text.into(),
|
||||||
|
kind,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Parsed { lines, columns }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sections(diff: &str) -> Vec<&str> {
|
||||||
|
let mut starts: Vec<_> = diff
|
||||||
|
.match_indices("diff --git ")
|
||||||
|
.map(|(index, _)| index)
|
||||||
|
.collect();
|
||||||
|
starts.push(diff.len());
|
||||||
|
starts
|
||||||
|
.windows(2)
|
||||||
|
.map(move |bounds| &diff[bounds[0]..bounds[1]])
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn section_path(section: &str) -> Option<String> {
|
||||||
|
section
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| line.strip_prefix("+++ "))
|
||||||
|
.filter(|path| *path != "/dev/null")
|
||||||
|
.or_else(|| {
|
||||||
|
section
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| line.strip_prefix("--- "))
|
||||||
|
.filter(|path| *path != "/dev/null")
|
||||||
|
})
|
||||||
|
.map(|path| {
|
||||||
|
decode_path(path)
|
||||||
|
.trim_start_matches("a/")
|
||||||
|
.trim_start_matches("b/")
|
||||||
|
.into()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_path(path: &str) -> String {
|
||||||
|
let path = path.trim_matches('"');
|
||||||
|
let mut bytes = Vec::with_capacity(path.len());
|
||||||
|
let mut chars = path.bytes();
|
||||||
|
while let Some(byte) = chars.next() {
|
||||||
|
if byte != b'\\' {
|
||||||
|
bytes.push(byte);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(escaped) = chars.next() else { break };
|
||||||
|
match escaped {
|
||||||
|
b'n' => bytes.push(b'\n'),
|
||||||
|
b'r' => bytes.push(b'\r'),
|
||||||
|
b't' => bytes.push(b'\t'),
|
||||||
|
b'\\' | b'"' => bytes.push(escaped),
|
||||||
|
b'0'..=b'7' => {
|
||||||
|
let mut value = escaped - b'0';
|
||||||
|
for _ in 0..2 {
|
||||||
|
let Some(next) = chars.next() else { break };
|
||||||
|
if !(b'0'..=b'7').contains(&next) {
|
||||||
|
bytes.push(value);
|
||||||
|
bytes.push(next);
|
||||||
|
value = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
value = value * 8 + next - b'0';
|
||||||
|
}
|
||||||
|
if value != 0 {
|
||||||
|
bytes.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => bytes.push(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&bytes).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hunk_starts(line: &str) -> Option<(i32, i32)> {
|
||||||
|
let mut parts = line.split_whitespace();
|
||||||
|
(parts.next()? == "@@").then_some(())?;
|
||||||
|
Some((
|
||||||
|
range_start(parts.next()?, '-'),
|
||||||
|
range_start(parts.next()?, '+'),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn range_start(value: &str, prefix: char) -> i32 {
|
||||||
|
value
|
||||||
|
.strip_prefix(prefix)
|
||||||
|
.and_then(|value| value.split(',').next())
|
||||||
|
.and_then(|value| value.parse().ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn display_columns(line: &str) -> usize {
|
||||||
|
line.chars().fold(0, |column, character| {
|
||||||
|
if character == '\t' {
|
||||||
|
(column / 8 + 1) * 8
|
||||||
|
} else {
|
||||||
|
column + 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selects_and_numbers_a_file_diff() {
|
||||||
|
let diff = "diff --git a/one b/one\n--- a/one\n+++ b/one\n@@ -1 +1 @@\n-old\n+new\n\
|
||||||
|
diff --git a/two b/two\n--- a/two\n+++ b/two\n@@ -4,2 +8,3 @@\n context\n-removed\n+added\n";
|
||||||
|
let parsed = parse_file(diff, "two");
|
||||||
|
assert_eq!(parsed.lines[4].old_number, "4");
|
||||||
|
assert_eq!(parsed.lines[4].new_number, "8");
|
||||||
|
assert_eq!(parsed.lines[5].kind, "removal");
|
||||||
|
assert_eq!(parsed.lines[6].kind, "addition");
|
||||||
|
assert!(parsed.lines.iter().all(|line| !line.text.contains("old")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decodes_git_quoted_paths() {
|
||||||
|
let diff = "diff --git \"a/caf\\303\\251\" \"b/caf\\303\\251\"\n--- \"a/caf\\303\\251\"\n+++ \"b/caf\\303\\251\"\n@@ -0,0 +1 @@\n+hello\n";
|
||||||
|
assert_eq!(
|
||||||
|
parse_file(diff, "café").lines.last().unwrap().kind,
|
||||||
|
"addition"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
259
crates/gitea/src/domain.rs
Normal file
259
crates/gitea/src/domain.rs
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
use crate::models;
|
||||||
|
|
||||||
|
pub const DEFAULT_PAGE_SIZE: i32 = 30;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct RepositoryId {
|
||||||
|
pub owner: String,
|
||||||
|
pub repository: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RepositoryId {
|
||||||
|
pub fn new(owner: impl Into<String>, repository: impl Into<String>) -> crate::Result<Self> {
|
||||||
|
let result = Self {
|
||||||
|
owner: owner.into(),
|
||||||
|
repository: repository.into(),
|
||||||
|
};
|
||||||
|
if result.owner.is_empty()
|
||||||
|
|| result.repository.is_empty()
|
||||||
|
|| result.owner.contains('/')
|
||||||
|
|| result.repository.contains('/')
|
||||||
|
{
|
||||||
|
return Err(crate::Error::InvalidInput(
|
||||||
|
"repository must be OWNER/REPOSITORY".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(value: &str) -> crate::Result<Self> {
|
||||||
|
let (owner, repository) = value.split_once('/').ok_or_else(|| {
|
||||||
|
crate::Error::InvalidInput("repository must be OWNER/REPOSITORY".into())
|
||||||
|
})?;
|
||||||
|
Self::new(owner, repository)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Page<T> {
|
||||||
|
pub items: Vec<T>,
|
||||||
|
pub has_more: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Page<T> {
|
||||||
|
pub fn from_items(items: Vec<T>, limit: i32) -> Self {
|
||||||
|
Self {
|
||||||
|
has_more: limit > 0 && items.len() == limit as usize,
|
||||||
|
items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct IssueQuery {
|
||||||
|
pub state: String,
|
||||||
|
pub labels: Option<String>,
|
||||||
|
pub keyword: Option<String>,
|
||||||
|
pub kind: String,
|
||||||
|
pub milestones: Option<String>,
|
||||||
|
pub from: Option<String>,
|
||||||
|
pub until: Option<String>,
|
||||||
|
pub author: Option<String>,
|
||||||
|
pub assignee: Option<String>,
|
||||||
|
pub mentions: Option<String>,
|
||||||
|
pub page: i32,
|
||||||
|
pub limit: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for IssueQuery {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
state: "open".into(),
|
||||||
|
labels: None,
|
||||||
|
keyword: None,
|
||||||
|
kind: "issues".into(),
|
||||||
|
milestones: None,
|
||||||
|
from: None,
|
||||||
|
until: None,
|
||||||
|
author: None,
|
||||||
|
assignee: None,
|
||||||
|
mentions: None,
|
||||||
|
page: 1,
|
||||||
|
limit: DEFAULT_PAGE_SIZE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct CreateIssue {
|
||||||
|
pub option: models::CreateIssueOption,
|
||||||
|
pub label_names: Vec<String>,
|
||||||
|
pub milestone_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct EditIssue {
|
||||||
|
pub option: models::EditIssueOption,
|
||||||
|
pub replace_labels: Option<Vec<i64>>,
|
||||||
|
pub add_labels: Vec<String>,
|
||||||
|
pub remove_labels: Vec<String>,
|
||||||
|
pub add_assignees: Vec<String>,
|
||||||
|
pub milestone_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct IssueDraft {
|
||||||
|
pub title: String,
|
||||||
|
pub body: String,
|
||||||
|
pub label_ids: Vec<i64>,
|
||||||
|
pub milestone_id: Option<i64>,
|
||||||
|
pub due_date: Option<String>,
|
||||||
|
pub closed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct IssueDetails {
|
||||||
|
pub issue: models::Issue,
|
||||||
|
pub comments: Vec<models::Comment>,
|
||||||
|
pub viewer_id: Option<i64>,
|
||||||
|
pub has_more: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct IssueEditorData {
|
||||||
|
pub issue: Option<models::Issue>,
|
||||||
|
pub labels: Vec<models::Label>,
|
||||||
|
pub milestones: Vec<models::Milestone>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct MilestoneDraft {
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
pub due_on: Option<String>,
|
||||||
|
pub state: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct MilestoneDetails {
|
||||||
|
pub milestone: models::Milestone,
|
||||||
|
pub issues: Vec<models::Issue>,
|
||||||
|
pub pulls: Vec<models::Issue>,
|
||||||
|
pub has_more: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct PullDetails {
|
||||||
|
pub pull: models::PullRequest,
|
||||||
|
pub comments: Vec<models::Comment>,
|
||||||
|
pub files: Vec<models::ChangedFile>,
|
||||||
|
pub has_more: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct HistoryCommit {
|
||||||
|
pub commit: models::Commit,
|
||||||
|
pub top_lanes: Vec<usize>,
|
||||||
|
pub bottom_lanes: Vec<usize>,
|
||||||
|
pub node_lane: Option<usize>,
|
||||||
|
pub top_connections: Vec<usize>,
|
||||||
|
pub bottom_connections: Vec<usize>,
|
||||||
|
pub refs: Vec<String>,
|
||||||
|
pub branch_starts: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct HomeData {
|
||||||
|
pub activities: Vec<models::Activity>,
|
||||||
|
pub heatmap: Vec<models::UserHeatmapData>,
|
||||||
|
pub next_page: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn api_date(timestamp: i64) -> String {
|
||||||
|
let (year, month, day) = civil_from_days(timestamp.div_euclid(86_400));
|
||||||
|
format!("{year:04}-{month:02}-{day:02}T00:00:00Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn api_timestamp(timestamp: i64) -> String {
|
||||||
|
let days = timestamp.div_euclid(86_400);
|
||||||
|
let seconds = timestamp.rem_euclid(86_400);
|
||||||
|
let (year, month, day) = civil_from_days(days);
|
||||||
|
let hour = seconds / 3_600;
|
||||||
|
let minute = seconds % 3_600 / 60;
|
||||||
|
let second = seconds % 60;
|
||||||
|
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_api_date(value: &str) -> Option<i64> {
|
||||||
|
let date = value.get(..10)?;
|
||||||
|
let mut parts = date.split('-');
|
||||||
|
let year = parts.next()?.parse().ok()?;
|
||||||
|
let month = parts.next()?.parse().ok()?;
|
||||||
|
let day = parts.next()?.parse().ok()?;
|
||||||
|
if parts.next().is_some() || !(1..=12).contains(&month) || !(1..=31).contains(&day) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let days = days_from_civil(year, month, day);
|
||||||
|
(civil_from_days(days) == (year, month, day)).then_some(days * 86_400)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_api_timestamp(value: &str) -> Option<i64> {
|
||||||
|
let date = parse_api_date(value)?;
|
||||||
|
let hour = value.get(11..13)?.parse::<i64>().ok()?;
|
||||||
|
let minute = value.get(14..16)?.parse::<i64>().ok()?;
|
||||||
|
let second = value.get(17..19)?.parse::<i64>().ok()?;
|
||||||
|
(hour < 24 && minute < 60 && second < 60).then_some(date + hour * 3_600 + minute * 60 + second)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn civil_from_days(days: i64) -> (i64, i64, i64) {
|
||||||
|
let days = days + 719_468;
|
||||||
|
let era = days.div_euclid(146_097);
|
||||||
|
let day_of_era = days - era * 146_097;
|
||||||
|
let year_of_era =
|
||||||
|
(day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
|
||||||
|
let mut year = year_of_era + era * 400;
|
||||||
|
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
|
||||||
|
let month_prime = (5 * day_of_year + 2) / 153;
|
||||||
|
let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
|
||||||
|
let month = month_prime + if month_prime < 10 { 3 } else { -9 };
|
||||||
|
year += i64::from(month <= 2);
|
||||||
|
(year, month, day)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn days_from_civil(mut year: i64, month: i64, day: i64) -> i64 {
|
||||||
|
year -= i64::from(month <= 2);
|
||||||
|
let era = year.div_euclid(400);
|
||||||
|
let year_of_era = year - era * 400;
|
||||||
|
let month_prime = month + if month > 2 { -3 } else { 9 };
|
||||||
|
let day_of_year = (153 * month_prime + 2) / 5 + day - 1;
|
||||||
|
let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
|
||||||
|
era * 146_097 + day_of_era - 719_468
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validates_repositories_pages_and_dates() {
|
||||||
|
assert_eq!(
|
||||||
|
RepositoryId::parse("alice/project").unwrap(),
|
||||||
|
RepositoryId {
|
||||||
|
owner: "alice".into(),
|
||||||
|
repository: "project".into(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert!(RepositoryId::parse("project").is_err());
|
||||||
|
assert!(Page::from_items(vec![(); 30], 30).has_more);
|
||||||
|
assert!(!Page::from_items(vec![(); 29], 30).has_more);
|
||||||
|
let leap_day = parse_api_date("2024-02-29T12:34:56Z").unwrap();
|
||||||
|
assert_eq!(api_date(leap_day), "2024-02-29T00:00:00Z");
|
||||||
|
assert_eq!(api_timestamp(leap_day + 45_296), "2024-02-29T12:34:56Z");
|
||||||
|
assert_eq!(
|
||||||
|
parse_api_timestamp("2024-02-29T12:34:56.123Z"),
|
||||||
|
Some(leap_day + 45_296)
|
||||||
|
);
|
||||||
|
assert_eq!(parse_api_date("2023-02-29T00:00:00Z"), None);
|
||||||
|
assert_eq!(parse_api_timestamp("2024-02-29T25:00:00Z"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
467
crates/gitea/src/issues.rs
Normal file
467
crates/gitea/src/issues.rs
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
use crate::{
|
||||||
|
Client, Error, Result,
|
||||||
|
domain::{
|
||||||
|
CreateIssue, EditIssue, IssueDetails, IssueDraft, IssueEditorData, IssueQuery, Page,
|
||||||
|
RepositoryId,
|
||||||
|
},
|
||||||
|
models, positive,
|
||||||
|
};
|
||||||
|
use gitea_openapi::apis;
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
pub async fn issues(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
query: &IssueQuery,
|
||||||
|
) -> Result<Page<models::Issue>> {
|
||||||
|
if !matches!(query.state.as_str(), "open" | "closed" | "all") {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"issue state must be open, closed, or all".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !matches!(query.kind.as_str(), "issues" | "pulls" | "all") {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"issue kind must be issues, pulls, or all".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if query.page < 1 || query.limit < 1 {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"issue page and limit must be positive".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
apis::issue_api::issue_list_issues(
|
||||||
|
&self.configuration(),
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
Some(&query.state),
|
||||||
|
query.labels.as_deref(),
|
||||||
|
query.keyword.as_deref(),
|
||||||
|
Some(&query.kind),
|
||||||
|
query.milestones.as_deref(),
|
||||||
|
query.from.clone(),
|
||||||
|
query.until.clone(),
|
||||||
|
query.author.as_deref(),
|
||||||
|
query.assignee.as_deref(),
|
||||||
|
query.mentions.as_deref(),
|
||||||
|
Some(query.page),
|
||||||
|
Some(query.limit),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|items| Page::from_items(items, query.limit))
|
||||||
|
.map_err(Error::generated)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn issue(&self, repository: &RepositoryId, number: i64) -> Result<models::Issue> {
|
||||||
|
positive(number, "issue number")?;
|
||||||
|
apis::issue_api::issue_get_issue(
|
||||||
|
&self.configuration(),
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
number,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Error::generated)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn issue_details(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
number: i64,
|
||||||
|
) -> Result<IssueDetails> {
|
||||||
|
let (issue, comments, viewer) = tokio::join!(
|
||||||
|
self.issue(repository, number),
|
||||||
|
self.issue_comments(repository, number),
|
||||||
|
self.current_user(),
|
||||||
|
);
|
||||||
|
Ok(IssueDetails {
|
||||||
|
issue: issue?,
|
||||||
|
comments: comments?,
|
||||||
|
viewer_id: viewer?.id,
|
||||||
|
has_more: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn issue_editor(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
number: Option<i64>,
|
||||||
|
) -> Result<IssueEditorData> {
|
||||||
|
let issue = async {
|
||||||
|
match number {
|
||||||
|
Some(number) => self.issue(repository, number).await.map(Some),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (issue, labels, milestones) =
|
||||||
|
tokio::try_join!(issue, self.labels(repository), self.milestones(repository),)?;
|
||||||
|
Ok(IssueEditorData {
|
||||||
|
issue,
|
||||||
|
labels,
|
||||||
|
milestones,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn labels(&self, repository: &RepositoryId) -> Result<Vec<models::Label>> {
|
||||||
|
let configuration = self.configuration();
|
||||||
|
let mut labels = Vec::new();
|
||||||
|
for page in 1.. {
|
||||||
|
let batch = apis::issue_api::issue_list_labels(
|
||||||
|
&configuration,
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
Some(page),
|
||||||
|
Some(100),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Error::generated)?;
|
||||||
|
let done = batch.len() < 100;
|
||||||
|
labels.extend(batch);
|
||||||
|
if done {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(labels)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn resolve_label_ids(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
names: &[String],
|
||||||
|
) -> Result<Vec<i64>> {
|
||||||
|
if names.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let labels = self.labels(repository).await?;
|
||||||
|
names
|
||||||
|
.iter()
|
||||||
|
.map(|name| {
|
||||||
|
labels
|
||||||
|
.iter()
|
||||||
|
.find(|label| label.name.as_deref() == Some(name))
|
||||||
|
.and_then(|label| label.id)
|
||||||
|
.ok_or_else(|| Error::InvalidInput(format!("unknown label {name:?}")))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_issue(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
mut input: CreateIssue,
|
||||||
|
) -> Result<models::Issue> {
|
||||||
|
if input.option.title.trim().is_empty() {
|
||||||
|
return Err(Error::InvalidInput("issue title must not be empty".into()));
|
||||||
|
}
|
||||||
|
if input.option.milestone.is_some() && input.milestone_name.is_some() {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"use either milestone or milestone_name, not both".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(name) = input.milestone_name.as_deref() {
|
||||||
|
input.option.milestone = Some(self.resolve_milestone_id(repository, name).await?);
|
||||||
|
}
|
||||||
|
if !input.label_names.is_empty() {
|
||||||
|
input.option.labels.get_or_insert_default().extend(
|
||||||
|
self.resolve_label_ids(repository, &input.label_names)
|
||||||
|
.await?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
apis::issue_api::issue_create_issue(
|
||||||
|
&self.configuration(),
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
Some(input.option),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Error::generated)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_issue_draft(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
draft: IssueDraft,
|
||||||
|
) -> Result<models::Issue> {
|
||||||
|
self.create_issue(
|
||||||
|
repository,
|
||||||
|
CreateIssue {
|
||||||
|
option: create_issue_option(draft),
|
||||||
|
label_names: Vec::new(),
|
||||||
|
milestone_name: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn edit_issues(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
numbers: &[i64],
|
||||||
|
mut input: EditIssue,
|
||||||
|
) -> Result<Vec<models::Issue>> {
|
||||||
|
if numbers.is_empty() {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"at least one issue number is required".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if input.option.milestone.is_some() && input.milestone_name.is_some() {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"use either milestone or milestone_name, not both".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if input.option.assignees.is_some() && !input.add_assignees.is_empty() {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"use either assignees or add_assignees, not both".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if input.replace_labels.is_some()
|
||||||
|
&& (!input.add_labels.is_empty() || !input.remove_labels.is_empty())
|
||||||
|
{
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"use either replace_labels or add_labels/remove_labels, not both".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(name) = input.milestone_name.as_deref() {
|
||||||
|
input.option.milestone = Some(self.resolve_milestone_id(repository, name).await?);
|
||||||
|
}
|
||||||
|
let remove = self
|
||||||
|
.resolve_label_ids(repository, &input.remove_labels)
|
||||||
|
.await?;
|
||||||
|
let add = self
|
||||||
|
.resolve_label_ids(repository, &input.add_labels)
|
||||||
|
.await?;
|
||||||
|
let mut issues = Vec::with_capacity(numbers.len());
|
||||||
|
for &number in numbers {
|
||||||
|
positive(number, "issue number")?;
|
||||||
|
issues.push(
|
||||||
|
self.apply_issue_edit(repository, number, &input, &remove, &add)
|
||||||
|
.await?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(issues)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn apply_issue_edit(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
number: i64,
|
||||||
|
input: &EditIssue,
|
||||||
|
remove: &[i64],
|
||||||
|
add: &[i64],
|
||||||
|
) -> Result<models::Issue> {
|
||||||
|
let configuration = self.configuration();
|
||||||
|
let mut edit = input.option.clone();
|
||||||
|
if !input.add_assignees.is_empty() {
|
||||||
|
let mut assignees = self
|
||||||
|
.issue(repository, number)
|
||||||
|
.await?
|
||||||
|
.assignees
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|user| user.login.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
for assignee in &input.add_assignees {
|
||||||
|
if !assignees.contains(assignee) {
|
||||||
|
assignees.push(assignee.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
edit.assignees = Some(assignees);
|
||||||
|
}
|
||||||
|
if edit != models::EditIssueOption::default() {
|
||||||
|
apis::issue_api::issue_edit_issue(
|
||||||
|
&configuration,
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
number,
|
||||||
|
Some(edit),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Error::generated)?;
|
||||||
|
}
|
||||||
|
if let Some(labels) = &input.replace_labels {
|
||||||
|
apis::issue_api::issue_replace_labels(
|
||||||
|
&configuration,
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
number,
|
||||||
|
Some(label_option(labels)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Error::generated)?;
|
||||||
|
} else {
|
||||||
|
for &id in remove {
|
||||||
|
apis::issue_api::issue_remove_label(
|
||||||
|
&configuration,
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
number,
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Error::generated)?;
|
||||||
|
}
|
||||||
|
if !add.is_empty() {
|
||||||
|
apis::issue_api::issue_add_label(
|
||||||
|
&configuration,
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
number,
|
||||||
|
Some(label_option(add)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Error::generated)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.issue(repository, number).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn edit_issue_draft(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
number: i64,
|
||||||
|
draft: IssueDraft,
|
||||||
|
) -> Result<models::Issue> {
|
||||||
|
self.edit_issues(repository, &[number], edit_issue_draft(draft))
|
||||||
|
.await
|
||||||
|
.map(|mut issues| issues.remove(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_issue_state(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
numbers: &[i64],
|
||||||
|
state: &str,
|
||||||
|
) -> Result<Vec<models::Issue>> {
|
||||||
|
if !matches!(state, "open" | "closed") {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"issue state must be open or closed".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.edit_issues(
|
||||||
|
repository,
|
||||||
|
numbers,
|
||||||
|
EditIssue {
|
||||||
|
option: models::EditIssueOption {
|
||||||
|
state: Some(state.into()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
replace_labels: None,
|
||||||
|
add_labels: Vec::new(),
|
||||||
|
remove_labels: Vec::new(),
|
||||||
|
add_assignees: Vec::new(),
|
||||||
|
milestone_name: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_issue(&self, repository: &RepositoryId, number: i64) -> Result<()> {
|
||||||
|
positive(number, "issue number")?;
|
||||||
|
apis::issue_api::issue_delete(
|
||||||
|
&self.configuration(),
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
number,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Error::generated)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn issue_comments(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
number: i64,
|
||||||
|
) -> Result<Vec<models::Comment>> {
|
||||||
|
positive(number, "issue number")?;
|
||||||
|
apis::issue_api::issue_get_comments(
|
||||||
|
&self.configuration(),
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
number,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Error::generated)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_issue_comment(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
number: i64,
|
||||||
|
body: String,
|
||||||
|
) -> Result<models::Comment> {
|
||||||
|
positive(number, "issue number")?;
|
||||||
|
if body.trim().is_empty() {
|
||||||
|
return Err(Error::InvalidInput("comment must not be empty".into()));
|
||||||
|
}
|
||||||
|
apis::issue_api::issue_create_comment(
|
||||||
|
&self.configuration(),
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
number,
|
||||||
|
Some(models::CreateIssueCommentOption::new(body)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Error::generated)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save_issue_comment(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
number: i64,
|
||||||
|
comment_id: Option<i64>,
|
||||||
|
body: String,
|
||||||
|
) -> Result<models::Comment> {
|
||||||
|
let Some(id) = comment_id else {
|
||||||
|
return self.create_issue_comment(repository, number, body).await;
|
||||||
|
};
|
||||||
|
positive(id, "comment id")?;
|
||||||
|
if body.trim().is_empty() {
|
||||||
|
return Err(Error::InvalidInput("comment must not be empty".into()));
|
||||||
|
}
|
||||||
|
let configuration = self.configuration();
|
||||||
|
let (comment, viewer) = tokio::join!(
|
||||||
|
apis::issue_api::issue_get_comment(
|
||||||
|
&configuration,
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
id,
|
||||||
|
),
|
||||||
|
self.current_user(),
|
||||||
|
);
|
||||||
|
let comment = comment.map_err(Error::generated)?;
|
||||||
|
let viewer = viewer?;
|
||||||
|
if !comment_can_edit(&comment, viewer.id) || !comment_belongs_to_issue(&comment, number) {
|
||||||
|
return Err(Error::Forbidden(
|
||||||
|
"You can only edit your own comments.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
apis::issue_api::issue_edit_comment(
|
||||||
|
&configuration,
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
id,
|
||||||
|
Some(models::EditIssueCommentOption::new(body)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Error::generated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn comment_can_edit(comment: &models::Comment, viewer_id: Option<i64>) -> bool {
|
||||||
|
matches!(
|
||||||
|
(
|
||||||
|
comment.id,
|
||||||
|
comment.user.as_ref().and_then(|user| user.id),
|
||||||
|
viewer_id,
|
||||||
|
),
|
||||||
|
(Some(_), Some(author), Some(viewer)) if author == viewer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
mod helpers;
|
||||||
|
use helpers::*;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
56
crates/gitea/src/issues/helpers.rs
Normal file
56
crates/gitea/src/issues/helpers.rs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
use crate::{
|
||||||
|
domain::{EditIssue, IssueDraft},
|
||||||
|
models,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(super) fn create_issue_option(draft: IssueDraft) -> models::CreateIssueOption {
|
||||||
|
models::CreateIssueOption {
|
||||||
|
body: Some(draft.body),
|
||||||
|
closed: Some(draft.closed),
|
||||||
|
due_date: draft.due_date,
|
||||||
|
labels: Some(draft.label_ids),
|
||||||
|
milestone: draft.milestone_id,
|
||||||
|
..models::CreateIssueOption::new(draft.title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn edit_issue_draft(draft: IssueDraft) -> EditIssue {
|
||||||
|
EditIssue {
|
||||||
|
option: models::EditIssueOption {
|
||||||
|
body: Some(draft.body),
|
||||||
|
due_date: draft.due_date.clone(),
|
||||||
|
milestone: Some(draft.milestone_id.unwrap_or_default()),
|
||||||
|
state: Some(if draft.closed { "closed" } else { "open" }.into()),
|
||||||
|
title: Some(draft.title),
|
||||||
|
unset_due_date: draft.due_date.is_none().then_some(true),
|
||||||
|
..models::EditIssueOption::new()
|
||||||
|
},
|
||||||
|
replace_labels: Some(draft.label_ids),
|
||||||
|
add_labels: Vec::new(),
|
||||||
|
remove_labels: Vec::new(),
|
||||||
|
add_assignees: Vec::new(),
|
||||||
|
milestone_name: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn label_option(labels: &[i64]) -> models::IssueLabelsOption {
|
||||||
|
models::IssueLabelsOption {
|
||||||
|
labels: Some(
|
||||||
|
labels
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.map(serde_json::Value::from)
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn comment_belongs_to_issue(comment: &models::Comment, number: i64) -> bool {
|
||||||
|
comment
|
||||||
|
.issue_url
|
||||||
|
.as_deref()
|
||||||
|
.map(|url| url.trim_end_matches('/'))
|
||||||
|
.and_then(|url| url.rsplit('/').next())
|
||||||
|
.and_then(|index| index.parse().ok())
|
||||||
|
== Some(number)
|
||||||
|
}
|
||||||
44
crates/gitea/src/issues/tests.rs
Normal file
44
crates/gitea/src/issues/tests.rs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scopes_comments_and_builds_label_payloads() {
|
||||||
|
let comment = models::Comment {
|
||||||
|
issue_url: Some("https://example.test/api/v1/repos/a/b/issues/7".into()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(comment_belongs_to_issue(&comment, 7));
|
||||||
|
assert!(!comment_belongs_to_issue(&comment, 8));
|
||||||
|
assert!(!comment_can_edit(&comment, Some(1)));
|
||||||
|
let owned = models::Comment {
|
||||||
|
id: Some(3),
|
||||||
|
user: Some(Box::new(models::User {
|
||||||
|
id: Some(1),
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(comment_can_edit(&owned, Some(1)));
|
||||||
|
assert_eq!(label_option(&[2, 4]).labels.unwrap().len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maps_issue_drafts_without_losing_clear_operations() {
|
||||||
|
let draft = IssueDraft {
|
||||||
|
title: "Title".into(),
|
||||||
|
body: "Body".into(),
|
||||||
|
label_ids: vec![2, 4],
|
||||||
|
milestone_id: None,
|
||||||
|
due_date: None,
|
||||||
|
closed: true,
|
||||||
|
};
|
||||||
|
let create = create_issue_option(draft.clone());
|
||||||
|
assert_eq!(create.title, "Title");
|
||||||
|
assert_eq!(create.body.as_deref(), Some("Body"));
|
||||||
|
assert_eq!(create.closed, Some(true));
|
||||||
|
assert_eq!(create.labels, Some(vec![2, 4]));
|
||||||
|
let edit = edit_issue_draft(draft);
|
||||||
|
assert_eq!(edit.option.milestone, Some(0));
|
||||||
|
assert_eq!(edit.option.state.as_deref(), Some("closed"));
|
||||||
|
assert_eq!(edit.option.unset_due_date, Some(true));
|
||||||
|
assert_eq!(edit.replace_labels, Some(vec![2, 4]));
|
||||||
|
}
|
||||||
@@ -1,20 +1,97 @@
|
|||||||
use std::{error, fmt};
|
use std::{error, fmt, str::FromStr};
|
||||||
|
|
||||||
use reqwest::{
|
use reqwest::{
|
||||||
Client as HttpClient,
|
Client as HttpClient,
|
||||||
header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderValue},
|
header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderValue},
|
||||||
};
|
};
|
||||||
pub use reqwest::{Method, RequestBuilder, Response, StatusCode, Url};
|
pub use reqwest::{Method, RequestBuilder, Response, StatusCode, Url};
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
pub use gitea_openapi::{apis, models};
|
use gitea_openapi::apis;
|
||||||
|
pub use gitea_openapi::models;
|
||||||
pub use models::{Repository, ServerVersion, User};
|
pub use models::{Repository, ServerVersion, User};
|
||||||
|
|
||||||
|
mod actions;
|
||||||
|
pub mod activity;
|
||||||
|
mod config;
|
||||||
|
pub mod diff;
|
||||||
|
mod domain;
|
||||||
|
mod issues;
|
||||||
|
mod milestones;
|
||||||
|
pub mod notifications;
|
||||||
|
mod pulls;
|
||||||
|
mod repositories;
|
||||||
|
|
||||||
|
pub use actions::{
|
||||||
|
ActionJobDetails, ActionJobLog, ActionLogGroup, ActionRunDetails, ActionRunQuery,
|
||||||
|
group_action_log, group_action_log_with_steps, parse_action_inputs,
|
||||||
|
};
|
||||||
|
pub use activity::{ActivityFilter, ServerActivityPager};
|
||||||
|
pub use config::{Config, Selection, ServerProfile, TuiPreferences, server_url};
|
||||||
|
pub use domain::{
|
||||||
|
CreateIssue, DEFAULT_PAGE_SIZE, EditIssue, HistoryCommit, HomeData, IssueDetails, IssueDraft,
|
||||||
|
IssueEditorData, IssueQuery, MilestoneDetails, MilestoneDraft, Page, PullDetails, RepositoryId,
|
||||||
|
api_date, api_timestamp, civil_from_days, days_from_civil, parse_api_date, parse_api_timestamp,
|
||||||
|
};
|
||||||
|
pub use issues::comment_can_edit;
|
||||||
|
pub use pulls::{PullFileSource, pull_file_source, pull_state};
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, Error>;
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Provider {
|
||||||
|
#[default]
|
||||||
|
Gitea,
|
||||||
|
Forgejo,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Provider {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter.write_str(match self {
|
||||||
|
Self::Gitea => "gitea",
|
||||||
|
Self::Forgejo => "forgejo",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for Provider {
|
||||||
|
type Err = Error;
|
||||||
|
|
||||||
|
fn from_str(value: &str) -> Result<Self> {
|
||||||
|
match value.to_ascii_lowercase().as_str() {
|
||||||
|
"gitea" => Ok(Self::Gitea),
|
||||||
|
"forgejo" => Ok(Self::Forgejo),
|
||||||
|
_ => Err(Error::InvalidInput(
|
||||||
|
"provider must be gitea or forgejo".into(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_page(page: i32, limit: i32) -> Result<()> {
|
||||||
|
if page < 1 || limit < 1 {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"page and limit must be positive".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn positive(value: i64, name: &str) -> Result<()> {
|
||||||
|
if value < 1 {
|
||||||
|
return Err(Error::InvalidInput(format!(
|
||||||
|
"{name} must be a positive integer"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
Configuration(String),
|
Configuration(String),
|
||||||
|
InvalidInput(String),
|
||||||
|
Forbidden(String),
|
||||||
Transport(reqwest::Error),
|
Transport(reqwest::Error),
|
||||||
Generated(String),
|
Generated(String),
|
||||||
Api { status: StatusCode, message: String },
|
Api { status: StatusCode, message: String },
|
||||||
@@ -24,10 +101,12 @@ impl fmt::Display for Error {
|
|||||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::Configuration(message) => formatter.write_str(message),
|
Self::Configuration(message) => formatter.write_str(message),
|
||||||
|
Self::InvalidInput(message) => formatter.write_str(message),
|
||||||
|
Self::Forbidden(message) => formatter.write_str(message),
|
||||||
Self::Transport(error) => error.fmt(formatter),
|
Self::Transport(error) => error.fmt(formatter),
|
||||||
Self::Generated(message) => formatter.write_str(message),
|
Self::Generated(message) => formatter.write_str(message),
|
||||||
Self::Api { status, message } => {
|
Self::Api { status, message } => {
|
||||||
write!(formatter, "Gitea returned {status}: {message}")
|
write!(formatter, "Server returned {status}: {message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,25 +127,41 @@ impl From<reqwest::Error> for Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Error {
|
||||||
|
pub(crate) fn generated(error: impl fmt::Display) -> Self {
|
||||||
|
Self::Generated(error.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct Client {
|
pub struct Client {
|
||||||
api_url: Url,
|
api_url: Url,
|
||||||
|
forgejo_api_url: Url,
|
||||||
http: HttpClient,
|
http: HttpClient,
|
||||||
|
provider: Provider,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Client {
|
impl Client {
|
||||||
pub fn new(instance_url: &str, token: Option<&str>) -> Result<Self> {
|
pub fn new(instance_url: &str, token: Option<&str>) -> Result<Self> {
|
||||||
|
Self::with_provider(instance_url, token, Provider::Gitea)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_provider(
|
||||||
|
instance_url: &str,
|
||||||
|
token: Option<&str>,
|
||||||
|
provider: Provider,
|
||||||
|
) -> Result<Self> {
|
||||||
let mut instance_url = Url::parse(instance_url)
|
let mut instance_url = Url::parse(instance_url)
|
||||||
.map_err(|error| Error::Configuration(format!("invalid Gitea URL: {error}")))?;
|
.map_err(|error| Error::Configuration(format!("invalid server URL: {error}")))?;
|
||||||
|
|
||||||
if !matches!(instance_url.scheme(), "http" | "https") {
|
if !matches!(instance_url.scheme(), "http" | "https") {
|
||||||
return Err(Error::Configuration(
|
return Err(Error::Configuration(
|
||||||
"Gitea URL must use http or https".into(),
|
"Server URL must use http or https".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if !instance_url.username().is_empty() || instance_url.password().is_some() {
|
if !instance_url.username().is_empty() || instance_url.password().is_some() {
|
||||||
return Err(Error::Configuration(
|
return Err(Error::Configuration(
|
||||||
"Gitea URL must not contain credentials".into(),
|
"Server URL must not contain credentials".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if !instance_url.path().ends_with('/') {
|
if !instance_url.path().ends_with('/') {
|
||||||
@@ -75,27 +170,63 @@ impl Client {
|
|||||||
|
|
||||||
let api_url = instance_url
|
let api_url = instance_url
|
||||||
.join("api/v1/")
|
.join("api/v1/")
|
||||||
.map_err(|error| Error::Configuration(format!("invalid Gitea URL: {error}")))?;
|
.map_err(|error| Error::Configuration(format!("invalid server URL: {error}")))?;
|
||||||
|
let forgejo_api_url = instance_url
|
||||||
|
.join("api/forgejo/v1/")
|
||||||
|
.map_err(|error| Error::Configuration(format!("invalid server URL: {error}")))?;
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
|
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
|
||||||
if let Some(token) = token {
|
if let Some(token) = token {
|
||||||
let value = HeaderValue::from_str(&format!("token {token}"))
|
let value = HeaderValue::from_str(&format!("token {token}"))
|
||||||
.map_err(|_| Error::Configuration("invalid Gitea token".into()))?;
|
.map_err(|_| Error::Configuration("invalid server token".into()))?;
|
||||||
headers.insert(AUTHORIZATION, value);
|
headers.insert(AUTHORIZATION, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
api_url,
|
api_url,
|
||||||
|
forgejo_api_url,
|
||||||
http: HttpClient::builder().default_headers(headers).build()?,
|
http: HttpClient::builder().default_headers(headers).build()?,
|
||||||
|
provider,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn discover(
|
||||||
|
instance_url: &str,
|
||||||
|
token: Option<&str>,
|
||||||
|
fallback: Provider,
|
||||||
|
) -> Result<Self> {
|
||||||
|
let mut client = Self::with_provider(instance_url, token, fallback)?;
|
||||||
|
let version = client.gitea_version().await?;
|
||||||
|
if is_forgejo_version(&version) {
|
||||||
|
client.provider = Provider::Forgejo;
|
||||||
|
return Ok(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
match client.forgejo_version().await {
|
||||||
|
Ok(_) => client.provider = Provider::Forgejo,
|
||||||
|
Err(Error::Api { status, .. }) if status == StatusCode::NOT_FOUND => {
|
||||||
|
if fallback == Provider::Forgejo {
|
||||||
|
return Err(Error::Configuration(
|
||||||
|
"Server does not expose the Forgejo API".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
client.provider = Provider::Gitea;
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
Ok(client)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn api_url(&self) -> &Url {
|
pub fn api_url(&self) -> &Url {
|
||||||
&self.api_url
|
&self.api_url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn provider(&self) -> Provider {
|
||||||
|
self.provider
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns an authenticated configuration for every generated typed API.
|
/// Returns an authenticated configuration for every generated typed API.
|
||||||
pub fn configuration(&self) -> apis::configuration::Configuration {
|
pub(crate) fn configuration(&self) -> apis::configuration::Configuration {
|
||||||
apis::configuration::Configuration {
|
apis::configuration::Configuration {
|
||||||
base_path: self.api_url.as_str().trim_end_matches('/').into(),
|
base_path: self.api_url.as_str().trim_end_matches('/').into(),
|
||||||
client: self.http.clone(),
|
client: self.http.clone(),
|
||||||
@@ -142,11 +273,27 @@ impl Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn version(&self) -> Result<models::ServerVersion> {
|
pub async fn version(&self) -> Result<models::ServerVersion> {
|
||||||
|
match self.provider {
|
||||||
|
Provider::Gitea => self.gitea_version().await,
|
||||||
|
Provider::Forgejo => self.forgejo_version().await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn gitea_version(&self) -> Result<models::ServerVersion> {
|
||||||
apis::miscellaneous_api::get_version(&self.configuration())
|
apis::miscellaneous_api::get_version(&self.configuration())
|
||||||
.await
|
.await
|
||||||
.map_err(|error| Error::Generated(error.to_string()))
|
.map_err(|error| Error::Generated(error.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn forgejo_version(&self) -> Result<models::ServerVersion> {
|
||||||
|
let request =
|
||||||
|
self.http
|
||||||
|
.get(self.forgejo_api_url.join("version").map_err(|error| {
|
||||||
|
Error::Configuration(format!("invalid Forgejo endpoint: {error}"))
|
||||||
|
})?);
|
||||||
|
Ok(self.execute(request).await?.json().await?)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn current_user(&self) -> Result<models::User> {
|
pub async fn current_user(&self) -> Result<models::User> {
|
||||||
apis::user_api::user_get_current(&self.configuration())
|
apis::user_api::user_get_current(&self.configuration())
|
||||||
.await
|
.await
|
||||||
@@ -172,6 +319,13 @@ impl Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_forgejo_version(version: &models::ServerVersion) -> bool {
|
||||||
|
version
|
||||||
|
.version
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|version| version.contains("+gitea-"))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct ApiError {
|
struct ApiError {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -184,7 +338,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn keeps_instance_subpaths_and_rejects_endpoint_escapes() {
|
fn keeps_instance_subpaths_and_rejects_endpoint_escapes() {
|
||||||
let client = Client::new("https://example.com/gitea", Some("secret")).unwrap();
|
let client = Client::with_provider(
|
||||||
|
"https://example.com/gitea",
|
||||||
|
Some("secret"),
|
||||||
|
Provider::Forgejo,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
client.api_url().as_str(),
|
client.api_url().as_str(),
|
||||||
@@ -201,7 +360,20 @@ mod tests {
|
|||||||
client.configuration().base_path,
|
client.configuration().base_path,
|
||||||
"https://example.com/gitea/api/v1"
|
"https://example.com/gitea/api/v1"
|
||||||
);
|
);
|
||||||
let _typed_query = apis::issue_api::issue_list_issues;
|
assert_eq!(client.provider(), Provider::Forgejo);
|
||||||
|
assert_eq!(
|
||||||
|
client.forgejo_api_url.as_str(),
|
||||||
|
"https://example.com/gitea/api/forgejo/v1/"
|
||||||
|
);
|
||||||
|
assert_eq!("GITEA".parse::<Provider>().unwrap(), Provider::Gitea);
|
||||||
|
assert_eq!(Provider::Forgejo.to_string(), "forgejo");
|
||||||
|
assert!("github".parse::<Provider>().is_err());
|
||||||
|
assert!(is_forgejo_version(&models::ServerVersion {
|
||||||
|
version: Some("16.0.1+gitea-1.24.6".into()),
|
||||||
|
}));
|
||||||
|
assert!(!is_forgejo_version(&models::ServerVersion {
|
||||||
|
version: Some("1.25.2".into()),
|
||||||
|
}));
|
||||||
let _typed_model = models::Issue::default();
|
let _typed_model = models::Issue::default();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
306
crates/gitea/src/milestones.rs
Normal file
306
crates/gitea/src/milestones.rs
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
Client, Error, Method, Result,
|
||||||
|
domain::{DEFAULT_PAGE_SIZE, IssueQuery, MilestoneDetails, MilestoneDraft, Page, RepositoryId},
|
||||||
|
models,
|
||||||
|
};
|
||||||
|
use gitea_openapi::apis;
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
pub async fn milestones_page(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
page: i32,
|
||||||
|
limit: i32,
|
||||||
|
) -> Result<Page<models::Milestone>> {
|
||||||
|
if page < 1 || limit < 1 {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"milestone page and limit must be positive".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
apis::issue_api::issue_get_milestones_list(
|
||||||
|
&self.configuration(),
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
Some("all"),
|
||||||
|
None,
|
||||||
|
Some(page),
|
||||||
|
Some(limit),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|items| Page::from_items(items, limit))
|
||||||
|
.map_err(Error::generated)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn milestones(&self, repository: &RepositoryId) -> Result<Vec<models::Milestone>> {
|
||||||
|
let mut milestones = Vec::new();
|
||||||
|
for page in 1.. {
|
||||||
|
let batch = self.milestones_page(repository, page, 100).await?;
|
||||||
|
milestones.extend(batch.items);
|
||||||
|
if !batch.has_more {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(milestones)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn resolve_milestone_id(&self, repository: &RepositoryId, name: &str) -> Result<i64> {
|
||||||
|
self.milestones(repository)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.find(|milestone| milestone.title.as_deref() == Some(name))
|
||||||
|
.and_then(|milestone| milestone.id)
|
||||||
|
.ok_or_else(|| Error::InvalidInput(format!("unknown milestone {name:?}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn milestone(&self, repository: &RepositoryId, id: i64) -> Result<models::Milestone> {
|
||||||
|
if id < 1 {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"milestone id must be a positive integer".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
apis::issue_api::issue_get_milestone(
|
||||||
|
&self.configuration(),
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
&id.to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Error::generated)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn milestone_details(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
id: i64,
|
||||||
|
page: i32,
|
||||||
|
) -> Result<MilestoneDetails> {
|
||||||
|
let milestone = self.milestone(repository, id).await?;
|
||||||
|
let base = IssueQuery {
|
||||||
|
state: "all".into(),
|
||||||
|
milestones: milestone.title.clone(),
|
||||||
|
page,
|
||||||
|
limit: DEFAULT_PAGE_SIZE,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let pulls_query = IssueQuery {
|
||||||
|
kind: "pulls".into(),
|
||||||
|
..base.clone()
|
||||||
|
};
|
||||||
|
let issues = self.issues(repository, &base);
|
||||||
|
let pulls = self.issues(repository, &pulls_query);
|
||||||
|
let (issues, pulls) = tokio::try_join!(issues, pulls)?;
|
||||||
|
let has_more = issues.has_more || pulls.has_more;
|
||||||
|
let mut pulls = pulls.items;
|
||||||
|
for pull in &mut pulls {
|
||||||
|
pull.repository.get_or_insert_with(|| {
|
||||||
|
Box::new(models::RepositoryMeta {
|
||||||
|
name: Some(repository.repository.clone()),
|
||||||
|
owner: Some(repository.owner.clone()),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(MilestoneDetails {
|
||||||
|
milestone,
|
||||||
|
has_more,
|
||||||
|
issues: issues.items,
|
||||||
|
pulls,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_milestone(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
option: models::CreateMilestoneOption,
|
||||||
|
) -> Result<models::Milestone> {
|
||||||
|
if option
|
||||||
|
.title
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.is_empty()
|
||||||
|
{
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"milestone title must not be empty".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
apis::issue_api::issue_create_milestone(
|
||||||
|
&self.configuration(),
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
Some(option),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Error::generated)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn edit_milestone(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
id: &str,
|
||||||
|
option: models::EditMilestoneOption,
|
||||||
|
) -> Result<models::Milestone> {
|
||||||
|
apis::issue_api::issue_edit_milestone(
|
||||||
|
&self.configuration(),
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
id,
|
||||||
|
Some(option),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Error::generated)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save_milestone(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
id: Option<i64>,
|
||||||
|
draft: MilestoneDraft,
|
||||||
|
) -> Result<models::Milestone> {
|
||||||
|
if draft.title.trim().is_empty() {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"milestone title must not be empty".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !matches!(draft.state.as_str(), "open" | "closed") {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"milestone state must be open or closed".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let endpoint = match id {
|
||||||
|
Some(id) if id > 0 => format!(
|
||||||
|
"repos/{}/{}/milestones/{id}",
|
||||||
|
apis::urlencode(&repository.owner),
|
||||||
|
apis::urlencode(&repository.repository)
|
||||||
|
),
|
||||||
|
Some(_) => {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"milestone id must be a positive integer".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
None => format!(
|
||||||
|
"repos/{}/{}/milestones",
|
||||||
|
apis::urlencode(&repository.owner),
|
||||||
|
apis::urlencode(&repository.repository)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let request = self
|
||||||
|
.request(
|
||||||
|
if id.is_some() {
|
||||||
|
Method::PATCH
|
||||||
|
} else {
|
||||||
|
Method::POST
|
||||||
|
},
|
||||||
|
&endpoint,
|
||||||
|
)?
|
||||||
|
.json(&MilestoneRequest {
|
||||||
|
title: draft.title,
|
||||||
|
description: draft.description,
|
||||||
|
due_on: draft.due_on,
|
||||||
|
state: draft.state,
|
||||||
|
});
|
||||||
|
self.execute(request)
|
||||||
|
.await?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_milestone_closed(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
id: i64,
|
||||||
|
closed: bool,
|
||||||
|
) -> Result<models::Milestone> {
|
||||||
|
if id < 1 {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"milestone id must be a positive integer".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.edit_milestone(
|
||||||
|
repository,
|
||||||
|
&id.to_string(),
|
||||||
|
models::EditMilestoneOption {
|
||||||
|
state: Some(if closed { "closed" } else { "open" }.into()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_milestone(&self, repository: &RepositoryId, id: &str) -> Result<()> {
|
||||||
|
let id = id
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| Error::InvalidInput("milestone id must be a positive integer".into()))?;
|
||||||
|
let milestone = self.milestone(repository, id).await?;
|
||||||
|
let title = milestone
|
||||||
|
.title
|
||||||
|
.ok_or_else(|| Error::Generated("The milestone has no title.".into()))?;
|
||||||
|
if !self
|
||||||
|
.issues(repository, &assigned_items_query(title))
|
||||||
|
.await?
|
||||||
|
.items
|
||||||
|
.is_empty()
|
||||||
|
{
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"Remove all assigned issues and pull requests before deleting this milestone."
|
||||||
|
.into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
apis::issue_api::issue_delete_milestone(
|
||||||
|
&self.configuration(),
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
&id.to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Error::generated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assigned_items_query(title: String) -> IssueQuery {
|
||||||
|
IssueQuery {
|
||||||
|
state: "all".into(),
|
||||||
|
kind: "all".into(),
|
||||||
|
milestones: Some(title),
|
||||||
|
limit: 1,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct MilestoneRequest {
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
due_on: Option<String>,
|
||||||
|
state: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn milestone_request_preserves_due_date_clear() {
|
||||||
|
let value = serde_json::to_value(MilestoneRequest {
|
||||||
|
title: "Release".into(),
|
||||||
|
description: String::new(),
|
||||||
|
due_on: None,
|
||||||
|
state: "closed".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(value.get("due_on").unwrap().is_null());
|
||||||
|
assert_eq!(value["state"], "closed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn assigned_item_check_includes_issues_and_pull_requests() {
|
||||||
|
let query = assigned_items_query("Release".into());
|
||||||
|
assert_eq!(query.state, "all");
|
||||||
|
assert_eq!(query.kind, "all");
|
||||||
|
assert_eq!(query.milestones.as_deref(), Some("Release"));
|
||||||
|
assert_eq!(query.limit, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
212
crates/gitea/src/notifications.rs
Normal file
212
crates/gitea/src/notifications.rs
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
use gitea_openapi::apis::notification_api;
|
||||||
|
|
||||||
|
use crate::{Client, Error, Page, Result, models, positive, validate_page};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum NotificationTarget {
|
||||||
|
None,
|
||||||
|
Repository {
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
},
|
||||||
|
Issue {
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
number: i64,
|
||||||
|
},
|
||||||
|
Pull {
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
number: i64,
|
||||||
|
},
|
||||||
|
Commit {
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
sha: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
pub async fn notifications(
|
||||||
|
&self,
|
||||||
|
unread: bool,
|
||||||
|
page: i32,
|
||||||
|
limit: i32,
|
||||||
|
) -> Result<Page<models::NotificationThread>> {
|
||||||
|
validate_page(page, limit)?;
|
||||||
|
let items = notification_api::notify_get_list(
|
||||||
|
&self.configuration(),
|
||||||
|
Some(!unread),
|
||||||
|
Some(vec![if unread { "unread" } else { "read" }.into()]),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(page),
|
||||||
|
Some(limit),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Error::generated)?;
|
||||||
|
Ok(Page::from_items(items, limit))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn notification_updates(
|
||||||
|
&self,
|
||||||
|
since: Option<&str>,
|
||||||
|
) -> Result<Vec<models::NotificationThread>> {
|
||||||
|
let mut notifications = Vec::new();
|
||||||
|
for page in 1.. {
|
||||||
|
let batch = notification_api::notify_get_list(
|
||||||
|
&self.configuration(),
|
||||||
|
Some(false),
|
||||||
|
Some(vec!["unread".into()]),
|
||||||
|
None,
|
||||||
|
since.map(str::to_owned),
|
||||||
|
None,
|
||||||
|
Some(page),
|
||||||
|
Some(100),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Error::generated)?;
|
||||||
|
let complete = batch.len() < 100;
|
||||||
|
notifications.extend(batch);
|
||||||
|
if complete {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(notifications)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn mark_notification_read(&self, id: i64) -> Result<models::NotificationThread> {
|
||||||
|
positive(id, "notification ID")?;
|
||||||
|
notification_api::notify_read_thread(&self.configuration(), &id.to_string(), Some("read"))
|
||||||
|
.await
|
||||||
|
.map_err(Error::generated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn target(notification: &models::NotificationThread) -> NotificationTarget {
|
||||||
|
let Some(repository) = notification.repository.as_deref() else {
|
||||||
|
return NotificationTarget::None;
|
||||||
|
};
|
||||||
|
let Some(owner) = repository
|
||||||
|
.owner
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|owner| owner.login.clone())
|
||||||
|
else {
|
||||||
|
return NotificationTarget::None;
|
||||||
|
};
|
||||||
|
let Some(repository) = repository.name.clone() else {
|
||||||
|
return NotificationTarget::None;
|
||||||
|
};
|
||||||
|
let Some(subject) = notification.subject.as_deref() else {
|
||||||
|
return NotificationTarget::Repository { owner, repository };
|
||||||
|
};
|
||||||
|
let value = subject
|
||||||
|
.url
|
||||||
|
.as_deref()
|
||||||
|
.or(subject.html_url.as_deref())
|
||||||
|
.and_then(last_path_component);
|
||||||
|
match subject
|
||||||
|
.r#type
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.as_str()
|
||||||
|
{
|
||||||
|
"issue" => {
|
||||||
|
value
|
||||||
|
.and_then(|value| value.parse().ok())
|
||||||
|
.map_or(NotificationTarget::None, |number| {
|
||||||
|
NotificationTarget::Issue {
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
number,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"pull" | "pullrequest" | "pull_request" => value
|
||||||
|
.and_then(|value| value.parse().ok())
|
||||||
|
.map_or(NotificationTarget::None, |number| {
|
||||||
|
NotificationTarget::Pull {
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
number,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
"commit" => value.map_or(NotificationTarget::None, |sha| NotificationTarget::Commit {
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
sha: sha.into(),
|
||||||
|
}),
|
||||||
|
"repository" => NotificationTarget::Repository { owner, repository },
|
||||||
|
_ => NotificationTarget::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn last_path_component(url: &str) -> Option<&str> {
|
||||||
|
url.trim_end_matches('/')
|
||||||
|
.rsplit('/')
|
||||||
|
.next()
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn notification(kind: &str, url: &str) -> models::NotificationThread {
|
||||||
|
models::NotificationThread {
|
||||||
|
repository: Some(Box::new(models::Repository {
|
||||||
|
name: Some("demo".into()),
|
||||||
|
owner: Some(Box::new(models::User {
|
||||||
|
login: Some("octo".into()),
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
subject: Some(Box::new(models::NotificationSubject {
|
||||||
|
r#type: Some(kind.into()),
|
||||||
|
url: Some(url.into()),
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maps_notification_subjects_to_native_destinations() {
|
||||||
|
assert_eq!(
|
||||||
|
target(¬ification(
|
||||||
|
"Issue",
|
||||||
|
"https://gitea.example/api/v1/repos/octo/demo/issues/42"
|
||||||
|
)),
|
||||||
|
NotificationTarget::Issue {
|
||||||
|
owner: "octo".into(),
|
||||||
|
repository: "demo".into(),
|
||||||
|
number: 42,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
target(¬ification(
|
||||||
|
"Pull",
|
||||||
|
"https://gitea.example/api/v1/repos/octo/demo/pulls/7"
|
||||||
|
)),
|
||||||
|
NotificationTarget::Pull {
|
||||||
|
owner: "octo".into(),
|
||||||
|
repository: "demo".into(),
|
||||||
|
number: 7,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
target(¬ification(
|
||||||
|
"Commit",
|
||||||
|
"https://gitea.example/api/v1/repos/octo/demo/git/commits/deadbeef"
|
||||||
|
)),
|
||||||
|
NotificationTarget::Commit {
|
||||||
|
owner: "octo".into(),
|
||||||
|
repository: "demo".into(),
|
||||||
|
sha: "deadbeef".into(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
325
crates/gitea/src/pulls.rs
Normal file
325
crates/gitea/src/pulls.rs
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
Client, Error, Result,
|
||||||
|
domain::{DEFAULT_PAGE_SIZE, Page, PullDetails, RepositoryId},
|
||||||
|
models, positive, validate_page,
|
||||||
|
};
|
||||||
|
use gitea_openapi::apis;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum PullFileSource {
|
||||||
|
Branch(String),
|
||||||
|
Commit(String),
|
||||||
|
Request,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pull_state(pull: &models::PullRequest) -> &str {
|
||||||
|
if pull.merged.unwrap_or(false) {
|
||||||
|
"merged"
|
||||||
|
} else if pull.draft.unwrap_or(false) {
|
||||||
|
"draft"
|
||||||
|
} else {
|
||||||
|
pull.state.as_deref().unwrap_or("unknown")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pull_file_source(pull: &models::PullRequest) -> PullFileSource {
|
||||||
|
if pull.state.as_deref() == Some("open") {
|
||||||
|
PullFileSource::Branch(
|
||||||
|
pull.head
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|head| head.r#ref.clone().or(head.label.clone()))
|
||||||
|
.unwrap_or_else(|| "head branch".into()),
|
||||||
|
)
|
||||||
|
} else if let Some(sha) = pull.merge_commit_sha.as_deref() {
|
||||||
|
PullFileSource::Commit(sha.chars().take(8).collect())
|
||||||
|
} else {
|
||||||
|
PullFileSource::Request
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
pub async fn repository_pulls(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
state: &str,
|
||||||
|
page: i32,
|
||||||
|
limit: i32,
|
||||||
|
) -> Result<Page<models::PullRequest>> {
|
||||||
|
validate_page(page, limit)?;
|
||||||
|
apis::repository_api::repo_list_pull_requests(
|
||||||
|
&self.configuration(),
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
None,
|
||||||
|
Some(state),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(page),
|
||||||
|
Some(limit),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|items| Page::from_items(items, limit))
|
||||||
|
.map_err(Error::generated)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search_pulls(
|
||||||
|
&self,
|
||||||
|
state: &str,
|
||||||
|
milestone: Option<&str>,
|
||||||
|
keyword: Option<&str>,
|
||||||
|
page: i32,
|
||||||
|
limit: i32,
|
||||||
|
) -> Result<Page<models::Issue>> {
|
||||||
|
validate_page(page, limit)?;
|
||||||
|
let owner = self
|
||||||
|
.current_user()
|
||||||
|
.await?
|
||||||
|
.login
|
||||||
|
.ok_or_else(|| Error::Generated("The server account has no username.".into()))?;
|
||||||
|
apis::issue_api::issue_search_issues(
|
||||||
|
&self.configuration(),
|
||||||
|
Some(state),
|
||||||
|
None,
|
||||||
|
milestone,
|
||||||
|
keyword,
|
||||||
|
None,
|
||||||
|
Some("pulls"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(&owner),
|
||||||
|
None,
|
||||||
|
Some(page),
|
||||||
|
Some(limit),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|items| Page::from_items(items, limit))
|
||||||
|
.map_err(Error::generated)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn pull_milestones(&self) -> Result<Vec<String>> {
|
||||||
|
let (open, closed) = tokio::try_join!(
|
||||||
|
self.all_searched_pulls("open"),
|
||||||
|
self.all_searched_pulls("closed"),
|
||||||
|
)?;
|
||||||
|
Ok(open
|
||||||
|
.into_iter()
|
||||||
|
.chain(closed)
|
||||||
|
.filter_map(|pull| pull.milestone?.title)
|
||||||
|
.collect::<BTreeSet<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn all_searched_pulls(&self, state: &str) -> Result<Vec<models::Issue>> {
|
||||||
|
let mut pulls = Vec::new();
|
||||||
|
for page in 1.. {
|
||||||
|
let batch = self
|
||||||
|
.search_pulls(state, None, None, page, DEFAULT_PAGE_SIZE)
|
||||||
|
.await?;
|
||||||
|
pulls.extend(batch.items);
|
||||||
|
if !batch.has_more {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(pulls)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn pull(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
number: i64,
|
||||||
|
) -> Result<models::PullRequest> {
|
||||||
|
positive(number, "pull request number")?;
|
||||||
|
apis::repository_api::repo_get_pull_request(
|
||||||
|
&self.configuration(),
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
number,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Error::generated)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn pull_details(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
number: i64,
|
||||||
|
page: i32,
|
||||||
|
) -> Result<PullDetails> {
|
||||||
|
let pull = self.pull(repository, number);
|
||||||
|
let comments = async {
|
||||||
|
if page == 1 {
|
||||||
|
self.issue_comments(repository, number).await
|
||||||
|
} else {
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let files = self.pull_files_page(repository, number, page, DEFAULT_PAGE_SIZE);
|
||||||
|
let (pull, comments, files) = tokio::try_join!(pull, comments, files)?;
|
||||||
|
Ok(PullDetails {
|
||||||
|
pull,
|
||||||
|
comments,
|
||||||
|
has_more: files.has_more,
|
||||||
|
files: files.items,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_pull(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
option: models::CreatePullRequestOption,
|
||||||
|
) -> Result<models::PullRequest> {
|
||||||
|
if [
|
||||||
|
option.title.as_deref(),
|
||||||
|
option.head.as_deref(),
|
||||||
|
option.base.as_deref(),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.any(|value| value.unwrap_or_default().trim().is_empty())
|
||||||
|
{
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"pull request title, head, and base must not be empty".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
apis::repository_api::repo_create_pull_request(
|
||||||
|
&self.configuration(),
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
Some(option),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Error::generated)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn edit_pull(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
number: i64,
|
||||||
|
option: models::EditPullRequestOption,
|
||||||
|
) -> Result<models::PullRequest> {
|
||||||
|
positive(number, "pull request number")?;
|
||||||
|
apis::repository_api::repo_edit_pull_request(
|
||||||
|
&self.configuration(),
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
number,
|
||||||
|
Some(option),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Error::generated)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn merge_pull(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
number: i64,
|
||||||
|
option: models::MergePullRequestOption,
|
||||||
|
) -> Result<()> {
|
||||||
|
positive(number, "pull request number")?;
|
||||||
|
apis::repository_api::repo_merge_pull_request(
|
||||||
|
&self.configuration(),
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
number,
|
||||||
|
Some(option),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(Error::generated)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn pull_commits(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
number: i64,
|
||||||
|
) -> Result<Vec<models::Commit>> {
|
||||||
|
positive(number, "pull request number")?;
|
||||||
|
apis::repository_api::repo_get_pull_request_commits(
|
||||||
|
&self.configuration(),
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
number,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Error::generated)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn pull_files_page(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
number: i64,
|
||||||
|
page: i32,
|
||||||
|
limit: i32,
|
||||||
|
) -> Result<Page<models::ChangedFile>> {
|
||||||
|
positive(number, "pull request number")?;
|
||||||
|
validate_page(page, limit)?;
|
||||||
|
apis::repository_api::repo_get_pull_request_files(
|
||||||
|
&self.configuration(),
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
number,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(page),
|
||||||
|
Some(limit),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|items| Page::from_items(items, limit))
|
||||||
|
.map_err(Error::generated)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn pull_files(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
number: i64,
|
||||||
|
) -> Result<Vec<models::ChangedFile>> {
|
||||||
|
self.pull_files_page(repository, number, 1, DEFAULT_PAGE_SIZE)
|
||||||
|
.await
|
||||||
|
.map(|page| page.items)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn pull_reviews(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
number: i64,
|
||||||
|
) -> Result<Vec<models::PullReview>> {
|
||||||
|
positive(number, "pull request number")?;
|
||||||
|
apis::repository_api::repo_list_pull_reviews(
|
||||||
|
&self.configuration(),
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
number,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Error::generated)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn pull_diff(&self, repository: &RepositoryId, number: i64) -> Result<String> {
|
||||||
|
positive(number, "pull request number")?;
|
||||||
|
apis::repository_api::repo_download_pull_diff_or_patch(
|
||||||
|
&self.configuration(),
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
number,
|
||||||
|
"diff",
|
||||||
|
Some(false),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Error::generated)
|
||||||
|
}
|
||||||
|
}
|
||||||
513
crates/gitea/src/repositories.rs
Normal file
513
crates/gitea/src/repositories.rs
Normal file
@@ -0,0 +1,513 @@
|
|||||||
|
use std::{
|
||||||
|
cmp::Reverse,
|
||||||
|
collections::{BTreeSet, BinaryHeap, HashMap},
|
||||||
|
};
|
||||||
|
|
||||||
|
use tokio::task::JoinSet;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
Client, Error, Method, Result,
|
||||||
|
domain::{DEFAULT_PAGE_SIZE, HistoryCommit, Page, RepositoryId},
|
||||||
|
models, validate_page,
|
||||||
|
};
|
||||||
|
use gitea_openapi::apis;
|
||||||
|
|
||||||
|
fn encode_path(path: &str) -> String {
|
||||||
|
apis::urlencode(path).replace('+', "%20")
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
pub async fn current_user_repositories_page(
|
||||||
|
&self,
|
||||||
|
page: i32,
|
||||||
|
limit: i32,
|
||||||
|
) -> Result<Page<models::Repository>> {
|
||||||
|
validate_page(page, limit)?;
|
||||||
|
apis::user_api::user_current_list_repos(&self.configuration(), Some(page), Some(limit))
|
||||||
|
.await
|
||||||
|
.map(|items| Page::from_items(items, limit))
|
||||||
|
.map_err(Error::generated)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn owned_repositories_page(
|
||||||
|
&self,
|
||||||
|
page: i32,
|
||||||
|
limit: i32,
|
||||||
|
) -> Result<Page<models::Repository>> {
|
||||||
|
let login = self
|
||||||
|
.current_user()
|
||||||
|
.await?
|
||||||
|
.login
|
||||||
|
.ok_or_else(|| Error::Generated("The server account has no username.".into()))?;
|
||||||
|
let page = self.current_user_repositories_page(page, limit).await?;
|
||||||
|
Ok(Page {
|
||||||
|
has_more: page.has_more,
|
||||||
|
items: page
|
||||||
|
.items
|
||||||
|
.into_iter()
|
||||||
|
.filter(|repository| {
|
||||||
|
repository
|
||||||
|
.owner
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|owner| owner.login.as_deref())
|
||||||
|
== Some(login.as_str())
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn branches(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
default_branch: &str,
|
||||||
|
) -> Result<Vec<String>> {
|
||||||
|
let configuration = self.configuration();
|
||||||
|
let mut branches = Vec::new();
|
||||||
|
for page in 1.. {
|
||||||
|
let batch = apis::repository_api::repo_list_branches(
|
||||||
|
&configuration,
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
Some(page),
|
||||||
|
Some(100),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Error::generated)?;
|
||||||
|
let done = batch.len() < 100;
|
||||||
|
branches.extend(batch.into_iter().filter_map(|branch| branch.name));
|
||||||
|
if done {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
branches.sort_by_key(|branch| (branch != default_branch, branch.to_lowercase()));
|
||||||
|
Ok(branches)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn repository_contents(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<Vec<models::ContentsResponse>> {
|
||||||
|
let configuration = self.configuration();
|
||||||
|
if path.is_empty() {
|
||||||
|
apis::repository_api::repo_get_contents_list(
|
||||||
|
&configuration,
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Error::generated)
|
||||||
|
} else {
|
||||||
|
let endpoint = format!(
|
||||||
|
"repos/{}/{}/contents/{}",
|
||||||
|
apis::urlencode(&repository.owner),
|
||||||
|
apis::urlencode(&repository.repository),
|
||||||
|
encode_path(path),
|
||||||
|
);
|
||||||
|
self.execute(self.request(Method::GET, &endpoint)?)
|
||||||
|
.await?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn repository_file(&self, repository: &RepositoryId, path: &str) -> Result<Vec<u8>> {
|
||||||
|
let endpoint = format!(
|
||||||
|
"repos/{}/{}/raw/{}",
|
||||||
|
apis::urlencode(&repository.owner),
|
||||||
|
apis::urlencode(&repository.repository),
|
||||||
|
encode_path(path),
|
||||||
|
);
|
||||||
|
self.execute(self.request(Method::GET, &endpoint)?)
|
||||||
|
.await?
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map(|bytes| bytes.to_vec())
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn repository_file_at_ref(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
path: &str,
|
||||||
|
reference: Option<&str>,
|
||||||
|
) -> Result<Vec<u8>> {
|
||||||
|
let endpoint = format!(
|
||||||
|
"repos/{}/{}/raw/{}",
|
||||||
|
apis::urlencode(&repository.owner),
|
||||||
|
apis::urlencode(&repository.repository),
|
||||||
|
encode_path(path),
|
||||||
|
);
|
||||||
|
let request = self.request(Method::GET, &endpoint)?;
|
||||||
|
let request = match reference {
|
||||||
|
Some(reference) => request.query(&[("ref", reference)]),
|
||||||
|
None => request,
|
||||||
|
};
|
||||||
|
self.execute(request)
|
||||||
|
.await?
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map(|bytes| bytes.to_vec())
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn branch_history(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
branch: &str,
|
||||||
|
path: Option<&str>,
|
||||||
|
pages: u32,
|
||||||
|
) -> Result<Page<HistoryCommit>> {
|
||||||
|
self.commits_for_ref(repository, Some(branch), path, pages)
|
||||||
|
.await
|
||||||
|
.map(|page| Page {
|
||||||
|
has_more: page.has_more,
|
||||||
|
items: page
|
||||||
|
.items
|
||||||
|
.into_iter()
|
||||||
|
.map(|commit| HistoryCommit {
|
||||||
|
commit,
|
||||||
|
top_lanes: Vec::new(),
|
||||||
|
bottom_lanes: Vec::new(),
|
||||||
|
node_lane: None,
|
||||||
|
top_connections: Vec::new(),
|
||||||
|
bottom_connections: Vec::new(),
|
||||||
|
refs: Vec::new(),
|
||||||
|
branch_starts: Vec::new(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn all_history(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
branches: &[String],
|
||||||
|
path: Option<&str>,
|
||||||
|
pages: u32,
|
||||||
|
) -> Result<Page<HistoryCommit>> {
|
||||||
|
let mut tasks = JoinSet::new();
|
||||||
|
for (lane, branch) in branches.iter().cloned().enumerate() {
|
||||||
|
let (client, repository) = (self.clone(), repository.clone());
|
||||||
|
let path = path.map(str::to_string);
|
||||||
|
tasks.spawn(async move {
|
||||||
|
let commits = client
|
||||||
|
.commits_for_ref(&repository, Some(&branch), path.as_deref(), pages)
|
||||||
|
.await?;
|
||||||
|
Ok::<_, Error>((lane, branch, commits))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut histories = Vec::new();
|
||||||
|
while let Some(result) = tasks.join_next().await {
|
||||||
|
histories.push(result.map_err(|error| Error::Generated(error.to_string()))??);
|
||||||
|
}
|
||||||
|
histories.sort_by_key(|(lane, _, _)| *lane);
|
||||||
|
let has_more = histories.iter().any(|(_, _, page)| page.has_more);
|
||||||
|
let histories = histories
|
||||||
|
.into_iter()
|
||||||
|
.map(|(lane, branch, page)| (lane, branch, page.items))
|
||||||
|
.collect();
|
||||||
|
let pull_refs = self.pull_refs(repository).await.unwrap_or_default();
|
||||||
|
Ok(Page {
|
||||||
|
items: build_graph(histories, pull_refs),
|
||||||
|
has_more,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn commit(&self, repository: &RepositoryId, sha: &str) -> Result<models::Commit> {
|
||||||
|
apis::repository_api::repo_get_single_commit(
|
||||||
|
&self.configuration(),
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
sha,
|
||||||
|
Some(false),
|
||||||
|
Some(true),
|
||||||
|
Some(true),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Error::generated)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn commit_diff(&self, repository: &RepositoryId, sha: &str) -> Result<String> {
|
||||||
|
apis::repository_api::repo_download_commit_diff_or_patch(
|
||||||
|
&self.configuration(),
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
sha,
|
||||||
|
"diff",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Error::generated)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pull_refs(&self, repository: &RepositoryId) -> Result<PullMetadata> {
|
||||||
|
let mut refs = PullMetadata::default();
|
||||||
|
for page in 1.. {
|
||||||
|
let batch = self.repository_pulls(repository, "all", page, 100).await?;
|
||||||
|
for pull in batch.items {
|
||||||
|
let Some(head) = pull.head else { continue };
|
||||||
|
let Some(sha) = head.sha else { continue };
|
||||||
|
let Some(label) = head.label.or(head.r#ref) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !label.is_empty() {
|
||||||
|
refs.tips
|
||||||
|
.entry(sha.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(label.clone());
|
||||||
|
if let Some(base) = pull.merge_base.filter(|base| !base.is_empty()) {
|
||||||
|
refs.starts.push(PullBranch {
|
||||||
|
head: sha,
|
||||||
|
base,
|
||||||
|
label,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !batch.has_more {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(refs)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn commits_for_ref(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
branch: Option<&str>,
|
||||||
|
path: Option<&str>,
|
||||||
|
pages: u32,
|
||||||
|
) -> Result<Page<models::Commit>> {
|
||||||
|
let configuration = self.configuration();
|
||||||
|
let mut commits = Vec::new();
|
||||||
|
let mut has_more = false;
|
||||||
|
for page in 1..=pages.max(1) {
|
||||||
|
let batch = apis::repository_api::repo_get_all_commits(
|
||||||
|
&configuration,
|
||||||
|
&repository.owner,
|
||||||
|
&repository.repository,
|
||||||
|
branch,
|
||||||
|
path,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(false),
|
||||||
|
Some(page as i32),
|
||||||
|
Some(DEFAULT_PAGE_SIZE),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Error::generated)?;
|
||||||
|
has_more = batch.len() == DEFAULT_PAGE_SIZE as usize;
|
||||||
|
commits.extend(batch);
|
||||||
|
if !has_more {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Page {
|
||||||
|
items: commits,
|
||||||
|
has_more,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct PullMetadata {
|
||||||
|
tips: HashMap<String, Vec<String>>,
|
||||||
|
starts: Vec<PullBranch>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PullBranch {
|
||||||
|
head: String,
|
||||||
|
base: String,
|
||||||
|
label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_graph(
|
||||||
|
histories: Vec<(usize, String, Vec<models::Commit>)>,
|
||||||
|
extra_refs: PullMetadata,
|
||||||
|
) -> Vec<HistoryCommit> {
|
||||||
|
let mut commits = HashMap::new();
|
||||||
|
let mut ranks: HashMap<String, (usize, usize)> = HashMap::new();
|
||||||
|
let mut refs: HashMap<String, Vec<String>> = HashMap::new();
|
||||||
|
for (branch_index, branch, history) in histories {
|
||||||
|
if let Some(sha) = history.iter().find_map(|commit| commit.sha.clone()) {
|
||||||
|
refs.entry(sha).or_default().push(branch);
|
||||||
|
}
|
||||||
|
for (index, commit) in history.into_iter().enumerate() {
|
||||||
|
let Some(sha) = commit.sha.clone() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
ranks
|
||||||
|
.entry(sha.clone())
|
||||||
|
.and_modify(|rank| *rank = (*rank).min((branch_index, index)))
|
||||||
|
.or_insert((branch_index, index));
|
||||||
|
commits.entry(sha).or_insert(commit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (sha, labels) in extra_refs.tips {
|
||||||
|
let row = refs.entry(sha).or_default();
|
||||||
|
for label in labels {
|
||||||
|
if !row.contains(&label) {
|
||||||
|
row.push(label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut branch_starts = branch_starts(&commits, extra_refs.starts);
|
||||||
|
|
||||||
|
let mut children = HashMap::new();
|
||||||
|
for sha in commits.keys() {
|
||||||
|
children.insert(sha.clone(), 0_usize);
|
||||||
|
}
|
||||||
|
for commit in commits.values() {
|
||||||
|
for parent in commit_parents(commit) {
|
||||||
|
if let Some(count) = children.get_mut(parent) {
|
||||||
|
*count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut ready = BinaryHeap::new();
|
||||||
|
for (sha, count) in &children {
|
||||||
|
if *count == 0 {
|
||||||
|
ready.push(Reverse((ranks[sha], sha.clone())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut ordered = Vec::with_capacity(commits.len());
|
||||||
|
while let Some(Reverse((_, sha))) = ready.pop() {
|
||||||
|
let Some(commit) = commits.remove(&sha) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for parent in commit_parents(&commit) {
|
||||||
|
if let Some(count) = children.get_mut(parent) {
|
||||||
|
*count -= 1;
|
||||||
|
if *count == 0 {
|
||||||
|
ready.push(Reverse((ranks[parent], parent.to_string())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ordered.push(HistoryCommit {
|
||||||
|
commit,
|
||||||
|
top_lanes: Vec::new(),
|
||||||
|
bottom_lanes: Vec::new(),
|
||||||
|
node_lane: None,
|
||||||
|
top_connections: Vec::new(),
|
||||||
|
bottom_connections: Vec::new(),
|
||||||
|
refs: refs.remove(&sha).unwrap_or_default(),
|
||||||
|
branch_starts: branch_starts.remove(&sha).unwrap_or_default(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
layout_graph(&mut ordered);
|
||||||
|
ordered
|
||||||
|
}
|
||||||
|
|
||||||
|
fn branch_starts(
|
||||||
|
commits: &HashMap<String, models::Commit>,
|
||||||
|
branches: Vec<PullBranch>,
|
||||||
|
) -> HashMap<String, Vec<String>> {
|
||||||
|
let mut starts: HashMap<String, Vec<String>> = HashMap::new();
|
||||||
|
for branch in branches {
|
||||||
|
let Some(sha) = first_commit_after(commits, &branch.head, &branch.base) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let labels = starts.entry(sha).or_default();
|
||||||
|
if !labels.contains(&branch.label) {
|
||||||
|
labels.push(branch.label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
starts
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_commit_after(
|
||||||
|
commits: &HashMap<String, models::Commit>,
|
||||||
|
head: &str,
|
||||||
|
base: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
let mut sha = head;
|
||||||
|
for _ in 0..commits.len() {
|
||||||
|
let parent = commit_parents(commits.get(sha)?).next()?;
|
||||||
|
if parent == base {
|
||||||
|
return Some(sha.into());
|
||||||
|
}
|
||||||
|
sha = parent;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn layout_graph(commits: &mut [HistoryCommit]) {
|
||||||
|
let mut lanes: Vec<Option<String>> = Vec::new();
|
||||||
|
for row in commits {
|
||||||
|
let Some(sha) = row.commit.sha.as_deref() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let matching_lanes: Vec<_> = lanes
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(index, next)| (next.as_deref() == Some(sha)).then_some(index))
|
||||||
|
.collect();
|
||||||
|
let existing_node = matching_lanes.first().copied();
|
||||||
|
let node = existing_node.unwrap_or_else(|| allocate_lane(&mut lanes, sha));
|
||||||
|
row.top_lanes = lanes
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(index, lane)| lane.as_ref().map(|_| index))
|
||||||
|
.filter(|index| existing_node.is_some() || *index != node)
|
||||||
|
.collect();
|
||||||
|
let mut top_connections = BTreeSet::new();
|
||||||
|
for duplicate in matching_lanes.into_iter().skip(1) {
|
||||||
|
lanes[duplicate] = None;
|
||||||
|
top_connections.insert(duplicate);
|
||||||
|
}
|
||||||
|
let mut bottom_connections = BTreeSet::new();
|
||||||
|
let parents: Vec<_> = commit_parents(&row.commit).map(str::to_string).collect();
|
||||||
|
lanes[node] = None;
|
||||||
|
for (index, parent) in parents.into_iter().enumerate() {
|
||||||
|
if index == 0 {
|
||||||
|
lanes[node] = Some(parent);
|
||||||
|
} else if let Some(existing) = lanes
|
||||||
|
.iter()
|
||||||
|
.position(|next| next.as_deref() == Some(&parent))
|
||||||
|
{
|
||||||
|
if node != existing {
|
||||||
|
bottom_connections.insert(existing);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let branch = allocate_lane(&mut lanes, &parent);
|
||||||
|
bottom_connections.insert(branch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row.node_lane = Some(node);
|
||||||
|
row.top_connections = top_connections.into_iter().collect();
|
||||||
|
row.bottom_connections = bottom_connections.into_iter().collect();
|
||||||
|
row.bottom_lanes = lanes
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(index, lane)| lane.as_ref().map(|_| index))
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allocate_lane(lanes: &mut Vec<Option<String>>, sha: &str) -> usize {
|
||||||
|
if let Some(index) = lanes.iter().position(Option::is_none) {
|
||||||
|
lanes[index] = Some(sha.into());
|
||||||
|
index
|
||||||
|
} else {
|
||||||
|
lanes.push(Some(sha.into()));
|
||||||
|
lanes.len() - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commit_parents(commit: &models::Commit) -> impl Iterator<Item = &str> {
|
||||||
|
commit
|
||||||
|
.parents
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|parent| parent.sha.as_deref())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
208
crates/gitea/src/repositories/tests.rs
Normal file
208
crates/gitea/src/repositories/tests.rs
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
use super::*;
|
||||||
|
use std::{
|
||||||
|
io::{Read, Write},
|
||||||
|
net::TcpListener,
|
||||||
|
thread,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn commit(sha: &str, parents: &[&str]) -> models::Commit {
|
||||||
|
models::Commit {
|
||||||
|
sha: Some(sha.into()),
|
||||||
|
parents: Some(
|
||||||
|
parents
|
||||||
|
.iter()
|
||||||
|
.map(|sha| models::CommitMeta {
|
||||||
|
sha: Some((*sha).into()),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lays_out_shared_commit_graph() {
|
||||||
|
let rows = build_graph(
|
||||||
|
vec![
|
||||||
|
(
|
||||||
|
0,
|
||||||
|
"main".into(),
|
||||||
|
vec![
|
||||||
|
commit("merge", &["left", "right"]),
|
||||||
|
commit("left", &["base"]),
|
||||||
|
commit("base", &[]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
1,
|
||||||
|
"feature".into(),
|
||||||
|
vec![commit("right", &["base"]), commit("base", &[])],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
PullMetadata::default(),
|
||||||
|
);
|
||||||
|
assert_eq!(rows.len(), 4);
|
||||||
|
assert!(rows.iter().any(|row| row.refs == ["main"]));
|
||||||
|
assert!(rows.iter().any(|row| row.refs == ["feature"]));
|
||||||
|
let merge = rows
|
||||||
|
.iter()
|
||||||
|
.find(|row| row.commit.sha.as_deref() == Some("merge"))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(merge.node_lane, Some(0));
|
||||||
|
assert_eq!(merge.bottom_connections, [1]);
|
||||||
|
let right = rows
|
||||||
|
.iter()
|
||||||
|
.find(|row| row.commit.sha.as_deref() == Some("right"))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(right.node_lane, Some(1));
|
||||||
|
let base = rows
|
||||||
|
.iter()
|
||||||
|
.find(|row| row.commit.sha.as_deref() == Some("base"))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(base.node_lane, Some(0));
|
||||||
|
assert_eq!(base.top_connections, [1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn labels_first_commit_of_pull_branch() {
|
||||||
|
let rows = build_graph(
|
||||||
|
vec![(
|
||||||
|
0,
|
||||||
|
"main".into(),
|
||||||
|
vec![
|
||||||
|
commit("merge", &["main", "head"]),
|
||||||
|
commit("head", &["first"]),
|
||||||
|
commit("first", &["base"]),
|
||||||
|
commit("main", &["base"]),
|
||||||
|
commit("base", &[]),
|
||||||
|
],
|
||||||
|
)],
|
||||||
|
PullMetadata {
|
||||||
|
tips: HashMap::from([("head".into(), vec!["feature".into()])]),
|
||||||
|
starts: vec![PullBranch {
|
||||||
|
head: "head".into(),
|
||||||
|
base: "base".into(),
|
||||||
|
label: "feature".into(),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let first = rows
|
||||||
|
.iter()
|
||||||
|
.find(|row| row.commit.sha.as_deref() == Some("first"))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(first.branch_starts, ["feature"]);
|
||||||
|
assert!(
|
||||||
|
rows.iter()
|
||||||
|
.find(|row| row.commit.sha.as_deref() == Some("head"))
|
||||||
|
.unwrap()
|
||||||
|
.refs
|
||||||
|
.contains(&"feature".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reads_nested_directory_from_compatible_contents_endpoint() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let address = listener.local_addr().unwrap();
|
||||||
|
let server = thread::spawn(move || {
|
||||||
|
let (mut stream, _) = listener.accept().unwrap();
|
||||||
|
let mut buffer = [0; 4096];
|
||||||
|
let length = stream.read(&mut buffer).unwrap();
|
||||||
|
let request = String::from_utf8_lossy(&buffer[..length]).into_owned();
|
||||||
|
let body = r#"[{"name":"guide.md","path":"docs/private guide/guide.md","type":"file"}]"#;
|
||||||
|
write!(
|
||||||
|
stream,
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||||
|
body.len()
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
request
|
||||||
|
});
|
||||||
|
|
||||||
|
let client =
|
||||||
|
Client::with_provider(&format!("http://{address}"), None, crate::Provider::Forgejo)
|
||||||
|
.unwrap();
|
||||||
|
let repository = RepositoryId::new("forgejo", "forgejo").unwrap();
|
||||||
|
let contents = client
|
||||||
|
.repository_contents(&repository, "docs/private guide")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(contents.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
contents[0].path.as_deref(),
|
||||||
|
Some("docs/private guide/guide.md")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
server
|
||||||
|
.join()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("GET /api/v1/repos/forgejo/forgejo/contents/docs%2Fprivate%20guide ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reads_file_with_spaces_from_raw_endpoint() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let address = listener.local_addr().unwrap();
|
||||||
|
let server = thread::spawn(move || {
|
||||||
|
let (mut stream, _) = listener.accept().unwrap();
|
||||||
|
let mut buffer = [0; 4096];
|
||||||
|
let length = stream.read(&mut buffer).unwrap();
|
||||||
|
let request = String::from_utf8_lossy(&buffer[..length]).into_owned();
|
||||||
|
let body = "contents";
|
||||||
|
write!(
|
||||||
|
stream,
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||||
|
body.len()
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
request
|
||||||
|
});
|
||||||
|
|
||||||
|
let client = Client::new(&format!("http://{address}"), None).unwrap();
|
||||||
|
let repository = RepositoryId::new("gitea", "gitea").unwrap();
|
||||||
|
let contents = client
|
||||||
|
.repository_file(&repository, "docs/private guide/read me.md")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(contents, b"contents");
|
||||||
|
assert!(
|
||||||
|
server.join().unwrap().starts_with(
|
||||||
|
"GET /api/v1/repos/gitea/gitea/raw/docs%2Fprivate%20guide%2Fread%20me.md "
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reads_historical_file_from_requested_ref() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let address = listener.local_addr().unwrap();
|
||||||
|
let server = thread::spawn(move || {
|
||||||
|
let (mut stream, _) = listener.accept().unwrap();
|
||||||
|
let mut buffer = [0; 4096];
|
||||||
|
let length = stream.read(&mut buffer).unwrap();
|
||||||
|
let request = String::from_utf8_lossy(&buffer[..length]).into_owned();
|
||||||
|
write!(
|
||||||
|
stream,
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Length: 8\r\nConnection: close\r\n\r\ncontents"
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
request
|
||||||
|
});
|
||||||
|
|
||||||
|
let client = Client::new(&format!("http://{address}"), None).unwrap();
|
||||||
|
let repository = RepositoryId::new("gitea", "gitea").unwrap();
|
||||||
|
client
|
||||||
|
.repository_file_at_ref(&repository, ".gitea/workflows/ci.yml", Some("abc123"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
server.join().unwrap().starts_with(
|
||||||
|
"GET /api/v1/repos/gitea/gitea/raw/.gitea%2Fworkflows%2Fci.yml?ref=abc123 "
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
20
crates/tui/Cargo.toml
Normal file
20
crates/tui/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "gotcha-tui"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Ratatui terminal client for Gitea and Forgejo"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "gotcha-tui"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
crossterm = "0.29"
|
||||||
|
gotcha_gitea = { path = "../gitea" }
|
||||||
|
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color"] }
|
||||||
|
syntect = { version = "5.3", default-features = false, features = ["default-syntaxes", "default-themes", "regex-fancy"] }
|
||||||
|
tokio.workspace = true
|
||||||
|
tui-markdown = { version = "0.3", default-features = false }
|
||||||
|
unicode-width = "0.2"
|
||||||
2685
crates/tui/src/app.rs
Normal file
2685
crates/tui/src/app.rs
Normal file
File diff suppressed because it is too large
Load Diff
344
crates/tui/src/editor.rs
Normal file
344
crates/tui/src/editor.rs
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
|
||||||
|
use gotcha_gitea::{Provider, RepositoryId};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum EditorAction {
|
||||||
|
Issue {
|
||||||
|
repository: RepositoryId,
|
||||||
|
number: Option<i64>,
|
||||||
|
},
|
||||||
|
Comment {
|
||||||
|
repository: RepositoryId,
|
||||||
|
number: i64,
|
||||||
|
comment_id: Option<i64>,
|
||||||
|
},
|
||||||
|
Milestone {
|
||||||
|
repository: RepositoryId,
|
||||||
|
id: Option<i64>,
|
||||||
|
},
|
||||||
|
Server {
|
||||||
|
original_name: Option<String>,
|
||||||
|
},
|
||||||
|
Settings,
|
||||||
|
IssueFilter,
|
||||||
|
PullFilter,
|
||||||
|
ActionDispatch {
|
||||||
|
repository: RepositoryId,
|
||||||
|
workflow: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct Field {
|
||||||
|
pub label: String,
|
||||||
|
pub value: String,
|
||||||
|
pub secret: bool,
|
||||||
|
pub multiline: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Field {
|
||||||
|
pub fn new(label: &str, value: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
label: label.into(),
|
||||||
|
value: value.into(),
|
||||||
|
secret: false,
|
||||||
|
multiline: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn secret(mut self) -> Self {
|
||||||
|
self.secret = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn multiline(mut self) -> Self {
|
||||||
|
self.multiline = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Editor {
|
||||||
|
pub title: String,
|
||||||
|
pub fields: Vec<Field>,
|
||||||
|
pub focus: usize,
|
||||||
|
pub cursor: usize,
|
||||||
|
pub action: EditorAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Editor {
|
||||||
|
pub fn issue(repository: RepositoryId, number: Option<i64>, fields: [String; 6]) -> Self {
|
||||||
|
let [title, body, labels, milestone, due_date, state] = fields;
|
||||||
|
Self::new(
|
||||||
|
if number.is_some() {
|
||||||
|
"Edit issue"
|
||||||
|
} else {
|
||||||
|
"New issue"
|
||||||
|
},
|
||||||
|
EditorAction::Issue { repository, number },
|
||||||
|
vec![
|
||||||
|
Field::new("Title", title),
|
||||||
|
Field::new("Body", body).multiline(),
|
||||||
|
Field::new("Labels (comma separated)", labels),
|
||||||
|
Field::new("Milestone", milestone),
|
||||||
|
Field::new("Due date (YYYY-MM-DD)", due_date),
|
||||||
|
Field::new("State (open/closed)", state),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn comment(
|
||||||
|
repository: RepositoryId,
|
||||||
|
number: i64,
|
||||||
|
comment_id: Option<i64>,
|
||||||
|
body: String,
|
||||||
|
) -> Self {
|
||||||
|
Self::new(
|
||||||
|
if comment_id.is_some() {
|
||||||
|
"Edit comment"
|
||||||
|
} else {
|
||||||
|
"New comment"
|
||||||
|
},
|
||||||
|
EditorAction::Comment {
|
||||||
|
repository,
|
||||||
|
number,
|
||||||
|
comment_id,
|
||||||
|
},
|
||||||
|
vec![Field::new("Comment", body).multiline()],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn issue_filter(state: &str, labels: &str, milestone: &str, search: &str) -> Self {
|
||||||
|
Self::new(
|
||||||
|
"Issue filters",
|
||||||
|
EditorAction::IssueFilter,
|
||||||
|
vec![
|
||||||
|
Field::new("State (open/closed/all)", state),
|
||||||
|
Field::new("Labels (comma separated)", labels),
|
||||||
|
Field::new("Milestone", milestone),
|
||||||
|
Field::new("Search", search),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pull_filter(state: &str, milestone: &str, search: &str) -> Self {
|
||||||
|
Self::new(
|
||||||
|
"Pull request filters",
|
||||||
|
EditorAction::PullFilter,
|
||||||
|
vec![
|
||||||
|
Field::new("State (open/closed)", state),
|
||||||
|
Field::new("Milestone", milestone),
|
||||||
|
Field::new("Search", search),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn milestone(
|
||||||
|
repository: RepositoryId,
|
||||||
|
id: Option<i64>,
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
due_date: String,
|
||||||
|
state: String,
|
||||||
|
) -> Self {
|
||||||
|
Self::new(
|
||||||
|
if id.is_some() {
|
||||||
|
"Edit milestone"
|
||||||
|
} else {
|
||||||
|
"New milestone"
|
||||||
|
},
|
||||||
|
EditorAction::Milestone { repository, id },
|
||||||
|
vec![
|
||||||
|
Field::new("Title", title),
|
||||||
|
Field::new("Description", description).multiline(),
|
||||||
|
Field::new("Due date (YYYY-MM-DD)", due_date),
|
||||||
|
Field::new("State (open/closed)", state),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn server(
|
||||||
|
original_name: Option<String>,
|
||||||
|
name: String,
|
||||||
|
url: String,
|
||||||
|
provider: Provider,
|
||||||
|
) -> Self {
|
||||||
|
Self::new(
|
||||||
|
if original_name.is_some() {
|
||||||
|
"Edit server"
|
||||||
|
} else {
|
||||||
|
"Add server"
|
||||||
|
},
|
||||||
|
EditorAction::Server { original_name },
|
||||||
|
vec![
|
||||||
|
Field::new("Profile name (host[:port])", name),
|
||||||
|
Field::new("Server URL", url),
|
||||||
|
Field::new("Token (blank keeps existing)", "").secret(),
|
||||||
|
Field::new("Provider (gitea/forgejo)", provider.to_string()),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn settings(refresh_seconds: u64) -> Self {
|
||||||
|
Self::new(
|
||||||
|
"TUI preferences",
|
||||||
|
EditorAction::Settings,
|
||||||
|
vec![Field::new(
|
||||||
|
"Auto-reload seconds (0 disables)",
|
||||||
|
refresh_seconds.to_string(),
|
||||||
|
)],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn action_dispatch(repository: RepositoryId, workflow: String, reference: String) -> Self {
|
||||||
|
Self::new(
|
||||||
|
"Dispatch workflow",
|
||||||
|
EditorAction::ActionDispatch {
|
||||||
|
repository,
|
||||||
|
workflow,
|
||||||
|
},
|
||||||
|
vec![
|
||||||
|
Field::new("Git reference", reference),
|
||||||
|
Field::new("Inputs (one KEY=VALUE per line)", "").multiline(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new(title: &str, action: EditorAction, fields: Vec<Field>) -> Self {
|
||||||
|
let cursor = fields
|
||||||
|
.first()
|
||||||
|
.map_or(0, |field| field.value.chars().count());
|
||||||
|
Self {
|
||||||
|
title: title.into(),
|
||||||
|
fields,
|
||||||
|
focus: 0,
|
||||||
|
cursor,
|
||||||
|
action,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_key(&mut self, key: KeyEvent) -> EditorEvent {
|
||||||
|
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('s') {
|
||||||
|
return EditorEvent::Submit;
|
||||||
|
}
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => EditorEvent::Cancel,
|
||||||
|
KeyCode::Tab | KeyCode::Down => {
|
||||||
|
self.move_focus(1);
|
||||||
|
EditorEvent::Changed
|
||||||
|
}
|
||||||
|
KeyCode::BackTab | KeyCode::Up => {
|
||||||
|
self.move_focus(self.fields.len().saturating_sub(1));
|
||||||
|
EditorEvent::Changed
|
||||||
|
}
|
||||||
|
KeyCode::Left => {
|
||||||
|
self.cursor = self.cursor.saturating_sub(1);
|
||||||
|
EditorEvent::Changed
|
||||||
|
}
|
||||||
|
KeyCode::Right => {
|
||||||
|
self.cursor = (self.cursor + 1).min(self.current_len());
|
||||||
|
EditorEvent::Changed
|
||||||
|
}
|
||||||
|
KeyCode::Home => {
|
||||||
|
self.cursor = 0;
|
||||||
|
EditorEvent::Changed
|
||||||
|
}
|
||||||
|
KeyCode::End => {
|
||||||
|
self.cursor = self.current_len();
|
||||||
|
EditorEvent::Changed
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
if self.cursor > 0 {
|
||||||
|
let end = byte_index(&self.fields[self.focus].value, self.cursor);
|
||||||
|
let start = byte_index(&self.fields[self.focus].value, self.cursor - 1);
|
||||||
|
self.fields[self.focus].value.replace_range(start..end, "");
|
||||||
|
self.cursor -= 1;
|
||||||
|
}
|
||||||
|
EditorEvent::Changed
|
||||||
|
}
|
||||||
|
KeyCode::Delete => {
|
||||||
|
if self.cursor < self.current_len() {
|
||||||
|
let start = byte_index(&self.fields[self.focus].value, self.cursor);
|
||||||
|
let end = byte_index(&self.fields[self.focus].value, self.cursor + 1);
|
||||||
|
self.fields[self.focus].value.replace_range(start..end, "");
|
||||||
|
}
|
||||||
|
EditorEvent::Changed
|
||||||
|
}
|
||||||
|
KeyCode::Enter if self.fields[self.focus].multiline => {
|
||||||
|
self.insert('\n');
|
||||||
|
EditorEvent::Changed
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
self.move_focus(1);
|
||||||
|
EditorEvent::Changed
|
||||||
|
}
|
||||||
|
KeyCode::Char(character)
|
||||||
|
if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT =>
|
||||||
|
{
|
||||||
|
self.insert(character);
|
||||||
|
EditorEvent::Changed
|
||||||
|
}
|
||||||
|
_ => EditorEvent::Ignored,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert(&mut self, character: char) {
|
||||||
|
let index = byte_index(&self.fields[self.focus].value, self.cursor);
|
||||||
|
self.fields[self.focus].value.insert(index, character);
|
||||||
|
self.cursor += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_focus(&mut self, amount: usize) {
|
||||||
|
self.focus = (self.focus + amount) % self.fields.len();
|
||||||
|
self.cursor = self.current_len();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_len(&self) -> usize {
|
||||||
|
self.fields[self.focus].value.chars().count()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn values(&self) -> Vec<String> {
|
||||||
|
self.fields
|
||||||
|
.iter()
|
||||||
|
.map(|field| field.value.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum EditorEvent {
|
||||||
|
Changed,
|
||||||
|
Submit,
|
||||||
|
Cancel,
|
||||||
|
Ignored,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn byte_index(value: &str, character: usize) -> usize {
|
||||||
|
value
|
||||||
|
.char_indices()
|
||||||
|
.nth(character)
|
||||||
|
.map_or(value.len(), |(index, _)| index)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editor_backspace_edits_instead_of_navigating() {
|
||||||
|
let mut editor = Editor::settings(5);
|
||||||
|
editor.fields[0].value = "é5".into();
|
||||||
|
editor.cursor = 1;
|
||||||
|
assert_eq!(
|
||||||
|
editor.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)),
|
||||||
|
EditorEvent::Changed
|
||||||
|
);
|
||||||
|
assert_eq!(editor.fields[0].value, "5");
|
||||||
|
assert_eq!(editor.cursor, 0);
|
||||||
|
assert_eq!(
|
||||||
|
editor.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)),
|
||||||
|
EditorEvent::Changed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
83
crates/tui/src/main.rs
Normal file
83
crates/tui/src/main.rs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
mod app;
|
||||||
|
mod editor;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
use std::{env, error::Error, io, time::Duration};
|
||||||
|
|
||||||
|
use crossterm::{
|
||||||
|
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind},
|
||||||
|
execute,
|
||||||
|
};
|
||||||
|
use gotcha_gitea::Config;
|
||||||
|
|
||||||
|
const HELP: &str = "\
|
||||||
|
Gotcha TUI
|
||||||
|
|
||||||
|
Usage: gotcha-tui [--server SERVER]
|
||||||
|
|
||||||
|
Keyboard:
|
||||||
|
1..5 switch Home, Issues, Repos, PRs, Milestones
|
||||||
|
j/k, arrows move selection
|
||||||
|
n/p or ]/[ next/previous page
|
||||||
|
Enter open selected item
|
||||||
|
Backspace go back (edits text inside an editor)
|
||||||
|
a/e/c/x/d add, edit, comment, toggle state, delete
|
||||||
|
/, v, b, * filters, activity filter, branch, repository favorite
|
||||||
|
f browse files from a repository commit view
|
||||||
|
r reload
|
||||||
|
s choose or manage servers
|
||||||
|
, edit TUI preferences
|
||||||
|
q quit
|
||||||
|
|
||||||
|
Editors use Tab/Shift-Tab between fields, normal cursor/editing keys, Ctrl-S to
|
||||||
|
save, and Esc to cancel. Mouse clicks select items, double-click opens them,
|
||||||
|
and the scroll wheel moves through lists.";
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn Error>> {
|
||||||
|
let requested_server = parse_args()?;
|
||||||
|
let config = Config::load()?;
|
||||||
|
let mut app = app::App::new(config, requested_server.as_deref()).await?;
|
||||||
|
|
||||||
|
let mut terminal = ratatui::init();
|
||||||
|
execute!(io::stdout(), EnableMouseCapture)?;
|
||||||
|
let result = run(&mut terminal, &mut app).await;
|
||||||
|
execute!(io::stdout(), DisableMouseCapture)?;
|
||||||
|
ratatui::restore();
|
||||||
|
result?;
|
||||||
|
app.persist_selected_server()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(
|
||||||
|
terminal: &mut ratatui::DefaultTerminal,
|
||||||
|
app: &mut app::App,
|
||||||
|
) -> Result<(), Box<dyn Error>> {
|
||||||
|
while !app.should_quit {
|
||||||
|
terminal.draw(|frame| ui::draw(frame, app))?;
|
||||||
|
if event::poll(Duration::from_millis(100))? {
|
||||||
|
match event::read()? {
|
||||||
|
Event::Key(key) if key.kind == KeyEventKind::Press => app.handle_key(key).await,
|
||||||
|
Event::Mouse(mouse) => app.handle_mouse(mouse).await,
|
||||||
|
Event::Resize(_, _) => {}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
} else if app.should_auto_reload() {
|
||||||
|
app.reload().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_args() -> Result<Option<String>, String> {
|
||||||
|
let args: Vec<_> = env::args().skip(1).collect();
|
||||||
|
match args.as_slice() {
|
||||||
|
[] => Ok(None),
|
||||||
|
[help] if matches!(help.as_str(), "-h" | "--help" | "help") => {
|
||||||
|
println!("{HELP}");
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
[option, server] if option == "--server" => Ok(Some(server.clone())),
|
||||||
|
_ => Err(format!("invalid arguments\n\n{HELP}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
687
crates/tui/src/ui.rs
Normal file
687
crates/tui/src/ui.rs
Normal file
@@ -0,0 +1,687 @@
|
|||||||
|
use std::{cell::RefCell, sync::LazyLock};
|
||||||
|
|
||||||
|
use ratatui::{
|
||||||
|
Frame,
|
||||||
|
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span, Text},
|
||||||
|
widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Tabs, Wrap},
|
||||||
|
};
|
||||||
|
use syntect::{
|
||||||
|
easy::HighlightLines,
|
||||||
|
highlighting::{FontStyle, ThemeSet},
|
||||||
|
parsing::SyntaxSet,
|
||||||
|
util::LinesWithEndings,
|
||||||
|
};
|
||||||
|
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
app::{ActivityKind, App, ItemDecoration, ScreenKind, Tab, WorkItemState},
|
||||||
|
editor::Editor,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACCENT: Color = Color::Cyan;
|
||||||
|
static SYNTAXES: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
|
||||||
|
static THEMES: LazyLock<ThemeSet> = LazyLock::new(ThemeSet::load_defaults);
|
||||||
|
thread_local! {
|
||||||
|
static HIGHLIGHT_CACHE: RefCell<Option<(String, String, Text<'static>)>> = const { RefCell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
|
||||||
|
let area = frame.area();
|
||||||
|
let rows = Layout::vertical([
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Min(4),
|
||||||
|
Constraint::Length(1),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
draw_tabs(frame, app, rows[0]);
|
||||||
|
draw_content(frame, app, rows[1]);
|
||||||
|
draw_footer(frame, app, rows[2]);
|
||||||
|
|
||||||
|
if let Some(editor) = &app.editor {
|
||||||
|
draw_editor(frame, editor);
|
||||||
|
} else if let Some((message, _)) = &app.confirm {
|
||||||
|
draw_confirm(frame, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_tabs(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
||||||
|
let titles = Tab::ALL.map(|tab| Line::from(tab.title()));
|
||||||
|
let selected = Tab::ALL.iter().position(|tab| *tab == app.tab).unwrap_or(0);
|
||||||
|
let tabs = Tabs::new(titles)
|
||||||
|
.select(selected)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(format!(" Gotcha · {} ", app.server_name)),
|
||||||
|
)
|
||||||
|
.highlight_style(Style::default().fg(ACCENT).add_modifier(Modifier::BOLD))
|
||||||
|
.divider("│");
|
||||||
|
frame.render_widget(tabs, area);
|
||||||
|
|
||||||
|
let inner = area.inner(ratatui::layout::Margin {
|
||||||
|
horizontal: 1,
|
||||||
|
vertical: 1,
|
||||||
|
});
|
||||||
|
app.tab_areas = tab_areas(inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_content(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
||||||
|
let direction = content_direction(area.width);
|
||||||
|
let panes = Layout::default()
|
||||||
|
.direction(direction)
|
||||||
|
.constraints(if direction == Direction::Horizontal {
|
||||||
|
[Constraint::Percentage(42), Constraint::Percentage(58)]
|
||||||
|
} else {
|
||||||
|
[Constraint::Percentage(55), Constraint::Percentage(45)]
|
||||||
|
})
|
||||||
|
.split(area);
|
||||||
|
app.list_area = panes[0];
|
||||||
|
app.detail_area = panes[1];
|
||||||
|
draw_list(frame, app, panes[0]);
|
||||||
|
draw_detail(frame, app, panes[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tab_areas(area: Rect) -> Vec<Rect> {
|
||||||
|
let mut x = area.x;
|
||||||
|
Tab::ALL
|
||||||
|
.iter()
|
||||||
|
.filter_map(|tab| {
|
||||||
|
let remaining = area.right().saturating_sub(x);
|
||||||
|
if remaining == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let width = (tab.title().len() as u16 + 2).min(remaining);
|
||||||
|
let tab_area = Rect::new(x, area.y, width, area.height.min(1));
|
||||||
|
x = x.saturating_add(width).saturating_add(1);
|
||||||
|
Some(tab_area)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn content_direction(width: u16) -> Direction {
|
||||||
|
if width >= 90 {
|
||||||
|
Direction::Horizontal
|
||||||
|
} else {
|
||||||
|
Direction::Vertical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_list(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
||||||
|
let items = if app.screen.items.is_empty() {
|
||||||
|
vec![ListItem::new(Line::styled(
|
||||||
|
"No items",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
))]
|
||||||
|
} else {
|
||||||
|
app.screen
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.map(|item| {
|
||||||
|
ListItem::new(vec![
|
||||||
|
item_title(
|
||||||
|
&item.title,
|
||||||
|
item.graph_lane,
|
||||||
|
item.decoration,
|
||||||
|
area.width.saturating_sub(4) as usize,
|
||||||
|
),
|
||||||
|
item_meta(
|
||||||
|
&item.meta,
|
||||||
|
item.decoration,
|
||||||
|
area.width.saturating_sub(4) as usize,
|
||||||
|
),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let list = List::new(items)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(format!(" {} ", app.screen.title)),
|
||||||
|
)
|
||||||
|
.highlight_symbol(Line::from(Span::styled(
|
||||||
|
"▸ ",
|
||||||
|
Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
|
||||||
|
)))
|
||||||
|
.highlight_style(Style::default().add_modifier(Modifier::BOLD));
|
||||||
|
let mut state = ListState::default()
|
||||||
|
.with_selected((!app.screen.items.is_empty()).then_some(app.screen.selected));
|
||||||
|
frame.render_stateful_widget(list, area, &mut state);
|
||||||
|
app.list_offset = state.offset();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn item_title(
|
||||||
|
title: &str,
|
||||||
|
graph_lane: Option<usize>,
|
||||||
|
decoration: ItemDecoration,
|
||||||
|
max_width: usize,
|
||||||
|
) -> Line<'static> {
|
||||||
|
let mut spans = graph_lane.map_or_else(Vec::new, |node_lane| {
|
||||||
|
let mut spans = (0..node_lane)
|
||||||
|
.map(|lane| Span::styled("│ ", Style::default().fg(lane_color(lane))))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
spans.push(Span::styled(
|
||||||
|
"● ",
|
||||||
|
Style::default().fg(lane_color(node_lane)),
|
||||||
|
));
|
||||||
|
spans
|
||||||
|
});
|
||||||
|
let icon = match decoration {
|
||||||
|
ItemDecoration::Activity(kind) => Some(activity_icon(kind)),
|
||||||
|
ItemDecoration::State(state) | ItemDecoration::Milestone { state, .. } => {
|
||||||
|
Some(state_icon(state))
|
||||||
|
}
|
||||||
|
ItemDecoration::None => None,
|
||||||
|
};
|
||||||
|
if let Some((icon, color)) = icon {
|
||||||
|
spans.push(Span::styled(icon, Style::default().fg(color)));
|
||||||
|
}
|
||||||
|
let prefix_width = spans.iter().map(Span::width).sum::<usize>();
|
||||||
|
spans.push(Span::styled(
|
||||||
|
truncate(title, max_width.saturating_sub(prefix_width)),
|
||||||
|
Style::default().add_modifier(Modifier::BOLD),
|
||||||
|
));
|
||||||
|
Line::from(spans)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn item_meta(meta: &str, decoration: ItemDecoration, max_width: usize) -> Line<'static> {
|
||||||
|
let ItemDecoration::Milestone { closed, total, .. } = decoration else {
|
||||||
|
return Line::from(Span::styled(
|
||||||
|
truncate(meta, max_width),
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let bar_width = 10.min(max_width.saturating_sub(1));
|
||||||
|
let closed = closed.max(0) as usize;
|
||||||
|
let total = total.max(0) as usize;
|
||||||
|
let filled = closed
|
||||||
|
.saturating_mul(bar_width)
|
||||||
|
.saturating_add(total / 2)
|
||||||
|
.checked_div(total)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.min(bar_width);
|
||||||
|
let mut spans = vec![
|
||||||
|
Span::styled("█".repeat(filled), Style::default().fg(Color::LightGreen)),
|
||||||
|
Span::styled(
|
||||||
|
"░".repeat(bar_width - filled),
|
||||||
|
Style::default().fg(if total == 0 {
|
||||||
|
Color::DarkGray
|
||||||
|
} else {
|
||||||
|
Color::LightYellow
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if bar_width < max_width {
|
||||||
|
spans.push(Span::raw(" "));
|
||||||
|
spans.push(Span::styled(
|
||||||
|
truncate(meta, max_width - bar_width - 1),
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Line::from(spans)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate(value: &str, width: usize) -> String {
|
||||||
|
if UnicodeWidthStr::width(value) <= width {
|
||||||
|
return value.to_string();
|
||||||
|
}
|
||||||
|
if width == 0 {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
let mut result = String::new();
|
||||||
|
let mut used = 0;
|
||||||
|
for character in value.chars() {
|
||||||
|
let character_width = UnicodeWidthChar::width(character).unwrap_or_default();
|
||||||
|
if used + character_width >= width {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
result.push(character);
|
||||||
|
used += character_width;
|
||||||
|
}
|
||||||
|
result.push('…');
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn activity_icon(kind: ActivityKind) -> (&'static str, Color) {
|
||||||
|
match kind {
|
||||||
|
ActivityKind::Repository => ("▣ ", Color::Yellow),
|
||||||
|
ActivityKind::Issue => ("◉ ", Color::LightGreen),
|
||||||
|
ActivityKind::PullRequest => ("⇄ ", Color::LightMagenta),
|
||||||
|
ActivityKind::Branch => ("⑂ ", Color::LightCyan),
|
||||||
|
ActivityKind::Tag => ("◆ ", Color::LightYellow),
|
||||||
|
ActivityKind::Push => ("↑ ", Color::LightBlue),
|
||||||
|
ActivityKind::Release => ("★ ", Color::LightRed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_icon(state: WorkItemState) -> (&'static str, Color) {
|
||||||
|
match state {
|
||||||
|
WorkItemState::Open => ("● ", Color::LightGreen),
|
||||||
|
WorkItemState::Closed => ("✓ ", Color::LightMagenta),
|
||||||
|
WorkItemState::Unknown => ("? ", Color::DarkGray),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lane_color(lane: usize) -> Color {
|
||||||
|
const COLORS: [Color; 6] = [
|
||||||
|
Color::LightRed,
|
||||||
|
Color::LightGreen,
|
||||||
|
Color::LightBlue,
|
||||||
|
Color::LightMagenta,
|
||||||
|
Color::LightCyan,
|
||||||
|
Color::Yellow,
|
||||||
|
];
|
||||||
|
COLORS[lane % COLORS.len()]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_detail(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||||
|
let mut detail = render_screen_detail(&app.screen.kind, &app.screen.detail);
|
||||||
|
if let Some(item) = app.screen.selected_item()
|
||||||
|
&& !item.detail.is_empty()
|
||||||
|
{
|
||||||
|
if !detail.lines.is_empty() {
|
||||||
|
detail.lines.extend([
|
||||||
|
Line::default(),
|
||||||
|
Line::styled("────", Style::default().fg(Color::DarkGray)),
|
||||||
|
Line::default(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
detail
|
||||||
|
.lines
|
||||||
|
.extend(render_item_detail(&app.screen.kind, &item.detail).lines);
|
||||||
|
}
|
||||||
|
if detail.lines.is_empty() {
|
||||||
|
detail = Text::from("Select an item to see details.");
|
||||||
|
}
|
||||||
|
let paragraph = Paragraph::new(detail)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(" Preview "))
|
||||||
|
.wrap(Wrap { trim: false })
|
||||||
|
.scroll((app.screen.detail_scroll, 0));
|
||||||
|
frame.render_widget(paragraph, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_screen_detail<'a>(kind: &ScreenKind, detail: &'a str) -> Text<'a> {
|
||||||
|
match kind {
|
||||||
|
ScreenKind::File(_, path) => highlight(detail, path),
|
||||||
|
ScreenKind::Text(_, _) if detail.starts_with("diff --git ") => {
|
||||||
|
highlight(detail, "change.diff")
|
||||||
|
}
|
||||||
|
ScreenKind::Text(title, _) => highlight(detail, title),
|
||||||
|
ScreenKind::Issue(_, _) | ScreenKind::Pull(_, _) | ScreenKind::Milestone(_, _) => {
|
||||||
|
render_markdown(detail)
|
||||||
|
}
|
||||||
|
_ => Text::from(detail),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_item_detail<'a>(kind: &ScreenKind, detail: &'a str) -> Text<'a> {
|
||||||
|
if detail.starts_with("diff --git ") {
|
||||||
|
highlight(detail, "change.diff")
|
||||||
|
} else if matches!(
|
||||||
|
kind,
|
||||||
|
ScreenKind::Home
|
||||||
|
| ScreenKind::Issue(_, _)
|
||||||
|
| ScreenKind::Pull(_, _)
|
||||||
|
| ScreenKind::Milestone(_, _)
|
||||||
|
) {
|
||||||
|
render_markdown(detail)
|
||||||
|
} else {
|
||||||
|
Text::from(detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_markdown(markdown: &str) -> Text<'_> {
|
||||||
|
tui_markdown::from_str(markdown)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn highlight(source: &str, path: &str) -> Text<'static> {
|
||||||
|
HIGHLIGHT_CACHE.with_borrow_mut(|cache| {
|
||||||
|
if let Some((cached_path, cached_source, text)) = cache.as_ref()
|
||||||
|
&& cached_path == path
|
||||||
|
&& cached_source == source
|
||||||
|
{
|
||||||
|
return text.clone();
|
||||||
|
}
|
||||||
|
let text = highlight_uncached(source, path);
|
||||||
|
*cache = Some((path.to_owned(), source.to_owned(), text.clone()));
|
||||||
|
text
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn highlight_uncached(source: &str, path: &str) -> Text<'static> {
|
||||||
|
let syntax = SYNTAXES
|
||||||
|
.find_syntax_for_file(path)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.or_else(|| SYNTAXES.find_syntax_by_first_line(source))
|
||||||
|
.unwrap_or_else(|| SYNTAXES.find_syntax_plain_text());
|
||||||
|
let theme = &THEMES.themes["base16-ocean.dark"];
|
||||||
|
let mut highlighter = HighlightLines::new(syntax, theme);
|
||||||
|
let lines = LinesWithEndings::from(source)
|
||||||
|
.map(|line| match highlighter.highlight_line(line, &SYNTAXES) {
|
||||||
|
Ok(ranges) => {
|
||||||
|
let last = ranges.len().saturating_sub(1);
|
||||||
|
Line::from(
|
||||||
|
ranges
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, (style, text))| {
|
||||||
|
let text = if index == last {
|
||||||
|
text.trim_end_matches(['\r', '\n'])
|
||||||
|
} else {
|
||||||
|
text
|
||||||
|
};
|
||||||
|
let mut terminal_style = Style::default().fg(Color::Rgb(
|
||||||
|
style.foreground.r,
|
||||||
|
style.foreground.g,
|
||||||
|
style.foreground.b,
|
||||||
|
));
|
||||||
|
if style.font_style.contains(FontStyle::BOLD) {
|
||||||
|
terminal_style = terminal_style.add_modifier(Modifier::BOLD);
|
||||||
|
}
|
||||||
|
if style.font_style.contains(FontStyle::ITALIC) {
|
||||||
|
terminal_style = terminal_style.add_modifier(Modifier::ITALIC);
|
||||||
|
}
|
||||||
|
if style.font_style.contains(FontStyle::UNDERLINE) {
|
||||||
|
terminal_style = terminal_style.add_modifier(Modifier::UNDERLINED);
|
||||||
|
}
|
||||||
|
Span::styled(text.to_owned(), terminal_style)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Err(_) => Line::from(line.trim_end_matches(['\r', '\n']).to_owned()),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
Text::from(lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_footer(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||||
|
let status = if let Some(status) = app.visible_status() {
|
||||||
|
format!(
|
||||||
|
"{} · Page {} · j/k move · Enter open · Backspace back · n/p pages · q quit",
|
||||||
|
status, app.screen.page
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"Page {} · j/k move · Enter open · Backspace back · n/p pages · a/e/c/x/d actions · / filters · * favorite · r reload · q quit",
|
||||||
|
app.screen.page
|
||||||
|
)
|
||||||
|
};
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(status)
|
||||||
|
.style(Style::default().fg(Color::DarkGray))
|
||||||
|
.alignment(Alignment::Center),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_editor(frame: &mut Frame<'_>, editor: &Editor) {
|
||||||
|
let area = centered(frame.area(), 86, 86);
|
||||||
|
frame.render_widget(Clear, area);
|
||||||
|
frame.render_widget(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(format!(
|
||||||
|
" {} · Tab fields · Ctrl-S save · Esc cancel ",
|
||||||
|
editor.title
|
||||||
|
))
|
||||||
|
.border_style(Style::default().fg(ACCENT)),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
let inner = area.inner(ratatui::layout::Margin {
|
||||||
|
horizontal: 2,
|
||||||
|
vertical: 1,
|
||||||
|
});
|
||||||
|
let heights = editor
|
||||||
|
.fields
|
||||||
|
.iter()
|
||||||
|
.map(|field| Constraint::Length(if field.multiline { 5 } else { 3 }));
|
||||||
|
let fields = Layout::vertical(heights).split(inner);
|
||||||
|
for (index, (field, field_area)) in editor.fields.iter().zip(fields.iter()).enumerate() {
|
||||||
|
let value = if field.secret {
|
||||||
|
"•".repeat(field.value.chars().count())
|
||||||
|
} else {
|
||||||
|
field.value.clone()
|
||||||
|
};
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(format!(" {} ", field.label))
|
||||||
|
.border_style(if index == editor.focus {
|
||||||
|
Style::default().fg(ACCENT)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
});
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(value)
|
||||||
|
.block(block)
|
||||||
|
.wrap(Wrap { trim: false }),
|
||||||
|
*field_area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(field_area) = fields.get(editor.focus) {
|
||||||
|
let prefix: String = editor.fields[editor.focus]
|
||||||
|
.value
|
||||||
|
.chars()
|
||||||
|
.take(editor.cursor)
|
||||||
|
.collect();
|
||||||
|
let lines: Vec<_> = prefix.split('\n').collect();
|
||||||
|
let x = field_area.x
|
||||||
|
+ 1
|
||||||
|
+ lines
|
||||||
|
.last()
|
||||||
|
.map_or(0, |line| line.chars().count())
|
||||||
|
.min(field_area.width.saturating_sub(3) as usize) as u16;
|
||||||
|
let y = field_area.y
|
||||||
|
+ 1
|
||||||
|
+ (lines.len().saturating_sub(1)).min(field_area.height.saturating_sub(3) as usize)
|
||||||
|
as u16;
|
||||||
|
frame.set_cursor_position((x, y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_confirm(frame: &mut Frame<'_>, message: &str) {
|
||||||
|
let area = centered(frame.area(), 64, 24);
|
||||||
|
frame.render_widget(Clear, area);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(format!(
|
||||||
|
"{message}\n\nPress y or Enter to confirm; n or Esc to cancel."
|
||||||
|
))
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(" Confirm destructive action ")
|
||||||
|
.border_style(Style::default().fg(Color::Red)),
|
||||||
|
)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.wrap(Wrap { trim: true }),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn centered(area: Rect, width_percent: u16, height_percent: u16) -> Rect {
|
||||||
|
let vertical = Layout::vertical([
|
||||||
|
Constraint::Percentage((100 - height_percent) / 2),
|
||||||
|
Constraint::Percentage(height_percent),
|
||||||
|
Constraint::Percentage((100 - height_percent) / 2),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
Layout::horizontal([
|
||||||
|
Constraint::Percentage((100 - width_percent) / 2),
|
||||||
|
Constraint::Percentage(width_percent),
|
||||||
|
Constraint::Percentage((100 - width_percent) / 2),
|
||||||
|
])
|
||||||
|
.split(vertical[1])[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use ratatui::{Terminal, backend::TestBackend, widgets::ListItem};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn responsive_popup_stays_inside_small_terminals() {
|
||||||
|
let area = centered(Rect::new(0, 0, 40, 20), 86, 86);
|
||||||
|
assert!(area.width <= 40);
|
||||||
|
assert!(area.height <= 20);
|
||||||
|
assert!(area.width > 0);
|
||||||
|
assert!(area.height > 0);
|
||||||
|
assert_eq!(content_direction(80), Direction::Vertical);
|
||||||
|
assert_eq!(content_direction(120), Direction::Horizontal);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tab_hit_areas_follow_the_rendered_titles() {
|
||||||
|
let areas = tab_areas(Rect::new(1, 1, 100, 1));
|
||||||
|
assert_eq!(areas[0], Rect::new(1, 1, 8, 1));
|
||||||
|
assert_eq!(areas[1], Rect::new(10, 1, 10, 1));
|
||||||
|
assert_eq!(areas[4], Rect::new(39, 1, 14, 1));
|
||||||
|
assert!(!areas[0].contains((9, 1).into()));
|
||||||
|
assert!(!areas[4].contains((80, 1).into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn commit_graph_lanes_use_stable_distinct_colors() {
|
||||||
|
let title = item_title("Merge feature", Some(2), ItemDecoration::None, 30);
|
||||||
|
assert_eq!(title.to_string(), "│ │ ● Merge feature");
|
||||||
|
assert_eq!(title.spans[0].style.fg, Some(lane_color(0)));
|
||||||
|
assert_eq!(title.spans[1].style.fg, Some(lane_color(1)));
|
||||||
|
assert_eq!(title.spans[2].style.fg, Some(lane_color(2)));
|
||||||
|
assert_ne!(lane_color(0), lane_color(1));
|
||||||
|
assert_eq!(lane_color(6), lane_color(0));
|
||||||
|
|
||||||
|
let backend = TestBackend::new(30, 1);
|
||||||
|
let mut terminal = Terminal::new(backend).unwrap();
|
||||||
|
terminal
|
||||||
|
.draw(|frame| {
|
||||||
|
let list = List::new([ListItem::new(title)])
|
||||||
|
.highlight_symbol(Line::from(Span::styled("▸ ", Style::default().fg(ACCENT))))
|
||||||
|
.highlight_style(Style::default().add_modifier(Modifier::BOLD));
|
||||||
|
let mut state = ListState::default().with_selected(Some(0));
|
||||||
|
frame.render_stateful_widget(list, frame.area(), &mut state);
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let buffer = terminal.backend().buffer();
|
||||||
|
assert_eq!(buffer[(0, 0)].fg, ACCENT);
|
||||||
|
assert_eq!(buffer[(2, 0)].fg, lane_color(0));
|
||||||
|
assert_eq!(buffer[(4, 0)].fg, lane_color(1));
|
||||||
|
assert_eq!(buffer[(6, 0)].fg, lane_color(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn home_activity_icons_are_colored_and_titles_fit_the_list() {
|
||||||
|
let issue = item_title(
|
||||||
|
"Notification settings opening issue · hugo/Gotcha",
|
||||||
|
None,
|
||||||
|
ItemDecoration::Activity(ActivityKind::Issue),
|
||||||
|
24,
|
||||||
|
);
|
||||||
|
assert!(issue.to_string().ends_with('…'));
|
||||||
|
assert!(issue.width() <= 24);
|
||||||
|
assert_eq!(issue.spans[0].content, "◉ ");
|
||||||
|
assert_eq!(issue.spans[0].style.fg, Some(Color::LightGreen));
|
||||||
|
|
||||||
|
let kinds = [
|
||||||
|
ActivityKind::Repository,
|
||||||
|
ActivityKind::Issue,
|
||||||
|
ActivityKind::PullRequest,
|
||||||
|
ActivityKind::Branch,
|
||||||
|
ActivityKind::Tag,
|
||||||
|
ActivityKind::Push,
|
||||||
|
ActivityKind::Release,
|
||||||
|
];
|
||||||
|
let colors = kinds.map(activity_icon).map(|(_, color)| color);
|
||||||
|
for (index, color) in colors.iter().enumerate() {
|
||||||
|
assert!(!colors[..index].contains(color));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn work_item_states_and_milestone_progress_have_native_terminal_treatments() {
|
||||||
|
let open = item_title(
|
||||||
|
"Open issue",
|
||||||
|
None,
|
||||||
|
ItemDecoration::State(WorkItemState::Open),
|
||||||
|
20,
|
||||||
|
);
|
||||||
|
let closed = item_title(
|
||||||
|
"Closed issue",
|
||||||
|
None,
|
||||||
|
ItemDecoration::State(WorkItemState::Closed),
|
||||||
|
20,
|
||||||
|
);
|
||||||
|
assert_eq!(open.spans[0].content, "● ");
|
||||||
|
assert_eq!(open.spans[0].style.fg, Some(Color::LightGreen));
|
||||||
|
assert_eq!(closed.spans[0].content, "✓ ");
|
||||||
|
assert_eq!(closed.spans[0].style.fg, Some(Color::LightMagenta));
|
||||||
|
|
||||||
|
let progress = item_meta(
|
||||||
|
"open · 2 of 5 closed · no due date",
|
||||||
|
ItemDecoration::Milestone {
|
||||||
|
state: WorkItemState::Open,
|
||||||
|
closed: 2,
|
||||||
|
total: 5,
|
||||||
|
},
|
||||||
|
40,
|
||||||
|
);
|
||||||
|
assert_eq!(progress.spans[0].content, "████");
|
||||||
|
assert_eq!(progress.spans[1].content, "░░░░░░");
|
||||||
|
assert_eq!(progress.spans[0].style.fg, Some(Color::LightGreen));
|
||||||
|
assert_eq!(progress.spans[1].style.fg, Some(Color::LightYellow));
|
||||||
|
assert!(progress.width() <= 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn previews_render_markdown_source_and_diffs_with_styles() {
|
||||||
|
let repository = gotcha_gitea::RepositoryId {
|
||||||
|
owner: "owner".into(),
|
||||||
|
repository: "project".into(),
|
||||||
|
};
|
||||||
|
let markdown = render_item_detail(
|
||||||
|
&ScreenKind::Home,
|
||||||
|
"# Title\n\nSome *emphasis* and **strong text**.",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
markdown.lines[0]
|
||||||
|
.style
|
||||||
|
.add_modifier
|
||||||
|
.contains(Modifier::BOLD)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
markdown
|
||||||
|
.lines
|
||||||
|
.iter()
|
||||||
|
.flat_map(|line| &line.spans)
|
||||||
|
.any(|span| { span.style.add_modifier.contains(Modifier::ITALIC) })
|
||||||
|
);
|
||||||
|
|
||||||
|
let source = render_screen_detail(
|
||||||
|
&ScreenKind::File(repository, "main.rs".into()),
|
||||||
|
"fn main() { println!(\"hello\"); }\n",
|
||||||
|
);
|
||||||
|
let source_colors = source
|
||||||
|
.lines
|
||||||
|
.iter()
|
||||||
|
.flat_map(|line| &line.spans)
|
||||||
|
.filter_map(|span| span.style.fg)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert!(
|
||||||
|
source_colors
|
||||||
|
.iter()
|
||||||
|
.any(|color| Some(color) != source_colors.first())
|
||||||
|
);
|
||||||
|
|
||||||
|
let diff_source = "diff --git a/main.rs b/main.rs\n@@ -1 +1 @@\n-old\n+new\n";
|
||||||
|
let diff = render_screen_detail(
|
||||||
|
&ScreenKind::Text("main.rs".into(), diff_source.into()),
|
||||||
|
diff_source,
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
diff.lines[2].spans[0].style.fg,
|
||||||
|
diff.lines[3].spans[0].style.fg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
ios/Generated/gotcha_core.modulemap
Normal file
7
ios/Generated/gotcha_core.modulemap
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
module gotcha_core {
|
||||||
|
header "gotcha_coreFFI.h"
|
||||||
|
export *
|
||||||
|
use "Darwin"
|
||||||
|
use "_Builtin_stdbool"
|
||||||
|
use "_Builtin_stdint"
|
||||||
|
}
|
||||||
6722
ios/Generated/gotcha_core.swift
Normal file
6722
ios/Generated/gotcha_core.swift
Normal file
File diff suppressed because it is too large
Load Diff
1170
ios/Generated/gotcha_coreFFI.h
Normal file
1170
ios/Generated/gotcha_coreFFI.h
Normal file
File diff suppressed because it is too large
Load Diff
1
ios/Gotcha-Bridging-Header.h
Normal file
1
ios/Gotcha-Bridging-Header.h
Normal file
@@ -0,0 +1 @@
|
|||||||
|
#include "Generated/gotcha_coreFFI.h"
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.de.rfc1437.gotcha</string>
|
||||||
|
</array>
|
||||||
<key>keychain-access-groups</key>
|
<key>keychain-access-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>$(AppIdentifierPrefix)$(CFBundleIdentifier)</string>
|
<string>$(AppIdentifierPrefix)$(CFBundleIdentifier)</string>
|
||||||
|
|||||||
@@ -7,19 +7,173 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
|
0020E57CC7B0C5CBBF04291C /* HomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35C7ECBC3EEC8B4659234AE /* HomeScreen.swift */; };
|
||||||
|
1279D8E7C8A76F0D7F45F97E /* ServerActivityScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C028DA45E7CB9E1CD56754AC /* ServerActivityScreen.swift */; };
|
||||||
|
130339B2D7AEAC791E50140F /* RepositoryDirectoryScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6BB62D12708255650508B2 /* RepositoryDirectoryScreen.swift */; };
|
||||||
|
1EDCCB5DE286C1DA407F00F1 /* MilestoneEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA582099DD57D35C748EFB02 /* MilestoneEditorViewController.swift */; };
|
||||||
|
25B30DCE869158C89021EFC6 /* DiffScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41E0F726C9668B50418D9ADF /* DiffScreen.swift */; };
|
||||||
|
267E72DD7E12E3082974337E /* IssueActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17264C5C909501D8ADEC00B7 /* IssueActions.swift */; };
|
||||||
|
33D3E65C9E50522B2039816A /* PullScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99B8279189276A084B69D7D0 /* PullScreens.swift */; };
|
||||||
|
381A27D70EA30C1A3BA1BBC1 /* CommentEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F9FFCC06E0313760524EBD2 /* CommentEditorViewController.swift */; };
|
||||||
|
39C36B0D5FB260C920D466DC /* GotchaWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5121A6F14A9C9F6EA144BB2F /* GotchaWidgets.swift */; };
|
||||||
|
4652515AE4CB10963D995143 /* CommitScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7588A44A93B8DB4B78C3B2BB /* CommitScreens.swift */; };
|
||||||
|
6DAA1D3230197F537D70735E /* NotificationsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE88A28F99C6D0224865E04D /* NotificationsScreen.swift */; };
|
||||||
|
74626C144E9214BF89F821D2 /* WidgetIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77AE1D594C38D2BDAA2E86AD /* WidgetIntents.swift */; };
|
||||||
|
77576F6C61D65F12BB64A2E9 /* ActionsScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABB8F58445A6BDB7FDE8B9CA /* ActionsScreens.swift */; };
|
||||||
|
7A511844F3CBA0603A894DDE /* NotificationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA5F9B46F07F3EB2333CD4DF /* NotificationCoordinator.swift */; };
|
||||||
|
7A9D1ADD6623C89A7D5E016F /* GotchaWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 52ABD1B07CEEF00263038653 /* GotchaWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
|
7BBE64F66374221F1743BC24 /* IssueScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACD6449327E1A51FCD8E3BD4 /* IssueScreens.swift */; };
|
||||||
|
7DD332583B169B3CA1CA46E0 /* Highlighter in Frameworks */ = {isa = PBXBuildFile; productRef = EC5F999F50905E3801E8A71A /* Highlighter */; };
|
||||||
|
8A42B4319AF343566D77D12A /* WorkItemDetailScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BB99EB32ECA1F8C55293EE3 /* WorkItemDetailScreens.swift */; };
|
||||||
|
8BDF1E1259A237221FDDB822 /* WidgetIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77AE1D594C38D2BDAA2E86AD /* WidgetIntents.swift */; };
|
||||||
|
950C584D58E80106DF350A22 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C0921C76676A14A024BA417 /* AppDelegate.swift */; };
|
||||||
|
96C4AFC206A1DBAE3D3E00CE /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */; };
|
||||||
|
9B2206DF1263080B9B25B82C /* ServerScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13A39F3C39C1F353D58C307 /* ServerScreens.swift */; };
|
||||||
|
A2AF2C1F3C8B0716E1EF36B2 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = F121BE52F7C9C8780341F988 /* PrivacyInfo.xcprivacy */; };
|
||||||
|
BDFE5D7A54541A109B35BF45 /* gotcha_core.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE12480725C13293FAFDDA52 /* gotcha_core.swift */; };
|
||||||
|
C33BA07C5F7DA72CBF72CEAE /* MilestoneScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64B529D84926EEEDC163A929 /* MilestoneScreens.swift */; };
|
||||||
|
C4FA15BB48499D3EED99522E /* NavigationSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A45865CA6E36C30A9EBC75C /* NavigationSettingsViewController.swift */; };
|
||||||
|
CD50D82AC4B92576A2ADE163 /* CommitDetailScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81A55AAC9DB99FEB79F56DAF /* CommitDetailScreen.swift */; };
|
||||||
|
D2B9B033E92BF8E7C0BDCDB0 /* IssueEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722405F999984C7CBE838711 /* IssueEditorViewController.swift */; };
|
||||||
|
D59D3ED36ABCC1D8690A9088 /* gotcha_core.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE12480725C13293FAFDDA52 /* gotcha_core.swift */; };
|
||||||
|
D78A121E9ADA0B72E2CF94DC /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 8AD463F3AC817A44306C2297 /* MarkdownUI */; };
|
||||||
|
DC155F5DEB86D95FAF709E16 /* RepositoryFileScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 181AE294D07DB4EAC9C9A0FF /* RepositoryFileScreens.swift */; };
|
||||||
|
DFB34862C386662D6EE7E6BE /* RepositoryListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000E810FCB39B9916CA5CAB8 /* RepositoryListScreen.swift */; };
|
||||||
E7AC0B140F5CFC5EF226D174 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DDAABE6B13ADC6D08D9438AF /* Assets.xcassets */; };
|
E7AC0B140F5CFC5EF226D174 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DDAABE6B13ADC6D08D9438AF /* Assets.xcassets */; };
|
||||||
|
EC7F8B4703DDE37A0B10CD9B /* AppContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = F61515849F6AACD721FE915C /* AppContext.swift */; };
|
||||||
|
F55A89489B2758D694F3B27D /* Support.swift in Sources */ = {isa = PBXBuildFile; fileRef = F75B3E4FFB9C9992517C4D69 /* Support.swift */; };
|
||||||
|
FF6BD7B3AAE19FDB107AF9F8 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = F121BE52F7C9C8780341F988 /* PrivacyInfo.xcprivacy */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
EFB9473AB945D16F3AA3B49B /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = C30A6F1B592A907D96D6AB40 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = AC111BD5DA5739822FCD7926;
|
||||||
|
remoteInfo = GotchaWidgets;
|
||||||
|
};
|
||||||
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
|
E567315EBFF59B424AE3BCC5 /* Embed Foundation Extensions */ = {
|
||||||
|
isa = PBXCopyFilesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
dstPath = "";
|
||||||
|
dstSubfolderSpec = 13;
|
||||||
|
files = (
|
||||||
|
7A9D1ADD6623C89A7D5E016F /* GotchaWidgets.appex in Embed Foundation Extensions */,
|
||||||
|
);
|
||||||
|
name = "Embed Foundation Extensions";
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
000E810FCB39B9916CA5CAB8 /* RepositoryListScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryListScreen.swift; sourceTree = "<group>"; };
|
||||||
|
0BB99EB32ECA1F8C55293EE3 /* WorkItemDetailScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkItemDetailScreens.swift; sourceTree = "<group>"; };
|
||||||
|
17264C5C909501D8ADEC00B7 /* IssueActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueActions.swift; sourceTree = "<group>"; };
|
||||||
|
181AE294D07DB4EAC9C9A0FF /* RepositoryFileScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryFileScreens.swift; sourceTree = "<group>"; };
|
||||||
|
41E0F726C9668B50418D9ADF /* DiffScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiffScreen.swift; sourceTree = "<group>"; };
|
||||||
|
5121A6F14A9C9F6EA144BB2F /* GotchaWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GotchaWidgets.swift; sourceTree = "<group>"; };
|
||||||
|
52ABD1B07CEEF00263038653 /* GotchaWidgets.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = GotchaWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
5FB3250A93766966A60A685E /* Gotcha.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Gotcha.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
5FB3250A93766966A60A685E /* Gotcha.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Gotcha.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
64B529D84926EEEDC163A929 /* MilestoneScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MilestoneScreens.swift; sourceTree = "<group>"; };
|
||||||
|
6A45865CA6E36C30A9EBC75C /* NavigationSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationSettingsViewController.swift; sourceTree = "<group>"; };
|
||||||
|
722405F999984C7CBE838711 /* IssueEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueEditorViewController.swift; sourceTree = "<group>"; };
|
||||||
|
7588A44A93B8DB4B78C3B2BB /* CommitScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommitScreens.swift; sourceTree = "<group>"; };
|
||||||
|
77AE1D594C38D2BDAA2E86AD /* WidgetIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetIntents.swift; sourceTree = "<group>"; };
|
||||||
|
81A55AAC9DB99FEB79F56DAF /* CommitDetailScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommitDetailScreen.swift; sourceTree = "<group>"; };
|
||||||
|
8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = "<group>"; };
|
||||||
|
8F9FFCC06E0313760524EBD2 /* CommentEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentEditorViewController.swift; sourceTree = "<group>"; };
|
||||||
|
99B8279189276A084B69D7D0 /* PullScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PullScreens.swift; sourceTree = "<group>"; };
|
||||||
|
9C0921C76676A14A024BA417 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
ABB8F58445A6BDB7FDE8B9CA /* ActionsScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionsScreens.swift; sourceTree = "<group>"; };
|
||||||
|
ACD6449327E1A51FCD8E3BD4 /* IssueScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueScreens.swift; sourceTree = "<group>"; };
|
||||||
|
BA5F9B46F07F3EB2333CD4DF /* NotificationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCoordinator.swift; sourceTree = "<group>"; };
|
||||||
|
C028DA45E7CB9E1CD56754AC /* ServerActivityScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerActivityScreen.swift; sourceTree = "<group>"; };
|
||||||
|
C13A39F3C39C1F353D58C307 /* ServerScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerScreens.swift; sourceTree = "<group>"; };
|
||||||
|
CA582099DD57D35C748EFB02 /* MilestoneEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MilestoneEditorViewController.swift; sourceTree = "<group>"; };
|
||||||
|
D35C7ECBC3EEC8B4659234AE /* HomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreen.swift; sourceTree = "<group>"; };
|
||||||
DDAABE6B13ADC6D08D9438AF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
DDAABE6B13ADC6D08D9438AF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
|
DE88A28F99C6D0224865E04D /* NotificationsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsScreen.swift; sourceTree = "<group>"; };
|
||||||
|
F121BE52F7C9C8780341F988 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||||
|
F61515849F6AACD721FE915C /* AppContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppContext.swift; sourceTree = "<group>"; };
|
||||||
|
F75B3E4FFB9C9992517C4D69 /* Support.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Support.swift; sourceTree = "<group>"; };
|
||||||
|
FD6BB62D12708255650508B2 /* RepositoryDirectoryScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryDirectoryScreen.swift; sourceTree = "<group>"; };
|
||||||
|
FE12480725C13293FAFDDA52 /* gotcha_core.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = gotcha_core.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
9BC71F568A96324A1942A0AC /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
7DD332583B169B3CA1CA46E0 /* Highlighter in Frameworks */,
|
||||||
|
D78A121E9ADA0B72E2CF94DC /* MarkdownUI in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
|
1E34F71186DBE1669D42B546 /* Widgets */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
5121A6F14A9C9F6EA144BB2F /* GotchaWidgets.swift */,
|
||||||
|
);
|
||||||
|
path = Widgets;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
710A50F51478401FC642E6E3 /* Sources */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
ABB8F58445A6BDB7FDE8B9CA /* ActionsScreens.swift */,
|
||||||
|
F61515849F6AACD721FE915C /* AppContext.swift */,
|
||||||
|
9C0921C76676A14A024BA417 /* AppDelegate.swift */,
|
||||||
|
8F9FFCC06E0313760524EBD2 /* CommentEditorViewController.swift */,
|
||||||
|
81A55AAC9DB99FEB79F56DAF /* CommitDetailScreen.swift */,
|
||||||
|
7588A44A93B8DB4B78C3B2BB /* CommitScreens.swift */,
|
||||||
|
41E0F726C9668B50418D9ADF /* DiffScreen.swift */,
|
||||||
|
D35C7ECBC3EEC8B4659234AE /* HomeScreen.swift */,
|
||||||
|
17264C5C909501D8ADEC00B7 /* IssueActions.swift */,
|
||||||
|
722405F999984C7CBE838711 /* IssueEditorViewController.swift */,
|
||||||
|
ACD6449327E1A51FCD8E3BD4 /* IssueScreens.swift */,
|
||||||
|
CA582099DD57D35C748EFB02 /* MilestoneEditorViewController.swift */,
|
||||||
|
64B529D84926EEEDC163A929 /* MilestoneScreens.swift */,
|
||||||
|
6A45865CA6E36C30A9EBC75C /* NavigationSettingsViewController.swift */,
|
||||||
|
BA5F9B46F07F3EB2333CD4DF /* NotificationCoordinator.swift */,
|
||||||
|
DE88A28F99C6D0224865E04D /* NotificationsScreen.swift */,
|
||||||
|
99B8279189276A084B69D7D0 /* PullScreens.swift */,
|
||||||
|
FD6BB62D12708255650508B2 /* RepositoryDirectoryScreen.swift */,
|
||||||
|
181AE294D07DB4EAC9C9A0FF /* RepositoryFileScreens.swift */,
|
||||||
|
000E810FCB39B9916CA5CAB8 /* RepositoryListScreen.swift */,
|
||||||
|
C028DA45E7CB9E1CD56754AC /* ServerActivityScreen.swift */,
|
||||||
|
C13A39F3C39C1F353D58C307 /* ServerScreens.swift */,
|
||||||
|
8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */,
|
||||||
|
F75B3E4FFB9C9992517C4D69 /* Support.swift */,
|
||||||
|
77AE1D594C38D2BDAA2E86AD /* WidgetIntents.swift */,
|
||||||
|
0BB99EB32ECA1F8C55293EE3 /* WorkItemDetailScreens.swift */,
|
||||||
|
);
|
||||||
|
path = Sources;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
94721140EFE7F8E7CD0F5C0B /* Generated */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
FE12480725C13293FAFDDA52 /* gotcha_core.swift */,
|
||||||
|
);
|
||||||
|
path = Generated;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
A8558BC8DD12191B80F52573 = {
|
A8558BC8DD12191B80F52573 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
DDAABE6B13ADC6D08D9438AF /* Assets.xcassets */,
|
DDAABE6B13ADC6D08D9438AF /* Assets.xcassets */,
|
||||||
|
F121BE52F7C9C8780341F988 /* PrivacyInfo.xcprivacy */,
|
||||||
|
94721140EFE7F8E7CD0F5C0B /* Generated */,
|
||||||
|
710A50F51478401FC642E6E3 /* Sources */,
|
||||||
|
1E34F71186DBE1669D42B546 /* Widgets */,
|
||||||
F059299C038F3CAFCE470831 /* Products */,
|
F059299C038F3CAFCE470831 /* Products */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -28,6 +182,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
5FB3250A93766966A60A685E /* Gotcha.app */,
|
5FB3250A93766966A60A685E /* Gotcha.app */,
|
||||||
|
52ABD1B07CEEF00263038653 /* GotchaWidgets.appex */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -39,20 +194,44 @@
|
|||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 7E8DF3DDA64C8C998FF3BB1D /* Build configuration list for PBXNativeTarget "Gotcha" */;
|
buildConfigurationList = 7E8DF3DDA64C8C998FF3BB1D /* Build configuration list for PBXNativeTarget "Gotcha" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
|
E01FE2CE8A64C51D25060BE9 /* Build Rust core */,
|
||||||
409DBF67E9C2743801B8F8A4 /* Sources */,
|
409DBF67E9C2743801B8F8A4 /* Sources */,
|
||||||
6334AD54CF86DAD2EC201EBC /* Build Rust app */,
|
|
||||||
9EDD4380917C29EE67EE344B /* Resources */,
|
9EDD4380917C29EE67EE344B /* Resources */,
|
||||||
|
9BC71F568A96324A1942A0AC /* Frameworks */,
|
||||||
|
E567315EBFF59B424AE3BCC5 /* Embed Foundation Extensions */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
D934DE8051607244EA19B70A /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
name = Gotcha;
|
||||||
|
packageProductDependencies = (
|
||||||
|
EC5F999F50905E3801E8A71A /* Highlighter */,
|
||||||
|
8AD463F3AC817A44306C2297 /* MarkdownUI */,
|
||||||
|
);
|
||||||
|
productName = Gotcha;
|
||||||
|
productReference = 5FB3250A93766966A60A685E /* Gotcha.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
AC111BD5DA5739822FCD7926 /* GotchaWidgets */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 09AF074399FFB04451DE5C3A /* Build configuration list for PBXNativeTarget "GotchaWidgets" */;
|
||||||
|
buildPhases = (
|
||||||
|
E451101CA0ABC24348B8641A /* Build Rust core */,
|
||||||
|
3850C0D57620418DE0FDE303 /* Sources */,
|
||||||
|
C2E7E52485E196A093E8124C /* Resources */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
);
|
);
|
||||||
name = Gotcha;
|
name = GotchaWidgets;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
);
|
);
|
||||||
productName = Gotcha;
|
productName = GotchaWidgets;
|
||||||
productReference = 5FB3250A93766966A60A685E /* Gotcha.app */;
|
productReference = 52ABD1B07CEEF00263038653 /* GotchaWidgets.appex */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.app-extension";
|
||||||
};
|
};
|
||||||
/* End PBXNativeTarget section */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
@@ -63,6 +242,14 @@
|
|||||||
BuildIndependentTargetsInParallel = YES;
|
BuildIndependentTargetsInParallel = YES;
|
||||||
LastUpgradeCheck = 1430;
|
LastUpgradeCheck = 1430;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
|
60C9DF1AB4A7858833302BA5 = {
|
||||||
|
DevelopmentTeam = MU22FMRGK8;
|
||||||
|
ProvisioningStyle = Automatic;
|
||||||
|
};
|
||||||
|
AC111BD5DA5739822FCD7926 = {
|
||||||
|
DevelopmentTeam = MU22FMRGK8;
|
||||||
|
ProvisioningStyle = Automatic;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = 807BC8918EC1B0F31384F2B7 /* Build configuration list for PBXProject "Gotcha" */;
|
buildConfigurationList = 807BC8918EC1B0F31384F2B7 /* Build configuration list for PBXProject "Gotcha" */;
|
||||||
@@ -74,12 +261,17 @@
|
|||||||
);
|
);
|
||||||
mainGroup = A8558BC8DD12191B80F52573;
|
mainGroup = A8558BC8DD12191B80F52573;
|
||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
|
packageReferences = (
|
||||||
|
85EFB86C5B2FDE8DEAD98A24 /* XCRemoteSwiftPackageReference "HighlighterSwift" */,
|
||||||
|
0B96240AE4FB1E82EE0D6B17 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */,
|
||||||
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = F059299C038F3CAFCE470831 /* Products */;
|
productRefGroup = F059299C038F3CAFCE470831 /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
60C9DF1AB4A7858833302BA5 /* Gotcha */,
|
60C9DF1AB4A7858833302BA5 /* Gotcha */,
|
||||||
|
AC111BD5DA5739822FCD7926 /* GotchaWidgets */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@@ -90,13 +282,22 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
E7AC0B140F5CFC5EF226D174 /* Assets.xcassets in Resources */,
|
E7AC0B140F5CFC5EF226D174 /* Assets.xcassets in Resources */,
|
||||||
|
A2AF2C1F3C8B0716E1EF36B2 /* PrivacyInfo.xcprivacy in Resources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
C2E7E52485E196A093E8124C /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
FF6BD7B3AAE19FDB107AF9F8 /* PrivacyInfo.xcprivacy in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXShellScriptBuildPhase section */
|
/* Begin PBXShellScriptBuildPhase section */
|
||||||
6334AD54CF86DAD2EC201EBC /* Build Rust app */ = {
|
E01FE2CE8A64C51D25060BE9 /* Build Rust core */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
alwaysOutOfDate = 1;
|
alwaysOutOfDate = 1;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -106,28 +307,93 @@
|
|||||||
);
|
);
|
||||||
inputPaths = (
|
inputPaths = (
|
||||||
);
|
);
|
||||||
name = "Build Rust app";
|
name = "Build Rust core";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
);
|
);
|
||||||
outputPaths = (
|
outputPaths = (
|
||||||
"$(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH)",
|
"$(DERIVED_FILE_DIR)/rust/libgotcha_core.a",
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "./build_for_ios_with_cargo.bash gotcha-app\n";
|
shellScript = "./build_rust_core.bash\n";
|
||||||
|
};
|
||||||
|
E451101CA0ABC24348B8641A /* Build Rust core */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
alwaysOutOfDate = 1;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
|
name = "Build Rust core";
|
||||||
|
outputFileListPaths = (
|
||||||
|
);
|
||||||
|
outputPaths = (
|
||||||
|
"$(DERIVED_FILE_DIR)/rust/libgotcha_core.a",
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "./build_rust_core.bash\n";
|
||||||
};
|
};
|
||||||
/* End PBXShellScriptBuildPhase section */
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
3850C0D57620418DE0FDE303 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
39C36B0D5FB260C920D466DC /* GotchaWidgets.swift in Sources */,
|
||||||
|
8BDF1E1259A237221FDDB822 /* WidgetIntents.swift in Sources */,
|
||||||
|
BDFE5D7A54541A109B35BF45 /* gotcha_core.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
409DBF67E9C2743801B8F8A4 /* Sources */ = {
|
409DBF67E9C2743801B8F8A4 /* Sources */ = {
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
77576F6C61D65F12BB64A2E9 /* ActionsScreens.swift in Sources */,
|
||||||
|
EC7F8B4703DDE37A0B10CD9B /* AppContext.swift in Sources */,
|
||||||
|
950C584D58E80106DF350A22 /* AppDelegate.swift in Sources */,
|
||||||
|
381A27D70EA30C1A3BA1BBC1 /* CommentEditorViewController.swift in Sources */,
|
||||||
|
CD50D82AC4B92576A2ADE163 /* CommitDetailScreen.swift in Sources */,
|
||||||
|
4652515AE4CB10963D995143 /* CommitScreens.swift in Sources */,
|
||||||
|
25B30DCE869158C89021EFC6 /* DiffScreen.swift in Sources */,
|
||||||
|
0020E57CC7B0C5CBBF04291C /* HomeScreen.swift in Sources */,
|
||||||
|
267E72DD7E12E3082974337E /* IssueActions.swift in Sources */,
|
||||||
|
D2B9B033E92BF8E7C0BDCDB0 /* IssueEditorViewController.swift in Sources */,
|
||||||
|
7BBE64F66374221F1743BC24 /* IssueScreens.swift in Sources */,
|
||||||
|
1EDCCB5DE286C1DA407F00F1 /* MilestoneEditorViewController.swift in Sources */,
|
||||||
|
C33BA07C5F7DA72CBF72CEAE /* MilestoneScreens.swift in Sources */,
|
||||||
|
C4FA15BB48499D3EED99522E /* NavigationSettingsViewController.swift in Sources */,
|
||||||
|
7A511844F3CBA0603A894DDE /* NotificationCoordinator.swift in Sources */,
|
||||||
|
6DAA1D3230197F537D70735E /* NotificationsScreen.swift in Sources */,
|
||||||
|
33D3E65C9E50522B2039816A /* PullScreens.swift in Sources */,
|
||||||
|
130339B2D7AEAC791E50140F /* RepositoryDirectoryScreen.swift in Sources */,
|
||||||
|
DC155F5DEB86D95FAF709E16 /* RepositoryFileScreens.swift in Sources */,
|
||||||
|
DFB34862C386662D6EE7E6BE /* RepositoryListScreen.swift in Sources */,
|
||||||
|
1279D8E7C8A76F0D7F45F97E /* ServerActivityScreen.swift in Sources */,
|
||||||
|
9B2206DF1263080B9B25B82C /* ServerScreens.swift in Sources */,
|
||||||
|
96C4AFC206A1DBAE3D3E00CE /* SettingsViewController.swift in Sources */,
|
||||||
|
F55A89489B2758D694F3B27D /* Support.swift in Sources */,
|
||||||
|
74626C144E9214BF89F821D2 /* WidgetIntents.swift in Sources */,
|
||||||
|
8A42B4319AF343566D77D12A /* WorkItemDetailScreens.swift in Sources */,
|
||||||
|
D59D3ED36ABCC1D8690A9088 /* gotcha_core.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
D934DE8051607244EA19B70A /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = AC111BD5DA5739822FCD7926 /* GotchaWidgets */;
|
||||||
|
targetProxy = EFB9473AB945D16F3AA3B49B /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
2F34E9C13F86A9A4A187A561 /* Debug */ = {
|
2F34E9C13F86A9A4A187A561 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
@@ -135,15 +401,21 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_ENTITLEMENTS = Gotcha.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Gotcha.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
DEVELOPMENT_TEAM = MU22FMRGK8;
|
||||||
INFOPLIST_FILE = Info.plist;
|
INFOPLIST_FILE = Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
|
LIBRARY_SEARCH_PATHS = "$(inherited) $(DERIVED_FILE_DIR)/rust";
|
||||||
|
OTHER_LDFLAGS = "$(inherited) -lgotcha_core";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.rfc1437.gotcha;
|
PRODUCT_BUNDLE_IDENTIFIER = de.rfc1437.gotcha;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
SWIFT_OBJC_BRIDGING_HEADER = "Gotcha-Bridging-Header.h";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
@@ -153,15 +425,21 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_ENTITLEMENTS = Gotcha.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Gotcha.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
DEVELOPMENT_TEAM = MU22FMRGK8;
|
||||||
INFOPLIST_FILE = Info.plist;
|
INFOPLIST_FILE = Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
|
LIBRARY_SEARCH_PATHS = "$(inherited) $(DERIVED_FILE_DIR)/rust";
|
||||||
|
OTHER_LDFLAGS = "$(inherited) -lgotcha_core";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.rfc1437.gotcha;
|
PRODUCT_BUNDLE_IDENTIFIER = de.rfc1437.gotcha;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
SWIFT_OBJC_BRIDGING_HEADER = "Gotcha-Bridging-Header.h";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
@@ -199,6 +477,7 @@
|
|||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_TESTABILITY = YES;
|
ENABLE_TESTABILITY = YES;
|
||||||
@@ -217,6 +496,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
MARKETING_VERSION = 1.1;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
@@ -228,6 +508,31 @@
|
|||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
|
9A0532DE2DD19A5F00EE4829 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = GotchaWidgets.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
DEVELOPMENT_TEAM = MU22FMRGK8;
|
||||||
|
INFOPLIST_FILE = "GotchaWidgets-Info.plist";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
LIBRARY_SEARCH_PATHS = "$(inherited) $(DERIVED_FILE_DIR)/rust";
|
||||||
|
OTHER_LDFLAGS = "$(inherited) -lgotcha_core";
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = de.rfc1437.gotcha.widgets;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER = "Gotcha-Bridging-Header.h";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
AA31619080EBF75C58A16A96 /* Release */ = {
|
AA31619080EBF75C58A16A96 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
@@ -262,6 +567,7 @@
|
|||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
@@ -274,6 +580,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
MARKETING_VERSION = 1.1;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -284,9 +591,43 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
|
B2DBC3D9C80A2BC962A8FA91 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = GotchaWidgets.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
DEVELOPMENT_TEAM = MU22FMRGK8;
|
||||||
|
INFOPLIST_FILE = "GotchaWidgets-Info.plist";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
LIBRARY_SEARCH_PATHS = "$(inherited) $(DERIVED_FILE_DIR)/rust";
|
||||||
|
OTHER_LDFLAGS = "$(inherited) -lgotcha_core";
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = de.rfc1437.gotcha.widgets;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER = "Gotcha-Bridging-Header.h";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
|
09AF074399FFB04451DE5C3A /* Build configuration list for PBXNativeTarget "GotchaWidgets" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
9A0532DE2DD19A5F00EE4829 /* Debug */,
|
||||||
|
B2DBC3D9C80A2BC962A8FA91 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Debug;
|
||||||
|
};
|
||||||
7E8DF3DDA64C8C998FF3BB1D /* Build configuration list for PBXNativeTarget "Gotcha" */ = {
|
7E8DF3DDA64C8C998FF3BB1D /* Build configuration list for PBXNativeTarget "Gotcha" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
@@ -306,6 +647,38 @@
|
|||||||
defaultConfigurationName = Debug;
|
defaultConfigurationName = Debug;
|
||||||
};
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
|
/* Begin XCRemoteSwiftPackageReference section */
|
||||||
|
0B96240AE4FB1E82EE0D6B17 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */ = {
|
||||||
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
repositoryURL = "https://github.com/gonzalezreal/swift-markdown-ui";
|
||||||
|
requirement = {
|
||||||
|
kind = upToNextMajorVersion;
|
||||||
|
minimumVersion = 2.1.0;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
85EFB86C5B2FDE8DEAD98A24 /* XCRemoteSwiftPackageReference "HighlighterSwift" */ = {
|
||||||
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
repositoryURL = "https://github.com/smittytone/HighlighterSwift";
|
||||||
|
requirement = {
|
||||||
|
kind = upToNextMajorVersion;
|
||||||
|
minimumVersion = 3.1.0;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|
||||||
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
8AD463F3AC817A44306C2297 /* MarkdownUI */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = 0B96240AE4FB1E82EE0D6B17 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */;
|
||||||
|
productName = MarkdownUI;
|
||||||
|
};
|
||||||
|
EC5F999F50905E3801E8A71A /* Highlighter */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = 85EFB86C5B2FDE8DEAD98A24 /* XCRemoteSwiftPackageReference "HighlighterSwift" */;
|
||||||
|
productName = Highlighter;
|
||||||
|
};
|
||||||
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = C30A6F1B592A907D96D6AB40 /* Project object */;
|
rootObject = C30A6F1B592A907D96D6AB40 /* Project object */;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"originHash" : "7a8d1ff34841e518cc21434c2148b0c9b3d7c0d8b436c01ab8711755963dcc94",
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "highlighterswift",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/smittytone/HighlighterSwift",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "fe7aae9c9b31d3b296fd3d2dd575e1a207bb29e0",
|
||||||
|
"version" : "3.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "networkimage",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/gonzalezreal/NetworkImage",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "2849f5323265386e200484b0d0f896e73c3411b9",
|
||||||
|
"version" : "6.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-cmark",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/swiftlang/swift-cmark",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "924936d0427cb25a61169739a7660230bffa6ea6",
|
||||||
|
"version" : "0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-markdown-ui",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/gonzalezreal/swift-markdown-ui",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "5f613358148239d0292c0cef674a3c2314737f9e",
|
||||||
|
"version" : "2.4.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 3
|
||||||
|
}
|
||||||
31
ios/GotchaWidgets-Info.plist
Normal file
31
ios/GotchaWidgets-Info.plist
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?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>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Gotcha Widgets</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>XPC!</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.widgetkit-extension</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
14
ios/GotchaWidgets.entitlements
Normal file
14
ios/GotchaWidgets.entitlements
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?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>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.de.rfc1437.gotcha</string>
|
||||||
|
</array>
|
||||||
|
<key>keychain-access-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>$(AppIdentifierPrefix)$(CFBundleIdentifier)</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>de.rfc1437.gotcha.notifications.refresh</string>
|
||||||
|
</array>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
@@ -17,9 +21,26 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0</string>
|
<string>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>de.rfc1437.gotcha</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>gotcha</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
<false/>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>fetch</string>
|
||||||
|
</array>
|
||||||
<key>UILaunchScreen</key>
|
<key>UILaunchScreen</key>
|
||||||
<dict/>
|
<dict/>
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
|||||||
14
ios/PrivacyInfo.xcprivacy
Normal file
14
ios/PrivacyInfo.xcprivacy
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?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>NSPrivacyAccessedAPITypes</key>
|
||||||
|
<array/>
|
||||||
|
<key>NSPrivacyCollectedDataTypes</key>
|
||||||
|
<array/>
|
||||||
|
<key>NSPrivacyTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyTrackingDomains</key>
|
||||||
|
<array/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
661
ios/Sources/ActionsScreens.swift
Normal file
661
ios/Sources/ActionsScreens.swift
Normal file
@@ -0,0 +1,661 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ActionsViewController: RefreshingTableViewController {
|
||||||
|
private enum Mode: Int { case workflows, runs }
|
||||||
|
|
||||||
|
private let context: AppContext
|
||||||
|
private let owner: String
|
||||||
|
private let repository: String
|
||||||
|
private let defaultBranch: String
|
||||||
|
private let modeControl = UISegmentedControl(items: ["Workflows", "Runs"])
|
||||||
|
private var mode = Mode.workflows
|
||||||
|
private var workflows: [ActionWorkflowRow] = []
|
||||||
|
private var runs: [ActionRunRow] = []
|
||||||
|
private var currentPage: UInt32 = 0
|
||||||
|
private var autoRefreshTask: Task<Void, Never>?
|
||||||
|
private var loading = false
|
||||||
|
private var requestGeneration = 0
|
||||||
|
|
||||||
|
init(context: AppContext, owner: String, repository: String, defaultBranch: String) {
|
||||||
|
self.context = context
|
||||||
|
self.owner = owner
|
||||||
|
self.repository = repository
|
||||||
|
self.defaultBranch = defaultBranch
|
||||||
|
super.init()
|
||||||
|
title = "Actions"
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
deinit { autoRefreshTask?.cancel() }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
modeControl.selectedSegmentIndex = mode.rawValue
|
||||||
|
modeControl.addTarget(self, action: #selector(modeChanged), for: .valueChanged)
|
||||||
|
navigationItem.titleView = modeControl
|
||||||
|
loadContent(refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
startAutoRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
|
super.viewWillDisappear(animated)
|
||||||
|
autoRefreshTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadContent(refreshing: Bool) {
|
||||||
|
loadPage(1, refreshing: refreshing)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadMoreContent() {
|
||||||
|
guard mode == .runs else { return }
|
||||||
|
loadPage(currentPage + 1, refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func modeChanged() {
|
||||||
|
guard let mode = Mode(rawValue: modeControl.selectedSegmentIndex) else { return }
|
||||||
|
self.mode = mode
|
||||||
|
loadingTask?.cancel()
|
||||||
|
loading = false
|
||||||
|
loadContent(refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadPage(_ page: UInt32, refreshing: Bool) {
|
||||||
|
guard !loading else {
|
||||||
|
refreshControl?.endRefreshing()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading = true
|
||||||
|
requestGeneration += 1
|
||||||
|
let generation = requestGeneration
|
||||||
|
let requestedMode = mode
|
||||||
|
if page == 1 {
|
||||||
|
resetPagination()
|
||||||
|
beginLoading(refreshing: refreshing)
|
||||||
|
}
|
||||||
|
loadingTask?.cancel()
|
||||||
|
loadingTask = Task {
|
||||||
|
defer {
|
||||||
|
if requestGeneration == generation {
|
||||||
|
loading = false
|
||||||
|
if page == 1 { endLoading() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
switch requestedMode {
|
||||||
|
case .workflows:
|
||||||
|
let result = try await context.core.actionWorkflows(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository
|
||||||
|
)
|
||||||
|
guard !Task.isCancelled, mode == requestedMode else { return }
|
||||||
|
workflows = result
|
||||||
|
currentPage = 1
|
||||||
|
finishPagination(hasMore: false)
|
||||||
|
case .runs:
|
||||||
|
let result = try await context.core.actionRuns(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
page: page
|
||||||
|
)
|
||||||
|
guard !Task.isCancelled, mode == requestedMode else { return }
|
||||||
|
runs = page == 1 ? result.rows : runs + result.rows
|
||||||
|
currentPage = page
|
||||||
|
finishPagination(hasMore: result.hasMore)
|
||||||
|
}
|
||||||
|
tableView.reloadData()
|
||||||
|
updateEmptyState()
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled {
|
||||||
|
show(error: error)
|
||||||
|
failPagination()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startAutoRefresh() {
|
||||||
|
autoRefreshTask?.cancel()
|
||||||
|
autoRefreshTask = Task { [weak self] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(for: .seconds(5))
|
||||||
|
guard let self, self.mode == .runs, !self.loading else { continue }
|
||||||
|
self.loadPage(1, refreshing: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateEmptyState() {
|
||||||
|
let empty = mode == .workflows ? workflows.isEmpty : runs.isEmpty
|
||||||
|
tableView.backgroundView = empty
|
||||||
|
? EmptyBackgroundView(
|
||||||
|
title: mode == .workflows ? "No workflows" : "No workflow runs",
|
||||||
|
detail: mode == .workflows
|
||||||
|
? "This repository has no Actions workflows."
|
||||||
|
: "Dispatch a workflow to create the first run."
|
||||||
|
)
|
||||||
|
: nil
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
mode == .workflows ? workflows.count : runs.count
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
cellForRowAt indexPath: IndexPath
|
||||||
|
) -> UITableViewCell {
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: "action")
|
||||||
|
?? UITableViewCell(style: .subtitle, reuseIdentifier: "action")
|
||||||
|
if mode == .workflows {
|
||||||
|
let row = workflows[indexPath.row]
|
||||||
|
cell.accessoryView = nil
|
||||||
|
configureTextCell(
|
||||||
|
cell,
|
||||||
|
title: row.name,
|
||||||
|
detail: "\(row.state) · \(row.path)",
|
||||||
|
image: context.symbol("play.square")
|
||||||
|
)
|
||||||
|
cell.accessoryType = .disclosureIndicator
|
||||||
|
} else {
|
||||||
|
cell.accessoryType = .none
|
||||||
|
let row = runs[indexPath.row]
|
||||||
|
configureActionCell(cell, row: row, context: context)
|
||||||
|
}
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
|
if mode == .workflows {
|
||||||
|
let workflow = workflows[indexPath.row]
|
||||||
|
navigationController?.pushViewController(
|
||||||
|
WorkflowDispatchViewController(
|
||||||
|
context: context,
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
workflow: workflow,
|
||||||
|
defaultBranch: defaultBranch
|
||||||
|
),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
navigationController?.pushViewController(
|
||||||
|
ActionRunViewController(
|
||||||
|
context: context,
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
run: runs[indexPath.row]
|
||||||
|
),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class WorkflowDispatchViewController: UITableViewController, UITextViewDelegate {
|
||||||
|
private let context: AppContext
|
||||||
|
private let owner: String
|
||||||
|
private let repository: String
|
||||||
|
private let workflow: ActionWorkflowRow
|
||||||
|
private let referenceField = UITextField()
|
||||||
|
private let inputsView = UITextView()
|
||||||
|
private lazy var runButton = UIBarButtonItem(
|
||||||
|
title: "Run Workflow",
|
||||||
|
primaryAction: UIAction { [weak self] _ in self?.dispatch() }
|
||||||
|
)
|
||||||
|
private var dispatchTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
init(
|
||||||
|
context: AppContext,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
workflow: ActionWorkflowRow,
|
||||||
|
defaultBranch: String
|
||||||
|
) {
|
||||||
|
self.context = context
|
||||||
|
self.owner = owner
|
||||||
|
self.repository = repository
|
||||||
|
self.workflow = workflow
|
||||||
|
referenceField.text = defaultBranch
|
||||||
|
super.init(style: .insetGrouped)
|
||||||
|
title = "Dispatch"
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
deinit { dispatchTask?.cancel() }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
referenceField.placeholder = "Branch or tag"
|
||||||
|
referenceField.autocapitalizationType = .none
|
||||||
|
referenceField.autocorrectionType = .no
|
||||||
|
referenceField.clearButtonMode = .whileEditing
|
||||||
|
referenceField.addTarget(self, action: #selector(referenceChanged), for: .editingChanged)
|
||||||
|
inputsView.font = .preferredFont(forTextStyle: .body)
|
||||||
|
inputsView.adjustsFontForContentSizeCategory = true
|
||||||
|
inputsView.autocapitalizationType = .none
|
||||||
|
inputsView.autocorrectionType = .no
|
||||||
|
inputsView.accessibilityLabel = "Workflow dispatch inputs"
|
||||||
|
navigationItem.rightBarButtonItem = runButton
|
||||||
|
referenceChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func numberOfSections(in tableView: UITableView) -> Int { 3 }
|
||||||
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 1 }
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||||
|
[workflow.name, "Git Reference", "Inputs"][section]
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
|
||||||
|
section == 2 ? "Enter one workflow_dispatch input per line as KEY=VALUE. Leave blank when the workflow has no inputs." : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
cellForRowAt indexPath: IndexPath
|
||||||
|
) -> UITableViewCell {
|
||||||
|
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
|
||||||
|
if indexPath.section == 0 {
|
||||||
|
var content = cell.defaultContentConfiguration()
|
||||||
|
content.text = workflow.path
|
||||||
|
content.secondaryText = workflow.state
|
||||||
|
cell.contentConfiguration = content
|
||||||
|
cell.selectionStyle = .none
|
||||||
|
} else {
|
||||||
|
let view = indexPath.section == 1 ? referenceField : inputsView
|
||||||
|
view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
cell.contentView.addSubview(view)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
view.leadingAnchor.constraint(equalTo: cell.contentView.leadingAnchor, constant: 16),
|
||||||
|
view.trailingAnchor.constraint(equalTo: cell.contentView.trailingAnchor, constant: -16),
|
||||||
|
view.topAnchor.constraint(equalTo: cell.contentView.topAnchor, constant: 8),
|
||||||
|
view.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor, constant: -8),
|
||||||
|
])
|
||||||
|
cell.selectionStyle = .none
|
||||||
|
}
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||||
|
indexPath.section == 2 ? 140 : UITableView.automaticDimension
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dispatch() {
|
||||||
|
navigationItem.rightBarButtonItem?.isEnabled = false
|
||||||
|
dispatchTask?.cancel()
|
||||||
|
dispatchTask = Task {
|
||||||
|
do {
|
||||||
|
try await context.core.dispatchActionWorkflow(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
workflow: workflow.id,
|
||||||
|
reference: referenceField.text ?? "",
|
||||||
|
inputs: [inputsView.text]
|
||||||
|
)
|
||||||
|
navigationController?.popViewController(animated: true)
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled { show(error: error) }
|
||||||
|
navigationItem.rightBarButtonItem?.isEnabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func referenceChanged() {
|
||||||
|
runButton.isEnabled = !(referenceField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ActionRunViewController: RefreshingTableViewController {
|
||||||
|
private let context: AppContext
|
||||||
|
private let owner: String
|
||||||
|
private let repository: String
|
||||||
|
private let runID: Int64
|
||||||
|
private var page: ActionRunPage?
|
||||||
|
private var autoRefreshTask: Task<Void, Never>?
|
||||||
|
private var loading = false
|
||||||
|
|
||||||
|
init(context: AppContext, owner: String, repository: String, run: ActionRunRow) {
|
||||||
|
self.context = context
|
||||||
|
self.owner = owner
|
||||||
|
self.repository = repository
|
||||||
|
runID = run.id
|
||||||
|
super.init()
|
||||||
|
title = "Run #\(run.number)"
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
deinit { autoRefreshTask?.cancel() }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
loadContent(refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
autoRefreshTask?.cancel()
|
||||||
|
autoRefreshTask = Task { [weak self] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(for: .seconds(5))
|
||||||
|
guard let self, !self.loading else { continue }
|
||||||
|
self.loadContent(refreshing: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
|
super.viewWillDisappear(animated)
|
||||||
|
autoRefreshTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadContent(refreshing: Bool) {
|
||||||
|
guard !loading else {
|
||||||
|
refreshControl?.endRefreshing()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading = true
|
||||||
|
beginLoading(refreshing: refreshing)
|
||||||
|
loadingTask?.cancel()
|
||||||
|
loadingTask = Task {
|
||||||
|
defer {
|
||||||
|
loading = false
|
||||||
|
endLoading()
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
page = try await context.core.actionRun(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
run: runID
|
||||||
|
)
|
||||||
|
tableView.reloadData()
|
||||||
|
tableView.backgroundView = page?.jobs.isEmpty == true
|
||||||
|
? EmptyBackgroundView(
|
||||||
|
title: "No jobs yet",
|
||||||
|
detail: "Pull to refresh while the run is queued."
|
||||||
|
)
|
||||||
|
: nil
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled { show(error: error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func numberOfSections(in tableView: UITableView) -> Int { page == nil ? 0 : 2 }
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
section == 0 ? 1 : page?.jobs.count ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||||
|
section == 0 ? "Run" : "Jobs"
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
cellForRowAt indexPath: IndexPath
|
||||||
|
) -> UITableViewCell {
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: "actionDetail")
|
||||||
|
?? UITableViewCell(style: .subtitle, reuseIdentifier: "actionDetail")
|
||||||
|
if indexPath.section == 0 {
|
||||||
|
let run = page!.run
|
||||||
|
configureTextCell(
|
||||||
|
cell,
|
||||||
|
title: run.title,
|
||||||
|
detail: "\(actionStateLabel(run.state)) · \(run.event) · \(run.branch)\n\(run.meta)",
|
||||||
|
image: context.symbol(actionStateSymbolName(run.state))
|
||||||
|
)
|
||||||
|
tintActionIcon(in: cell, state: run.state)
|
||||||
|
cell.selectionStyle = .none
|
||||||
|
cell.accessoryType = .none
|
||||||
|
} else {
|
||||||
|
let job = page!.jobs[indexPath.row]
|
||||||
|
configureTextCell(
|
||||||
|
cell,
|
||||||
|
title: job.name,
|
||||||
|
detail: job.meta,
|
||||||
|
image: context.symbol(actionStateSymbolName(job.state))
|
||||||
|
)
|
||||||
|
tintActionIcon(in: cell, state: job.state)
|
||||||
|
cell.selectionStyle = .default
|
||||||
|
cell.accessoryType = .disclosureIndicator
|
||||||
|
}
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
guard indexPath.section == 1 else { return }
|
||||||
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
|
let job = page!.jobs[indexPath.row]
|
||||||
|
navigationController?.pushViewController(
|
||||||
|
ActionJobViewController(
|
||||||
|
context: context,
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
runID: runID,
|
||||||
|
job: job
|
||||||
|
),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ActionJobViewController: RefreshingTableViewController {
|
||||||
|
private let context: AppContext
|
||||||
|
private let owner: String
|
||||||
|
private let repository: String
|
||||||
|
private let runID: Int64
|
||||||
|
private let job: ActionJobRow
|
||||||
|
private var page: ActionJobLogPage?
|
||||||
|
|
||||||
|
init(
|
||||||
|
context: AppContext,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
runID: Int64,
|
||||||
|
job: ActionJobRow
|
||||||
|
) {
|
||||||
|
self.context = context
|
||||||
|
self.owner = owner
|
||||||
|
self.repository = repository
|
||||||
|
self.runID = runID
|
||||||
|
self.job = job
|
||||||
|
super.init()
|
||||||
|
title = job.name
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
loadContent(refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadContent(refreshing: Bool) {
|
||||||
|
beginLoading(refreshing: refreshing)
|
||||||
|
loadingTask?.cancel()
|
||||||
|
loadingTask = Task {
|
||||||
|
defer { endLoading() }
|
||||||
|
do {
|
||||||
|
page = try await context.core.actionJobLog(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
run: runID,
|
||||||
|
job: job.id
|
||||||
|
)
|
||||||
|
tableView.reloadData()
|
||||||
|
tableView.backgroundView = page?.groups.isEmpty == true
|
||||||
|
? EmptyBackgroundView(
|
||||||
|
title: "No log groups",
|
||||||
|
detail: "This job has no log output yet. Pull to refresh."
|
||||||
|
)
|
||||||
|
: nil
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled { show(error: error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func numberOfSections(in tableView: UITableView) -> Int { page == nil ? 0 : 2 }
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
section == 0 ? 1 : page?.groups.count ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||||
|
section == 0 ? "Job" : "Tasks"
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
cellForRowAt indexPath: IndexPath
|
||||||
|
) -> UITableViewCell {
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: "jobDetail")
|
||||||
|
?? UITableViewCell(style: .subtitle, reuseIdentifier: "jobDetail")
|
||||||
|
if indexPath.section == 0 {
|
||||||
|
configureTextCell(
|
||||||
|
cell,
|
||||||
|
title: page!.job.name,
|
||||||
|
detail: page!.job.meta,
|
||||||
|
image: context.symbol(actionStateSymbolName(page!.job.state))
|
||||||
|
)
|
||||||
|
tintActionIcon(in: cell, state: page!.job.state)
|
||||||
|
cell.selectionStyle = .none
|
||||||
|
cell.accessoryType = .none
|
||||||
|
} else {
|
||||||
|
let group = page!.groups[indexPath.row]
|
||||||
|
configureTextCell(
|
||||||
|
cell,
|
||||||
|
title: group.name,
|
||||||
|
detail: group.duration.isEmpty
|
||||||
|
? actionStateLabel(group.state)
|
||||||
|
: "\(actionStateLabel(group.state)) · \(group.duration)",
|
||||||
|
image: context.symbol(actionStateSymbolName(group.state))
|
||||||
|
)
|
||||||
|
tintActionIcon(in: cell, state: group.state)
|
||||||
|
cell.selectionStyle = .default
|
||||||
|
cell.accessoryType = .disclosureIndicator
|
||||||
|
}
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
guard indexPath.section == 1 else { return }
|
||||||
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
|
let group = page!.groups[indexPath.row]
|
||||||
|
navigationController?.pushViewController(
|
||||||
|
ActionLogTextViewController(title: group.name, text: group.text),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ActionLogTextViewController: UIViewController {
|
||||||
|
private let logTitle: String
|
||||||
|
private let text: String
|
||||||
|
|
||||||
|
init(title: String, text: String) {
|
||||||
|
logTitle = title
|
||||||
|
self.text = text
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
self.title = title
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
view.backgroundColor = .systemBackground
|
||||||
|
let textView = UITextView()
|
||||||
|
textView.isEditable = false
|
||||||
|
textView.font = UIFontMetrics(forTextStyle: .body).scaledFont(
|
||||||
|
for: .monospacedSystemFont(ofSize: 15, weight: .regular)
|
||||||
|
)
|
||||||
|
textView.adjustsFontForContentSizeCategory = true
|
||||||
|
textView.text = text
|
||||||
|
textView.accessibilityLabel = "\(logTitle) log"
|
||||||
|
textView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(textView)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
textView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
textView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
textView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||||
|
textView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func configureActionCell(_ cell: UITableViewCell, row: ActionRunRow, context: AppContext) {
|
||||||
|
configureTextCell(
|
||||||
|
cell,
|
||||||
|
title: "#\(row.number) \(row.title)",
|
||||||
|
detail: "\(actionStateLabel(row.state)) · \(row.event) · \(row.branch)\n\(row.meta)",
|
||||||
|
image: context.symbol(actionStateSymbolName(row.state))
|
||||||
|
)
|
||||||
|
tintActionIcon(in: cell, state: row.state)
|
||||||
|
cell.accessoryView = nil
|
||||||
|
cell.accessoryType = .disclosureIndicator
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tintActionIcon(in cell: UITableViewCell, state: ActionState) {
|
||||||
|
guard var content = cell.contentConfiguration as? UIListContentConfiguration else { return }
|
||||||
|
content.imageProperties.tintColor = actionStateColor(state)
|
||||||
|
cell.contentConfiguration = content
|
||||||
|
}
|
||||||
|
|
||||||
|
private func actionStateColor(_ state: ActionState) -> UIColor {
|
||||||
|
switch state {
|
||||||
|
case .succeeded: return .systemGreen
|
||||||
|
case .failed: return .systemRed
|
||||||
|
case .cancelled: return .systemGray
|
||||||
|
case .skipped: return .tertiaryLabel
|
||||||
|
case .queued: return .systemOrange
|
||||||
|
case .waiting: return .systemOrange
|
||||||
|
case .inProgress: return .systemBlue
|
||||||
|
case .unknown: return .secondaryLabel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func actionStateLabel(_ state: ActionState) -> String {
|
||||||
|
switch state {
|
||||||
|
case .queued: return "Queued"
|
||||||
|
case .waiting: return "Waiting"
|
||||||
|
case .inProgress: return "In Progress"
|
||||||
|
case .succeeded: return "Succeeded"
|
||||||
|
case .failed: return "Failed"
|
||||||
|
case .cancelled: return "Cancelled"
|
||||||
|
case .skipped: return "Skipped"
|
||||||
|
case .unknown: return "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func actionStateSymbolName(_ state: ActionState) -> String {
|
||||||
|
switch state {
|
||||||
|
case .succeeded: return "checkmark.circle.fill"
|
||||||
|
case .failed: return "xmark.circle.fill"
|
||||||
|
case .cancelled: return "slash.circle.fill"
|
||||||
|
case .skipped: return "minus.circle.fill"
|
||||||
|
case .queued: return "clock.fill"
|
||||||
|
case .waiting: return "hourglass.circle.fill"
|
||||||
|
case .inProgress: return "circle.fill"
|
||||||
|
case .unknown: return "questionmark.circle.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
294
ios/Sources/AppContext.swift
Normal file
294
ios/Sources/AppContext.swift
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
import UIKit
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AppContext {
|
||||||
|
let core: GotchaCore
|
||||||
|
lazy var notifications = NotificationCoordinator(context: self)
|
||||||
|
private let window: UIWindow
|
||||||
|
private(set) var tabs = UITabBarController()
|
||||||
|
private(set) var navigationControllers: [UINavigationController] = []
|
||||||
|
|
||||||
|
init(window: UIWindow) {
|
||||||
|
self.window = window
|
||||||
|
let storageDirectory = FileManager.default.containerURL(
|
||||||
|
forSecurityApplicationGroupIdentifier: "group.de.rfc1437.gotcha"
|
||||||
|
)?.path
|
||||||
|
core = GotchaCore(storageDirectory: storageDirectory)
|
||||||
|
applyAppearance()
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeRootController() -> UIViewController {
|
||||||
|
rebuildTabs(preservingHomeStack: false)
|
||||||
|
return tabs
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyPrimaryDestinations() {
|
||||||
|
rebuildTabs(preservingHomeStack: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rebuildTabs(preservingHomeStack: Bool) {
|
||||||
|
let destinations = core.settings().primaryDestinations
|
||||||
|
let home = preservingHomeStack ? navigationControllers.first : nil
|
||||||
|
let roots = destinations.map(destinationController)
|
||||||
|
let items = [("Home", "house", "house.fill")] + destinations.map(destinationItem)
|
||||||
|
let destinationNavigations = zip(roots, items.dropFirst()).map { root, item in
|
||||||
|
let navigation = UINavigationController(rootViewController: root)
|
||||||
|
navigation.tabBarItem = UITabBarItem(
|
||||||
|
title: item.0,
|
||||||
|
image: UIImage(systemName: item.1),
|
||||||
|
selectedImage: UIImage(systemName: item.2)
|
||||||
|
)
|
||||||
|
return navigation
|
||||||
|
}
|
||||||
|
let homeNavigation = home ?? UINavigationController(
|
||||||
|
rootViewController: HomeViewController(context: self)
|
||||||
|
)
|
||||||
|
homeNavigation.tabBarItem = UITabBarItem(
|
||||||
|
title: items[0].0,
|
||||||
|
image: UIImage(systemName: items[0].1),
|
||||||
|
selectedImage: UIImage(systemName: items[0].2)
|
||||||
|
)
|
||||||
|
navigationControllers = [homeNavigation] + destinationNavigations
|
||||||
|
tabs.viewControllers = navigationControllers
|
||||||
|
(homeNavigation.viewControllers.first as? HomeViewController)?.refreshPrimaryDestinations()
|
||||||
|
}
|
||||||
|
|
||||||
|
func showStartupErrorIfNeeded() {
|
||||||
|
guard let message = core.startupError() else { return }
|
||||||
|
tabs.present(errorAlert(message), animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectServer(index: UInt32) throws {
|
||||||
|
try core.selectServer(index: index)
|
||||||
|
reloadAfterServerChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
func reloadAfterServerChange() {
|
||||||
|
rebuildTabs(preservingHomeStack: false)
|
||||||
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func route(widgetURL: URL) -> Bool {
|
||||||
|
guard widgetURL.scheme == "gotcha" else { return false }
|
||||||
|
switch widgetURL.host {
|
||||||
|
case "home":
|
||||||
|
tabs.selectedIndex = 0
|
||||||
|
navigationControllers[0].popToRootViewController(animated: false)
|
||||||
|
case "pulls":
|
||||||
|
if let index = primaryDestinations.firstIndex(of: .pullRequests).map({ $0 + 1 }) {
|
||||||
|
tabs.selectedIndex = index
|
||||||
|
navigationControllers[index].popToRootViewController(animated: false)
|
||||||
|
} else {
|
||||||
|
tabs.selectedIndex = 0
|
||||||
|
navigationControllers[0].pushViewController(
|
||||||
|
PullsViewController(context: self),
|
||||||
|
animated: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var primaryDestinations: [PrimaryDestination] {
|
||||||
|
core.settings().primaryDestinations
|
||||||
|
}
|
||||||
|
|
||||||
|
var secondaryDestinations: [PrimaryDestination] {
|
||||||
|
PrimaryDestination.allCases.filter { !primaryDestinations.contains($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func show(_ destination: PrimaryDestination, from navigation: UINavigationController?) {
|
||||||
|
if let index = primaryDestinations.firstIndex(of: destination).map({ $0 + 1 }) {
|
||||||
|
tabs.selectedIndex = index
|
||||||
|
navigationControllers[index].popToRootViewController(animated: false)
|
||||||
|
} else {
|
||||||
|
navigation?.pushViewController(destinationController(destination), animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func didAddServer(index: UInt32) throws {
|
||||||
|
try selectServer(index: index)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyAppearance() {
|
||||||
|
switch core.settings().appearance {
|
||||||
|
case 1: window.overrideUserInterfaceStyle = .light
|
||||||
|
case 2: window.overrideUserInterfaceStyle = .dark
|
||||||
|
default: window.overrideUserInterfaceStyle = .unspecified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func route(_ activity: ActivityRow) {
|
||||||
|
route(
|
||||||
|
serverId: nil,
|
||||||
|
target: activity.target,
|
||||||
|
owner: activity.owner,
|
||||||
|
repository: activity.repository,
|
||||||
|
number: activity.number,
|
||||||
|
sha: activity.sha
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func route(_ notification: NotificationRow) {
|
||||||
|
route(
|
||||||
|
serverId: notification.serverId,
|
||||||
|
target: notification.target,
|
||||||
|
owner: notification.owner,
|
||||||
|
repository: notification.repository,
|
||||||
|
number: notification.number,
|
||||||
|
sha: notification.sha
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func route(notificationUserInfo userInfo: [AnyHashable: Any]) {
|
||||||
|
let target: ActivityTargetKind
|
||||||
|
switch userInfo["target"] as? String {
|
||||||
|
case "repository": target = .repository
|
||||||
|
case "issue": target = .issue
|
||||||
|
case "pull": target = .pullRequest
|
||||||
|
case "commit": target = .commit
|
||||||
|
default: target = .none
|
||||||
|
}
|
||||||
|
route(
|
||||||
|
serverId: userInfo["serverId"] as? String,
|
||||||
|
target: target,
|
||||||
|
owner: userInfo["owner"] as? String ?? "",
|
||||||
|
repository: userInfo["repository"] as? String ?? "",
|
||||||
|
number: (userInfo["number"] as? NSNumber)?.int64Value ?? 0,
|
||||||
|
sha: userInfo["sha"] as? String ?? ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func route(
|
||||||
|
serverId: String?,
|
||||||
|
target: ActivityTargetKind,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
number: Int64,
|
||||||
|
sha: String
|
||||||
|
) {
|
||||||
|
if let serverId {
|
||||||
|
guard let index = core.servers().firstIndex(where: { $0.id == serverId }) else {
|
||||||
|
tabs.present(errorAlert("That notification's server is no longer configured."), animated: true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if core.activeServerIndex() != UInt32(index) {
|
||||||
|
do {
|
||||||
|
try selectServer(index: UInt32(index))
|
||||||
|
} catch {
|
||||||
|
tabs.present(errorAlert(errorMessage(error)), animated: true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let navigation = navigationControllers[0]
|
||||||
|
tabs.selectedIndex = 0
|
||||||
|
switch target {
|
||||||
|
case .repository:
|
||||||
|
navigation.pushViewController(
|
||||||
|
IssuesViewController(
|
||||||
|
context: self,
|
||||||
|
owner: owner,
|
||||||
|
repository: repository
|
||||||
|
),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
case .issue:
|
||||||
|
navigation.pushViewController(
|
||||||
|
IssueViewController(
|
||||||
|
context: self,
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
number: number
|
||||||
|
),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
case .pullRequest:
|
||||||
|
navigation.pushViewController(
|
||||||
|
PullViewController(
|
||||||
|
context: self,
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
number: number
|
||||||
|
),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
case .commit:
|
||||||
|
navigation.pushViewController(
|
||||||
|
FilesViewController(
|
||||||
|
context: self,
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
sha: sha
|
||||||
|
),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
case .none:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func symbol(_ name: String) -> UIImage? {
|
||||||
|
UIImage(systemName: name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func repositoryRoot(mode: RepositoryPane) -> UIViewController {
|
||||||
|
if core.activeServerIndex() == nil {
|
||||||
|
return ServersViewController(context: self)
|
||||||
|
}
|
||||||
|
return RepositoriesViewController(context: self, mode: mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func destinationController(_ destination: PrimaryDestination) -> UIViewController {
|
||||||
|
switch destination {
|
||||||
|
case .issues: return repositoryRoot(mode: .issues)
|
||||||
|
case .repositories: return repositoryRoot(mode: .commits)
|
||||||
|
case .pullRequests: return PullsViewController(context: self)
|
||||||
|
case .milestones: return repositoryRoot(mode: .milestones)
|
||||||
|
case .actions: return repositoryRoot(mode: .actions)
|
||||||
|
case .serverActivity: return ServerActivityViewController(context: self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func destinationItem(_ destination: PrimaryDestination) -> (String, String, String) {
|
||||||
|
switch destination {
|
||||||
|
case .issues: return ("Issues", "exclamationmark.circle", "exclamationmark.circle.fill")
|
||||||
|
case .repositories: return ("Repos", "books.vertical", "books.vertical.fill")
|
||||||
|
case .pullRequests: return ("PRs", "arrow.triangle.pull", "arrow.triangle.pull")
|
||||||
|
case .milestones: return ("Milestones", "flag", "flag.fill")
|
||||||
|
case .actions: return ("Actions", "play.square.stack", "play.square.stack.fill")
|
||||||
|
case .serverActivity: return ("Activity", "person.3", "person.3.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PrimaryDestination: CaseIterable {
|
||||||
|
public static var allCases: [PrimaryDestination] {
|
||||||
|
[.issues, .repositories, .pullRequests, .milestones, .actions]
|
||||||
|
}
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .issues: return "Issues"
|
||||||
|
case .repositories: return "Repositories"
|
||||||
|
case .pullRequests: return "Pull Requests"
|
||||||
|
case .milestones: return "Milestones"
|
||||||
|
case .actions: return "Actions"
|
||||||
|
case .serverActivity: return "Server Activity"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var symbolName: String {
|
||||||
|
switch self {
|
||||||
|
case .issues: return "exclamationmark.circle"
|
||||||
|
case .repositories: return "books.vertical"
|
||||||
|
case .pullRequests: return "arrow.triangle.pull"
|
||||||
|
case .milestones: return "flag"
|
||||||
|
case .actions: return "play.square.stack"
|
||||||
|
case .serverActivity: return "person.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
ios/Sources/AppDelegate.swift
Normal file
43
ios/Sources/AppDelegate.swift
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import UIKit
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
@main
|
||||||
|
final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
|
var window: UIWindow?
|
||||||
|
private var context: AppContext?
|
||||||
|
|
||||||
|
func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||||
|
) -> Bool {
|
||||||
|
let window = UIWindow(frame: UIScreen.main.bounds)
|
||||||
|
let context = AppContext(window: window)
|
||||||
|
self.context = context
|
||||||
|
window.rootViewController = context.makeRootController()
|
||||||
|
window.makeKeyAndVisible()
|
||||||
|
self.window = window
|
||||||
|
context.notifications.start()
|
||||||
|
context.showStartupErrorIfNeeded()
|
||||||
|
if let url = launchOptions?[.url] as? URL {
|
||||||
|
context.route(widgetURL: url)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
open url: URL,
|
||||||
|
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
|
||||||
|
) -> Bool {
|
||||||
|
context?.route(widgetURL: url) ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||||
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
context?.notifications.applicationDidBecomeActive()
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationDidEnterBackground(_ application: UIApplication) {
|
||||||
|
context?.notifications.applicationDidEnterBackground()
|
||||||
|
}
|
||||||
|
}
|
||||||
201
ios/Sources/CommentEditorViewController.swift
Normal file
201
ios/Sources/CommentEditorViewController.swift
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import Highlighter
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class CommentEditorViewController: UIViewController, UITextViewDelegate {
|
||||||
|
private let context: AppContext
|
||||||
|
private let owner: String
|
||||||
|
private let repository: String
|
||||||
|
private let number: Int64
|
||||||
|
private let comment: CommentRow?
|
||||||
|
private let saved: () -> Void
|
||||||
|
private let scrollView = UIScrollView()
|
||||||
|
private let modeControl = UISegmentedControl(items: ["Write", "Preview"])
|
||||||
|
private let bodyView = UITextView()
|
||||||
|
private let previewView = UIView()
|
||||||
|
private let spinner = UIActivityIndicatorView(style: .medium)
|
||||||
|
private var task: Task<Void, Never>?
|
||||||
|
private var previewController: UIViewController?
|
||||||
|
private var editorFont: UIFont {
|
||||||
|
UIFontMetrics(forTextStyle: .body).scaledFont(
|
||||||
|
for: .monospacedSystemFont(ofSize: 15, weight: .regular)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
context: AppContext,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
number: Int64,
|
||||||
|
comment: CommentRow? = nil,
|
||||||
|
saved: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
self.context = context
|
||||||
|
self.owner = owner
|
||||||
|
self.repository = repository
|
||||||
|
self.number = number
|
||||||
|
self.comment = comment
|
||||||
|
self.saved = saved
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
title = comment == nil ? "New Comment" : "Edit Comment"
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
deinit { task?.cancel() }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
view.backgroundColor = .systemGroupedBackground
|
||||||
|
navigationItem.leftBarButtonItem = UIBarButtonItem(
|
||||||
|
barButtonSystemItem: .cancel,
|
||||||
|
target: self,
|
||||||
|
action: #selector(cancel)
|
||||||
|
)
|
||||||
|
restoreSaveButton()
|
||||||
|
|
||||||
|
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
scrollView.keyboardDismissMode = .interactive
|
||||||
|
view.addSubview(scrollView)
|
||||||
|
let stack = UIStackView(arrangedSubviews: [modeControl, bodyView, previewView])
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = 12
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
scrollView.addSubview(stack)
|
||||||
|
|
||||||
|
modeControl.selectedSegmentIndex = 0
|
||||||
|
modeControl.accessibilityLabel = "Comment editor mode"
|
||||||
|
modeControl.addTarget(self, action: #selector(modeChanged), for: .valueChanged)
|
||||||
|
bodyView.delegate = self
|
||||||
|
bodyView.text = comment?.body ?? ""
|
||||||
|
bodyView.font = editorFont
|
||||||
|
bodyView.adjustsFontForContentSizeCategory = true
|
||||||
|
bodyView.backgroundColor = .secondarySystemGroupedBackground
|
||||||
|
bodyView.layer.cornerRadius = 10
|
||||||
|
bodyView.textContainerInset = UIEdgeInsets(top: 12, left: 8, bottom: 12, right: 8)
|
||||||
|
bodyView.autocapitalizationType = .sentences
|
||||||
|
bodyView.accessibilityLabel = "Comment in Markdown"
|
||||||
|
previewView.backgroundColor = .secondarySystemGroupedBackground
|
||||||
|
previewView.layer.cornerRadius = 10
|
||||||
|
previewView.clipsToBounds = true
|
||||||
|
previewView.accessibilityLabel = "Comment preview"
|
||||||
|
previewView.isHidden = true
|
||||||
|
highlightBody()
|
||||||
|
updateSaveButton()
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
scrollView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
||||||
|
scrollView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
||||||
|
scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||||
|
scrollView.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
|
||||||
|
stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 16),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -16),
|
||||||
|
stack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 16),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -16),
|
||||||
|
stack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -32),
|
||||||
|
bodyView.heightAnchor.constraint(greaterThanOrEqualToConstant: 280),
|
||||||
|
previewView.heightAnchor.constraint(greaterThanOrEqualToConstant: 280),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
if comment == nil { bodyView.becomeFirstResponder() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func textViewDidChange(_ textView: UITextView) {
|
||||||
|
highlightBody()
|
||||||
|
updateSaveButton()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func highlightBody() {
|
||||||
|
let source = bodyView.text ?? ""
|
||||||
|
let selection = bodyView.selectedRange
|
||||||
|
let highlighter = Highlighter()
|
||||||
|
highlighter?.setTheme(traitCollection.userInterfaceStyle == .dark ? "atom-one-dark" : "atom-one-light")
|
||||||
|
highlighter?.theme.setCodeFont(editorFont)
|
||||||
|
bodyView.attributedText = highlighter?.highlight(source, as: "markdown")
|
||||||
|
?? NSAttributedString(string: source, attributes: [
|
||||||
|
.font: editorFont,
|
||||||
|
.foregroundColor: UIColor.label,
|
||||||
|
])
|
||||||
|
bodyView.selectedRange = selection
|
||||||
|
bodyView.typingAttributes = [
|
||||||
|
.font: editorFont,
|
||||||
|
.foregroundColor: UIColor.label,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func modeChanged() {
|
||||||
|
let preview = modeControl.selectedSegmentIndex == 1
|
||||||
|
if preview {
|
||||||
|
previewController?.willMove(toParent: nil)
|
||||||
|
previewController?.view.removeFromSuperview()
|
||||||
|
previewController?.removeFromParent()
|
||||||
|
let controller = UIHostingController(
|
||||||
|
rootView: RepositoryMarkdownPreview(source: bodyView.text ?? "")
|
||||||
|
)
|
||||||
|
addChild(controller)
|
||||||
|
controller.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
previewView.addSubview(controller.view)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
controller.view.leadingAnchor.constraint(equalTo: previewView.leadingAnchor),
|
||||||
|
controller.view.trailingAnchor.constraint(equalTo: previewView.trailingAnchor),
|
||||||
|
controller.view.topAnchor.constraint(equalTo: previewView.topAnchor),
|
||||||
|
controller.view.bottomAnchor.constraint(equalTo: previewView.bottomAnchor),
|
||||||
|
])
|
||||||
|
controller.didMove(toParent: self)
|
||||||
|
previewController = controller
|
||||||
|
}
|
||||||
|
bodyView.isHidden = preview
|
||||||
|
previewView.isHidden = !preview
|
||||||
|
view.endEditing(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func save() {
|
||||||
|
view.endEditing(true)
|
||||||
|
navigationItem.leftBarButtonItem?.isEnabled = false
|
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(customView: spinner)
|
||||||
|
spinner.startAnimating()
|
||||||
|
task?.cancel()
|
||||||
|
task = Task {
|
||||||
|
do {
|
||||||
|
try await context.core.saveIssueComment(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
number: number,
|
||||||
|
commentId: comment?.id,
|
||||||
|
body: bodyView.text ?? ""
|
||||||
|
)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
saved()
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled {
|
||||||
|
show(error: error)
|
||||||
|
restoreSaveButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restoreSaveButton() {
|
||||||
|
spinner.stopAnimating()
|
||||||
|
navigationItem.leftBarButtonItem?.isEnabled = true
|
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
|
title: "Save",
|
||||||
|
style: .done,
|
||||||
|
target: self,
|
||||||
|
action: #selector(save)
|
||||||
|
)
|
||||||
|
updateSaveButton()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateSaveButton() {
|
||||||
|
navigationItem.rightBarButtonItem?.isEnabled = !(bodyView.text ?? "")
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func cancel() { dismiss(animated: true) }
|
||||||
|
}
|
||||||
178
ios/Sources/CommitDetailScreen.swift
Normal file
178
ios/Sources/CommitDetailScreen.swift
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
private final class CommitHeaderView: UIView {
|
||||||
|
private let stack = UIStackView()
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private var descriptionView: UIView?
|
||||||
|
private let metadataStack = UIStackView()
|
||||||
|
private let bottomSeparator = separator()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
backgroundColor = .systemBackground
|
||||||
|
directionalLayoutMargins = .init(top: 20, leading: 20, bottom: 20, trailing: 20)
|
||||||
|
|
||||||
|
titleLabel.font = .preferredFont(forTextStyle: .title2)
|
||||||
|
titleLabel.adjustsFontForContentSizeCategory = true
|
||||||
|
titleLabel.numberOfLines = 0
|
||||||
|
titleLabel.accessibilityTraits = .header
|
||||||
|
|
||||||
|
metadataStack.axis = .vertical
|
||||||
|
metadataStack.spacing = 10
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = 12
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
stack.addArrangedSubview(titleLabel)
|
||||||
|
stack.addArrangedSubview(metadataStack)
|
||||||
|
addSubview(stack)
|
||||||
|
bottomSeparator.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
addSubview(bottomSeparator)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
stack.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
|
||||||
|
stack.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor),
|
||||||
|
bottomSeparator.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
|
bottomSeparator.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
|
bottomSeparator.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
func configure(_ page: CommitDetailsPage) {
|
||||||
|
titleLabel.text = page.title
|
||||||
|
descriptionView?.removeFromSuperview()
|
||||||
|
if !page.description.isEmpty {
|
||||||
|
let view = markdownView(page.description)
|
||||||
|
stack.insertArrangedSubview(view, at: 1)
|
||||||
|
descriptionView = view
|
||||||
|
}
|
||||||
|
metadataStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||||
|
for metadata in page.metadata {
|
||||||
|
let label = UILabel()
|
||||||
|
label.text = metadata.label.uppercased()
|
||||||
|
label.font = .preferredFont(forTextStyle: .caption2)
|
||||||
|
label.adjustsFontForContentSizeCategory = true
|
||||||
|
label.textColor = .secondaryLabel
|
||||||
|
|
||||||
|
let value = UILabel()
|
||||||
|
value.text = metadata.value
|
||||||
|
value.font = metadata.monospaced
|
||||||
|
? UIFontMetrics(forTextStyle: .subheadline).scaledFont(
|
||||||
|
for: .monospacedSystemFont(ofSize: 15, weight: .regular)
|
||||||
|
)
|
||||||
|
: .preferredFont(forTextStyle: .subheadline)
|
||||||
|
value.adjustsFontForContentSizeCategory = true
|
||||||
|
value.numberOfLines = 0
|
||||||
|
value.lineBreakMode = metadata.monospaced ? .byCharWrapping : .byWordWrapping
|
||||||
|
|
||||||
|
let row = UIStackView(arrangedSubviews: [label, value])
|
||||||
|
row.axis = .vertical
|
||||||
|
row.spacing = 2
|
||||||
|
row.isAccessibilityElement = true
|
||||||
|
row.accessibilityLabel = "\(metadata.label): \(metadata.value)"
|
||||||
|
metadataStack.addArrangedSubview(row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@MainActor
|
||||||
|
final class FilesViewController: RefreshingTableViewController {
|
||||||
|
private let context: AppContext
|
||||||
|
private let owner: String
|
||||||
|
private let repository: String
|
||||||
|
private let sha: String
|
||||||
|
private let branch: String?
|
||||||
|
private let commitHeader = CommitHeaderView()
|
||||||
|
private var page: CommitDetailsPage?
|
||||||
|
|
||||||
|
init(context: AppContext, owner: String, repository: String, sha: String, branch: String? = nil) {
|
||||||
|
self.context = context
|
||||||
|
self.owner = owner
|
||||||
|
self.repository = repository
|
||||||
|
self.sha = sha
|
||||||
|
self.branch = branch
|
||||||
|
super.init()
|
||||||
|
title = "Changed Files"
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
loadContent(refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLayoutSubviews() {
|
||||||
|
super.viewDidLayoutSubviews()
|
||||||
|
guard let header = tableView.tableHeaderView else { return }
|
||||||
|
let size = header.systemLayoutSizeFitting(
|
||||||
|
CGSize(width: tableView.bounds.width, height: 0),
|
||||||
|
withHorizontalFittingPriority: .required,
|
||||||
|
verticalFittingPriority: .fittingSizeLevel
|
||||||
|
)
|
||||||
|
guard header.frame.height != size.height else { return }
|
||||||
|
header.frame.size.height = size.height
|
||||||
|
tableView.tableHeaderView = header
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadContent(refreshing: Bool) {
|
||||||
|
beginLoading(refreshing: refreshing)
|
||||||
|
loadingTask?.cancel()
|
||||||
|
loadingTask = Task {
|
||||||
|
do {
|
||||||
|
let page = try await context.core.commitDetails(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
sha: sha,
|
||||||
|
branch: branch
|
||||||
|
)
|
||||||
|
self.page = page
|
||||||
|
commitHeader.configure(page)
|
||||||
|
tableView.tableHeaderView = commitHeader
|
||||||
|
tableView.reloadData()
|
||||||
|
tableView.backgroundView = page.files.isEmpty
|
||||||
|
? EmptyBackgroundView(title: "No changed files", detail: "This commit does not contain file changes.")
|
||||||
|
: nil
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled { show(error: error) }
|
||||||
|
}
|
||||||
|
endLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
page?.files.count ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
cellForRowAt indexPath: IndexPath
|
||||||
|
) -> UITableViewCell {
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: "file")
|
||||||
|
?? UITableViewCell(style: .subtitle, reuseIdentifier: "file")
|
||||||
|
let row = page!.files[indexPath.row]
|
||||||
|
configureTextCell(cell, title: row.path, detail: row.status)
|
||||||
|
cell.accessoryType = .disclosureIndicator
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
|
guard let row = page?.files[indexPath.row] else { return }
|
||||||
|
navigationController?.pushViewController(
|
||||||
|
DiffViewController(
|
||||||
|
context: context,
|
||||||
|
source: .commit(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
sha: sha,
|
||||||
|
path: row.path
|
||||||
|
)
|
||||||
|
),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
312
ios/Sources/CommitScreens.swift
Normal file
312
ios/Sources/CommitScreens.swift
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class CommitsViewController: RefreshingTableViewController {
|
||||||
|
private enum Mode: Int { case history, files }
|
||||||
|
|
||||||
|
private let context: AppContext
|
||||||
|
private let owner: String
|
||||||
|
private let repository: String
|
||||||
|
private var page: CommitPage?
|
||||||
|
private var contents: [RepositoryContentRow] = []
|
||||||
|
private var branch: String?
|
||||||
|
private var mode = Mode.history
|
||||||
|
private var currentPage: UInt32 = 0
|
||||||
|
|
||||||
|
init(context: AppContext, owner: String, repository: String) {
|
||||||
|
self.context = context
|
||||||
|
self.owner = owner
|
||||||
|
self.repository = repository
|
||||||
|
super.init()
|
||||||
|
title = repository
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
tableView.register(CommitCell.self, forCellReuseIdentifier: "commit")
|
||||||
|
let modeControl = UISegmentedControl(items: ["History", "Files"])
|
||||||
|
modeControl.selectedSegmentIndex = mode.rawValue
|
||||||
|
modeControl.addTarget(self, action: #selector(modeChanged(_:)), for: .valueChanged)
|
||||||
|
modeControl.accessibilityLabel = "Repository view"
|
||||||
|
navigationItem.titleView = modeControl
|
||||||
|
updateBranchMenu()
|
||||||
|
loadContent(refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadContent(refreshing: Bool) {
|
||||||
|
loadPage(1, refreshing: refreshing)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadMoreContent() {
|
||||||
|
guard mode == .history else { return }
|
||||||
|
loadPage(currentPage + 1, refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadPage(_ requestedPage: UInt32, refreshing: Bool) {
|
||||||
|
if requestedPage == 1 {
|
||||||
|
resetPagination()
|
||||||
|
beginLoading(refreshing: refreshing)
|
||||||
|
}
|
||||||
|
loadingTask?.cancel()
|
||||||
|
loadingTask = Task {
|
||||||
|
do {
|
||||||
|
switch mode {
|
||||||
|
case .history:
|
||||||
|
page = try await context.core.commits(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
branch: branch,
|
||||||
|
path: "",
|
||||||
|
pages: requestedPage
|
||||||
|
)
|
||||||
|
currentPage = requestedPage
|
||||||
|
finishPagination(hasMore: page?.hasMore ?? false)
|
||||||
|
updateBranchMenu()
|
||||||
|
case .files:
|
||||||
|
contents = try await context.core.repositoryContents(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
path: ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
tableView.reloadData()
|
||||||
|
updateEmptyView()
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled {
|
||||||
|
show(error: error)
|
||||||
|
failPagination()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if requestedPage == 1 { endLoading() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
mode == .history ? page?.commits.count ?? 0 : contents.count
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
cellForRowAt indexPath: IndexPath
|
||||||
|
) -> UITableViewCell {
|
||||||
|
switch mode {
|
||||||
|
case .history:
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: "commit", for: indexPath) as! CommitCell
|
||||||
|
if let page { cell.configure(page.commits[indexPath.row], laneCount: page.laneCount) }
|
||||||
|
return cell
|
||||||
|
case .files:
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: "repository-content")
|
||||||
|
?? UITableViewCell(style: .subtitle, reuseIdentifier: "repository-content")
|
||||||
|
configureRepositoryContentCell(cell, row: contents[indexPath.row])
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||||
|
mode == .history ? 86 : UITableView.automaticDimension
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
|
switch mode {
|
||||||
|
case .history:
|
||||||
|
guard let row = page?.commits[indexPath.row] else { return }
|
||||||
|
navigationController?.pushViewController(
|
||||||
|
FilesViewController(
|
||||||
|
context: context,
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
sha: row.sha,
|
||||||
|
branch: row.branchLabel ?? branch
|
||||||
|
),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
case .files:
|
||||||
|
showRepositoryContent(
|
||||||
|
contents[indexPath.row],
|
||||||
|
context: context,
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
navigationController: navigationController
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func modeChanged(_ sender: UISegmentedControl) {
|
||||||
|
guard let mode = Mode(rawValue: sender.selectedSegmentIndex), mode != self.mode else { return }
|
||||||
|
self.mode = mode
|
||||||
|
navigationItem.rightBarButtonItem = nil
|
||||||
|
if mode == .history { updateBranchMenu() }
|
||||||
|
tableView.reloadData()
|
||||||
|
loadContent(refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateEmptyView() {
|
||||||
|
switch mode {
|
||||||
|
case .history:
|
||||||
|
tableView.backgroundView = page?.commits.isEmpty == true
|
||||||
|
? EmptyBackgroundView(title: "No commits", detail: "This repository has no commit history.")
|
||||||
|
: nil
|
||||||
|
case .files:
|
||||||
|
tableView.backgroundView = contents.isEmpty
|
||||||
|
? EmptyBackgroundView(title: "No files", detail: "This repository is empty.")
|
||||||
|
: nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateBranchMenu() {
|
||||||
|
let branches = page?.branches ?? []
|
||||||
|
let choices: [String?] = [nil] + branches.map(Optional.some)
|
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
|
title: branch ?? "All",
|
||||||
|
menu: UIMenu(children: choices.map { choice in
|
||||||
|
UIAction(
|
||||||
|
title: choice ?? "All",
|
||||||
|
state: choice == branch ? .on : .off
|
||||||
|
) { [weak self] _ in
|
||||||
|
self?.branch = choice
|
||||||
|
self?.updateBranchMenu()
|
||||||
|
self?.loadContent(refreshing: false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class CommitCell: UITableViewCell {
|
||||||
|
private static let laneOrigin: CGFloat = 12
|
||||||
|
private static let laneSpacing: CGFloat = 12
|
||||||
|
|
||||||
|
private var row: CommitRow?
|
||||||
|
private var laneCount: UInt32 = 0
|
||||||
|
|
||||||
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
|
accessoryType = .disclosureIndicator
|
||||||
|
backgroundColor = .systemBackground
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
func configure(_ row: CommitRow, laneCount: UInt32) {
|
||||||
|
self.row = row
|
||||||
|
self.laneCount = laneCount
|
||||||
|
var content = defaultContentConfiguration()
|
||||||
|
content.directionalLayoutMargins.leading = laneCount == 0
|
||||||
|
? 0
|
||||||
|
: CGFloat(laneCount) * Self.laneSpacing + 10
|
||||||
|
content.text = row.title
|
||||||
|
if let branch = row.branchLabel {
|
||||||
|
let text = NSMutableAttributedString(string: "\(branch)\n\(row.detail)")
|
||||||
|
text.addAttribute(
|
||||||
|
.foregroundColor,
|
||||||
|
value: UIColor.tintColor,
|
||||||
|
range: NSRange(location: 0, length: (branch as NSString).length)
|
||||||
|
)
|
||||||
|
content.secondaryAttributedText = text
|
||||||
|
} else {
|
||||||
|
content.secondaryText = row.detail
|
||||||
|
}
|
||||||
|
content.secondaryTextProperties.numberOfLines = 2
|
||||||
|
contentConfiguration = content
|
||||||
|
setNeedsDisplay()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func draw(_ rect: CGRect) {
|
||||||
|
super.draw(rect)
|
||||||
|
guard let row, laneCount > 0, let context = UIGraphicsGetCurrentContext() else { return }
|
||||||
|
let centerY = bounds.midY
|
||||||
|
let nodeX = row.nodeLane.map(laneX)
|
||||||
|
context.setLineWidth(3)
|
||||||
|
context.setLineCap(.round)
|
||||||
|
context.setLineJoin(.round)
|
||||||
|
|
||||||
|
for lane in 0..<laneCount {
|
||||||
|
if row.topLanes.contains(lane) {
|
||||||
|
let start = CGPoint(x: laneX(lane), y: 0)
|
||||||
|
if row.topConnections.contains(lane), let nodeX {
|
||||||
|
strokeCurve(
|
||||||
|
context,
|
||||||
|
from: start,
|
||||||
|
to: CGPoint(x: nodeX, y: centerY),
|
||||||
|
color: laneColor(lane)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
strokeLine(
|
||||||
|
context,
|
||||||
|
from: start,
|
||||||
|
to: CGPoint(x: start.x, y: centerY),
|
||||||
|
color: laneColor(lane)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if row.bottomLanes.contains(lane) {
|
||||||
|
let end = CGPoint(x: laneX(lane), y: bounds.height)
|
||||||
|
if !row.bottomConnections.contains(lane) || row.topLanes.contains(lane) {
|
||||||
|
strokeLine(
|
||||||
|
context,
|
||||||
|
from: CGPoint(x: end.x, y: centerY),
|
||||||
|
to: end,
|
||||||
|
color: laneColor(lane)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if row.bottomConnections.contains(lane), let nodeX {
|
||||||
|
strokeCurve(
|
||||||
|
context,
|
||||||
|
from: CGPoint(x: nodeX, y: centerY),
|
||||||
|
to: end,
|
||||||
|
color: laneColor(lane)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let lane = row.nodeLane, let nodeX {
|
||||||
|
context.setFillColor(laneColor(lane).cgColor)
|
||||||
|
context.fillEllipse(
|
||||||
|
in: CGRect(x: nodeX - 5, y: centerY - 5, width: 10, height: 10)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func laneX(_ lane: UInt32) -> CGFloat {
|
||||||
|
Self.laneOrigin + CGFloat(lane) * Self.laneSpacing
|
||||||
|
}
|
||||||
|
|
||||||
|
private func strokeLine(
|
||||||
|
_ context: CGContext,
|
||||||
|
from start: CGPoint,
|
||||||
|
to end: CGPoint,
|
||||||
|
color: UIColor
|
||||||
|
) {
|
||||||
|
context.setStrokeColor(color.cgColor)
|
||||||
|
context.move(to: start)
|
||||||
|
context.addLine(to: end)
|
||||||
|
context.strokePath()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func strokeCurve(
|
||||||
|
_ context: CGContext,
|
||||||
|
from start: CGPoint,
|
||||||
|
to end: CGPoint,
|
||||||
|
color: UIColor
|
||||||
|
) {
|
||||||
|
let middleY = (start.y + end.y) / 2
|
||||||
|
context.setStrokeColor(color.cgColor)
|
||||||
|
context.move(to: start)
|
||||||
|
context.addCurve(
|
||||||
|
to: end,
|
||||||
|
control1: CGPoint(x: start.x, y: middleY),
|
||||||
|
control2: CGPoint(x: end.x, y: middleY)
|
||||||
|
)
|
||||||
|
context.strokePath()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func laneColor(_ lane: UInt32) -> UIColor {
|
||||||
|
UIColor(hue: CGFloat((Double(lane) * 137.508).truncatingRemainder(dividingBy: 360)) / 360,
|
||||||
|
saturation: 0.78, brightness: 0.78, alpha: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
95
ios/Sources/DiffScreen.swift
Normal file
95
ios/Sources/DiffScreen.swift
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
enum DiffSource {
|
||||||
|
case commit(owner: String, repository: String, sha: String, path: String)
|
||||||
|
case pull(owner: String, repository: String, number: Int64, path: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class DiffViewController: UIViewController {
|
||||||
|
private let context: AppContext
|
||||||
|
private let source: DiffSource
|
||||||
|
private let codeView = CodeScrollView()
|
||||||
|
private let spinner = UIActivityIndicatorView(style: .medium)
|
||||||
|
private var loadingTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
init(context: AppContext, source: DiffSource) {
|
||||||
|
self.context = context
|
||||||
|
self.source = source
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
view.backgroundColor = .systemBackground
|
||||||
|
codeView.refreshControl = UIRefreshControl()
|
||||||
|
codeView.refreshControl?.addTarget(self, action: #selector(reload), for: .valueChanged)
|
||||||
|
view.addSubview(codeView)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
codeView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
||||||
|
codeView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
||||||
|
codeView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||||
|
codeView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
|
||||||
|
])
|
||||||
|
beginNavigationLoading(spinner)
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit { loadingTask?.cancel() }
|
||||||
|
|
||||||
|
@objc private func reload() {
|
||||||
|
loadingTask?.cancel()
|
||||||
|
loadingTask = Task {
|
||||||
|
do {
|
||||||
|
let page: DiffPage
|
||||||
|
switch source {
|
||||||
|
case let .commit(owner, repository, sha, path):
|
||||||
|
page = try await context.core.commitDiff(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
sha: sha,
|
||||||
|
path: path
|
||||||
|
)
|
||||||
|
case let .pull(owner, repository, number, path):
|
||||||
|
page = try await context.core.pullDiff(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
number: number,
|
||||||
|
path: path
|
||||||
|
)
|
||||||
|
}
|
||||||
|
title = page.title
|
||||||
|
codeView.display(diffText(page))
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled { show(error: error) }
|
||||||
|
}
|
||||||
|
endNavigationLoading(spinner)
|
||||||
|
codeView.refreshControl?.endRefreshing()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func diffText(_ page: DiffPage) -> NSAttributedString {
|
||||||
|
let output = NSMutableAttributedString()
|
||||||
|
let font = UIFont.monospacedSystemFont(ofSize: 12, weight: .regular)
|
||||||
|
for line in page.lines {
|
||||||
|
let text = String(format: "%4@ %4@ %@\n", line.oldNumber, line.newNumber, line.text)
|
||||||
|
let color: UIColor
|
||||||
|
switch line.kind {
|
||||||
|
case .addition: color = UIColor.systemGreen.withAlphaComponent(0.16)
|
||||||
|
case .removal: color = UIColor.systemRed.withAlphaComponent(0.16)
|
||||||
|
case .hunk: color = UIColor.systemBlue.withAlphaComponent(0.14)
|
||||||
|
case .header: color = UIColor.systemGray.withAlphaComponent(0.14)
|
||||||
|
case .context: color = .clear
|
||||||
|
}
|
||||||
|
output.append(NSAttributedString(string: text, attributes: [
|
||||||
|
.font: font,
|
||||||
|
.foregroundColor: UIColor.label,
|
||||||
|
.backgroundColor: color,
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
}
|
||||||
538
ios/Sources/HomeScreen.swift
Normal file
538
ios/Sources/HomeScreen.swift
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class HomeViewController: RefreshingTableViewController {
|
||||||
|
private enum Timeline: Int {
|
||||||
|
case user
|
||||||
|
case allUsers
|
||||||
|
case notifications
|
||||||
|
}
|
||||||
|
|
||||||
|
private let context: AppContext
|
||||||
|
private var page: HomePage?
|
||||||
|
private var timeline = Timeline.user
|
||||||
|
private var userNextPage: UInt32?
|
||||||
|
private var serverActivities: [ActivityRow] = []
|
||||||
|
private var serverNextPage: UInt32?
|
||||||
|
private let notificationStatusControl = UISegmentedControl(items: ["Open", "Closed"])
|
||||||
|
private lazy var notificationHeaderView: UIView = {
|
||||||
|
let header = UIView()
|
||||||
|
notificationStatusControl.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
header.addSubview(notificationStatusControl)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
notificationStatusControl.centerXAnchor.constraint(equalTo: header.centerXAnchor),
|
||||||
|
notificationStatusControl.centerYAnchor.constraint(equalTo: header.centerYAnchor),
|
||||||
|
])
|
||||||
|
return header
|
||||||
|
}()
|
||||||
|
private var notificationStatus = NotificationStatus.open
|
||||||
|
private var notificationRows: [NotificationRow] = []
|
||||||
|
private var notificationPage: UInt32 = 0
|
||||||
|
private var notificationsHaveMore = false
|
||||||
|
|
||||||
|
private var activities: [ActivityRow] {
|
||||||
|
timeline == .allUsers ? serverActivities : page?.activities ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
init(context: AppContext) {
|
||||||
|
self.context = context
|
||||||
|
super.init()
|
||||||
|
title = "Home"
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
tableView.register(NotificationCell.self, forCellReuseIdentifier: "notification")
|
||||||
|
notificationStatusControl.selectedSegmentIndex = 0
|
||||||
|
notificationStatusControl.accessibilityLabel = "Notification status"
|
||||||
|
notificationStatusControl.addTarget(
|
||||||
|
self,
|
||||||
|
action: #selector(notificationStatusChanged),
|
||||||
|
for: .valueChanged
|
||||||
|
)
|
||||||
|
let servers = UIBarButtonItem(
|
||||||
|
image: context.symbol("server.rack"),
|
||||||
|
primaryAction: UIAction { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
self.navigationController?.pushViewController(
|
||||||
|
ServersViewController(context: self.context),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
servers.accessibilityLabel = "Servers"
|
||||||
|
navigationItem.leftBarButtonItem = servers
|
||||||
|
let settings = UIBarButtonItem(
|
||||||
|
image: context.symbol("gearshape"),
|
||||||
|
primaryAction: UIAction { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
self.navigationController?.pushViewController(
|
||||||
|
SettingsViewController(context: self.context),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
settings.accessibilityLabel = "Settings"
|
||||||
|
navigationItem.rightBarButtonItem = settings
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
if page == nil {
|
||||||
|
loadContent(refreshing: false)
|
||||||
|
} else if timeline == .notifications {
|
||||||
|
loadNotifications(page: 1, refreshing: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadContent(refreshing: Bool) {
|
||||||
|
guard context.core.activeServerIndex() != nil else {
|
||||||
|
tableView.backgroundView = EmptyBackgroundView(
|
||||||
|
title: "No server selected",
|
||||||
|
detail: "Use the server button to select a server or add your first one."
|
||||||
|
)
|
||||||
|
refreshControl?.endRefreshing()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch timeline {
|
||||||
|
case .user:
|
||||||
|
loadUserPage(1, refreshing: refreshing)
|
||||||
|
case .allUsers:
|
||||||
|
loadServerActivityPage(1, refreshing: refreshing)
|
||||||
|
case .notifications:
|
||||||
|
loadNotifications(page: 1, refreshing: refreshing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadMoreContent() {
|
||||||
|
switch timeline {
|
||||||
|
case .user:
|
||||||
|
guard let userNextPage else { return }
|
||||||
|
loadUserPage(userNextPage, refreshing: false)
|
||||||
|
case .allUsers:
|
||||||
|
guard let serverNextPage else { return }
|
||||||
|
loadServerActivityPage(serverNextPage, refreshing: false)
|
||||||
|
case .notifications:
|
||||||
|
guard notificationsHaveMore else { return }
|
||||||
|
loadNotifications(page: notificationPage + 1, refreshing: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadUserPage(_ requestedPage: UInt32, refreshing: Bool) {
|
||||||
|
if requestedPage == 1 {
|
||||||
|
resetPagination()
|
||||||
|
beginLoading(refreshing: refreshing)
|
||||||
|
}
|
||||||
|
loadingTask?.cancel()
|
||||||
|
loadingTask = Task {
|
||||||
|
do {
|
||||||
|
let result = try await context.core.home(
|
||||||
|
page: requestedPage,
|
||||||
|
filter: .all
|
||||||
|
)
|
||||||
|
guard !Task.isCancelled, timeline == .user else { return }
|
||||||
|
if requestedPage == 1 {
|
||||||
|
page = result
|
||||||
|
} else {
|
||||||
|
page?.activities.append(contentsOf: result.activities)
|
||||||
|
page?.nextPage = result.nextPage
|
||||||
|
}
|
||||||
|
userNextPage = result.nextPage
|
||||||
|
finishPagination(hasMore: result.nextPage != nil)
|
||||||
|
setPanelTitle(self, "Home", server: page?.serverName)
|
||||||
|
if requestedPage == 1 { refreshPrimaryDestinations() }
|
||||||
|
updateRows()
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled, timeline == .user {
|
||||||
|
show(error: error)
|
||||||
|
failPagination()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if requestedPage == 1, timeline == .user { endLoading() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadServerActivityPage(_ requestedPage: UInt32, refreshing: Bool) {
|
||||||
|
if requestedPage == 1 {
|
||||||
|
resetPagination()
|
||||||
|
beginLoading(refreshing: refreshing)
|
||||||
|
}
|
||||||
|
loadingTask?.cancel()
|
||||||
|
loadingTask = Task {
|
||||||
|
do {
|
||||||
|
let result = try await context.core.serverActivity(page: requestedPage)
|
||||||
|
guard !Task.isCancelled, timeline == .allUsers else { return }
|
||||||
|
if requestedPage == 1 {
|
||||||
|
serverActivities = result.rows
|
||||||
|
} else {
|
||||||
|
serverActivities.append(contentsOf: result.rows)
|
||||||
|
}
|
||||||
|
serverNextPage = result.hasMore ? requestedPage + 1 : nil
|
||||||
|
finishPagination(hasMore: result.hasMore)
|
||||||
|
updateRows()
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled, timeline == .allUsers {
|
||||||
|
show(error: error)
|
||||||
|
failPagination()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if requestedPage == 1, timeline == .allUsers { endLoading() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadNotifications(page requestedPage: UInt32, refreshing: Bool) {
|
||||||
|
if requestedPage == 1 {
|
||||||
|
resetPagination()
|
||||||
|
beginLoading(refreshing: refreshing)
|
||||||
|
}
|
||||||
|
loadingTask?.cancel()
|
||||||
|
loadingTask = Task {
|
||||||
|
do {
|
||||||
|
let result = try await context.core.notifications(
|
||||||
|
status: notificationStatus,
|
||||||
|
page: requestedPage
|
||||||
|
)
|
||||||
|
guard !Task.isCancelled, timeline == .notifications else { return }
|
||||||
|
if requestedPage == 1 {
|
||||||
|
notificationRows = result.rows
|
||||||
|
} else {
|
||||||
|
notificationRows.append(contentsOf: result.rows)
|
||||||
|
}
|
||||||
|
notificationPage = requestedPage
|
||||||
|
notificationsHaveMore = result.hasMore
|
||||||
|
finishPagination(hasMore: result.hasMore)
|
||||||
|
updateRows()
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled, timeline == .notifications {
|
||||||
|
show(error: error)
|
||||||
|
failPagination()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if requestedPage == 1, timeline == .notifications { endLoading() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
timeline == .notifications ? notificationRows.count : activities.count
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
cellForRowAt indexPath: IndexPath
|
||||||
|
) -> UITableViewCell {
|
||||||
|
if timeline == .notifications {
|
||||||
|
let cell = tableView.dequeueReusableCell(
|
||||||
|
withIdentifier: "notification",
|
||||||
|
for: indexPath
|
||||||
|
) as! NotificationCell
|
||||||
|
cell.configure(notificationRows[indexPath.row])
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: "activity")
|
||||||
|
?? UITableViewCell(style: .subtitle, reuseIdentifier: "activity")
|
||||||
|
let row = activities[indexPath.row]
|
||||||
|
configureTextCell(
|
||||||
|
cell,
|
||||||
|
title: row.title,
|
||||||
|
detail: "\(row.detail)\n\(row.meta)",
|
||||||
|
image: context.symbol(activitySymbolName(for: row.icon))
|
||||||
|
)
|
||||||
|
cell.accessoryType = row.target == .none ? .none : .disclosureIndicator
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||||
|
timeline == .notifications ? UITableView.automaticDimension : 92
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
viewForHeaderInSection section: Int
|
||||||
|
) -> UIView? {
|
||||||
|
guard timeline == .notifications else { return nil }
|
||||||
|
return notificationHeaderView
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
heightForHeaderInSection section: Int
|
||||||
|
) -> CGFloat {
|
||||||
|
timeline == .notifications ? 44 : .leastNormalMagnitude
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
|
if timeline == .notifications {
|
||||||
|
openNotification(notificationRows[indexPath.row])
|
||||||
|
} else {
|
||||||
|
context.route(activities[indexPath.row])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateRows() {
|
||||||
|
tableView.reloadData()
|
||||||
|
let isEmpty = timeline == .notifications ? notificationRows.isEmpty : activities.isEmpty
|
||||||
|
guard isEmpty else {
|
||||||
|
tableView.backgroundView = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let message: (String, String) = switch timeline {
|
||||||
|
case .user:
|
||||||
|
("No activity", "This user has no recent activity.")
|
||||||
|
case .allUsers:
|
||||||
|
("No server activity", "No activity is visible to this server account.")
|
||||||
|
case .notifications:
|
||||||
|
notificationStatus == .open
|
||||||
|
? ("No open notifications", "This server has no open notifications.")
|
||||||
|
: ("No closed notifications", "This server has no closed notifications.")
|
||||||
|
}
|
||||||
|
tableView.backgroundView = EmptyBackgroundView(title: message.0, detail: message.1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func selectTimeline(_ rawValue: Int) {
|
||||||
|
guard let selected = Timeline(rawValue: rawValue), selected != timeline else { return }
|
||||||
|
timeline = selected
|
||||||
|
setPanelTitle(self, "Home", server: page?.serverName)
|
||||||
|
refreshPrimaryDestinations()
|
||||||
|
tableView.reloadData()
|
||||||
|
loadContent(refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func notificationStatusChanged() {
|
||||||
|
notificationStatus = notificationStatusControl.selectedSegmentIndex == 0 ? .open : .closed
|
||||||
|
loadNotifications(page: 1, refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openNotification(_ row: NotificationRow) {
|
||||||
|
guard row.target != .none else { return }
|
||||||
|
guard row.unread else {
|
||||||
|
context.route(row)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loadingTask?.cancel()
|
||||||
|
loadingTask = Task {
|
||||||
|
do {
|
||||||
|
try await context.core.markNotificationRead(serverId: row.serverId, id: row.id)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
context.route(row)
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled { show(error: error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshPrimaryDestinations() {
|
||||||
|
tableView.tableHeaderView = page.map { page in
|
||||||
|
HeatmapView(
|
||||||
|
page: page,
|
||||||
|
selectedTimeline: timeline.rawValue,
|
||||||
|
destinations: context.secondaryDestinations.filter { $0 != .serverActivity },
|
||||||
|
onTimeline: { [weak self] timeline in self?.selectTimeline(timeline) },
|
||||||
|
onDestination: { [weak self] destination in
|
||||||
|
guard let self else { return }
|
||||||
|
self.context.show(destination, from: self.navigationController)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func activitySymbolName(for icon: ActivityIcon) -> String {
|
||||||
|
switch icon {
|
||||||
|
case .pullRequest: return "arrow.triangle.pull"
|
||||||
|
case .issue: return "exclamationmark.circle"
|
||||||
|
case .branch: return "arrow.triangle.branch"
|
||||||
|
case .tag: return "tag"
|
||||||
|
case .push: return "arrow.up.circle"
|
||||||
|
case .release: return "shippingbox"
|
||||||
|
case .repository: return "books.vertical"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class HeatmapView: UIView {
|
||||||
|
private let cells: [HeatCell]
|
||||||
|
private let calendar = Calendar(identifier: .gregorian)
|
||||||
|
private let controlsScroller = UIScrollView()
|
||||||
|
|
||||||
|
init(
|
||||||
|
page: HomePage,
|
||||||
|
selectedTimeline: Int,
|
||||||
|
destinations: [PrimaryDestination],
|
||||||
|
onTimeline: @escaping (Int) -> Void,
|
||||||
|
onDestination: @escaping (PrimaryDestination) -> Void
|
||||||
|
) {
|
||||||
|
cells = page.heatCells
|
||||||
|
super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 180))
|
||||||
|
backgroundColor = .systemBackground
|
||||||
|
let title = UILabel(frame: CGRect(x: 16, y: 12, width: 300, height: 22))
|
||||||
|
title.text = "Activity · last 9 months"
|
||||||
|
title.font = .preferredFont(forTextStyle: .subheadline)
|
||||||
|
title.textColor = .secondaryLabel
|
||||||
|
addSubview(title)
|
||||||
|
let total = UILabel(frame: CGRect(x: 16, y: 108, width: 300, height: 18))
|
||||||
|
total.text = "\(page.contributionCount) contributions"
|
||||||
|
total.font = .preferredFont(forTextStyle: .caption1)
|
||||||
|
total.textColor = .tertiaryLabel
|
||||||
|
addSubview(total)
|
||||||
|
let timelinesView = UIStackView()
|
||||||
|
timelinesView.axis = .horizontal
|
||||||
|
let timelineItems = [
|
||||||
|
("person.crop.circle", "person.crop.circle.fill", "Your activity"),
|
||||||
|
("person.3", "person.3.fill", "All users activity"),
|
||||||
|
("bell", "bell.fill", "Notifications"),
|
||||||
|
]
|
||||||
|
for (index, item) in timelineItems.enumerated() {
|
||||||
|
let button = UIButton(type: .custom, primaryAction: UIAction { action in
|
||||||
|
guard
|
||||||
|
let button = action.sender as? UIButton,
|
||||||
|
let stack = button.superview as? UIStackView
|
||||||
|
else { return }
|
||||||
|
for case let item as UIButton in stack.arrangedSubviews {
|
||||||
|
item.isSelected = item === button
|
||||||
|
item.tintColor = item.isSelected ? .tintColor : .secondaryLabel
|
||||||
|
item.accessibilityTraits = item.isSelected ? [.button, .selected] : .button
|
||||||
|
}
|
||||||
|
onTimeline(button.tag)
|
||||||
|
})
|
||||||
|
button.tag = index
|
||||||
|
button.setImage(UIImage(systemName: item.0), for: .normal)
|
||||||
|
button.setImage(UIImage(systemName: item.1), for: .selected)
|
||||||
|
button.isSelected = index == selectedTimeline
|
||||||
|
button.tintColor = button.isSelected ? .tintColor : .secondaryLabel
|
||||||
|
button.accessibilityLabel = item.2
|
||||||
|
button.accessibilityTraits = button.isSelected ? [.button, .selected] : .button
|
||||||
|
button.widthAnchor.constraint(equalToConstant: 44).isActive = true
|
||||||
|
timelinesView.addArrangedSubview(button)
|
||||||
|
}
|
||||||
|
|
||||||
|
let destinationsView = UIStackView()
|
||||||
|
destinationsView.axis = .horizontal
|
||||||
|
for destination in destinations {
|
||||||
|
let button = UIButton(
|
||||||
|
type: .custom,
|
||||||
|
primaryAction: UIAction { _ in onDestination(destination) }
|
||||||
|
)
|
||||||
|
let symbol = destination == .milestones ? "flag.fill" : destination.symbolName
|
||||||
|
button.setImage(UIImage(systemName: symbol), for: .normal)
|
||||||
|
button.tintColor = .secondaryLabel
|
||||||
|
button.accessibilityLabel = destination.title
|
||||||
|
button.accessibilityHint = "Opens from Home"
|
||||||
|
button.widthAnchor.constraint(equalToConstant: 44).isActive = true
|
||||||
|
destinationsView.addArrangedSubview(button)
|
||||||
|
}
|
||||||
|
|
||||||
|
let controls = UIStackView(arrangedSubviews: [timelinesView])
|
||||||
|
controls.axis = .horizontal
|
||||||
|
controls.alignment = .center
|
||||||
|
controls.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
if !destinations.isEmpty {
|
||||||
|
controls.addArrangedSubview(destinationsView)
|
||||||
|
}
|
||||||
|
controlsScroller.showsHorizontalScrollIndicator = false
|
||||||
|
controlsScroller.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
controlsScroller.addSubview(controls)
|
||||||
|
addSubview(controlsScroller)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
controlsScroller.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
|
controlsScroller.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
|
controlsScroller.topAnchor.constraint(equalTo: topAnchor, constant: 132),
|
||||||
|
controlsScroller.heightAnchor.constraint(equalToConstant: 44),
|
||||||
|
controls.leadingAnchor.constraint(
|
||||||
|
equalTo: controlsScroller.contentLayoutGuide.leadingAnchor,
|
||||||
|
constant: 8
|
||||||
|
),
|
||||||
|
controls.trailingAnchor.constraint(
|
||||||
|
equalTo: controlsScroller.contentLayoutGuide.trailingAnchor,
|
||||||
|
constant: -8
|
||||||
|
),
|
||||||
|
controls.topAnchor.constraint(equalTo: controlsScroller.contentLayoutGuide.topAnchor),
|
||||||
|
controls.bottomAnchor.constraint(
|
||||||
|
equalTo: controlsScroller.contentLayoutGuide.bottomAnchor
|
||||||
|
),
|
||||||
|
controls.heightAnchor.constraint(equalTo: controlsScroller.frameLayoutGuide.heightAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
let inset = max(0, (controlsScroller.bounds.width - controlsScroller.contentSize.width) / 2)
|
||||||
|
controlsScroller.contentInset.left = inset
|
||||||
|
controlsScroller.contentInset.right = inset
|
||||||
|
}
|
||||||
|
|
||||||
|
override func draw(_ rect: CGRect) {
|
||||||
|
guard
|
||||||
|
let first = cells.first,
|
||||||
|
let last = cells.last
|
||||||
|
else { return }
|
||||||
|
let firstDate = Date(timeIntervalSince1970: TimeInterval(first.timestamp))
|
||||||
|
let lastDate = Date(timeIntervalSince1970: TimeInterval(last.timestamp))
|
||||||
|
guard let firstWeek = calendar.dateInterval(of: .weekOfYear, for: firstDate)?.start else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let monthFormatter = DateFormatter()
|
||||||
|
monthFormatter.dateFormat = "MMM"
|
||||||
|
let months = cells.reduce(into: [Date]()) { result, cell in
|
||||||
|
let date = Date(timeIntervalSince1970: TimeInterval(cell.timestamp))
|
||||||
|
let month = calendar.date(
|
||||||
|
from: calendar.dateComponents([.year, .month], from: date)
|
||||||
|
) ?? date
|
||||||
|
if result.last != month { result.append(month) }
|
||||||
|
}
|
||||||
|
let weekCount = CGFloat(
|
||||||
|
(calendar.dateComponents([.day], from: firstWeek, to: lastDate).day ?? 0) / 7 + 1
|
||||||
|
)
|
||||||
|
let monthGap: CGFloat = 4
|
||||||
|
let monthGaps = CGFloat(max(months.count - 1, 0)) * monthGap
|
||||||
|
let width = max(4, min(6, (bounds.width - 32 - monthGaps) / weekCount - 1))
|
||||||
|
let gap = width + 1
|
||||||
|
let graphWidth = (weekCount - 1) * gap + width + monthGaps
|
||||||
|
let graphOrigin = max(0, (bounds.width - graphWidth) / 2)
|
||||||
|
let labelAttributes: [NSAttributedString.Key: Any] = [
|
||||||
|
.font: UIFont.preferredFont(forTextStyle: .caption2),
|
||||||
|
.foregroundColor: UIColor.secondaryLabel,
|
||||||
|
]
|
||||||
|
for (index, month) in months.enumerated() {
|
||||||
|
let days = calendar.dateComponents([.day], from: firstWeek, to: month).day ?? 0
|
||||||
|
monthFormatter.string(from: month).draw(
|
||||||
|
at: CGPoint(
|
||||||
|
x: graphOrigin + CGFloat(days / 7) * gap + CGFloat(index) * monthGap,
|
||||||
|
y: 34
|
||||||
|
),
|
||||||
|
withAttributes: labelAttributes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for cell in cells {
|
||||||
|
let date = Date(timeIntervalSince1970: TimeInterval(cell.timestamp))
|
||||||
|
let days = calendar.dateComponents([.day], from: firstWeek, to: date).day ?? 0
|
||||||
|
let month = calendar.date(
|
||||||
|
from: calendar.dateComponents([.year, .month], from: date)
|
||||||
|
) ?? date
|
||||||
|
let monthIndex = months.firstIndex(of: month) ?? 0
|
||||||
|
let colors: [UIColor] = [
|
||||||
|
.systemGray5,
|
||||||
|
UIColor(red: 0.72, green: 0.85, blue: 0.96, alpha: 1),
|
||||||
|
UIColor(red: 0.45, green: 0.71, blue: 0.91, alpha: 1),
|
||||||
|
UIColor(red: 0.15, green: 0.55, blue: 0.83, alpha: 1),
|
||||||
|
UIColor(red: 0.04, green: 0.41, blue: 0.72, alpha: 1),
|
||||||
|
]
|
||||||
|
colors[Int(min(cell.level, 4))].setFill()
|
||||||
|
UIBezierPath(
|
||||||
|
roundedRect: CGRect(
|
||||||
|
x: graphOrigin + CGFloat(days / 7) * gap + CGFloat(monthIndex) * monthGap,
|
||||||
|
y: 48 + CGFloat(calendar.component(.weekday, from: date) - 1) * gap,
|
||||||
|
width: width,
|
||||||
|
height: width
|
||||||
|
),
|
||||||
|
cornerRadius: 1
|
||||||
|
).fill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
113
ios/Sources/IssueActions.swift
Normal file
113
ios/Sources/IssueActions.swift
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension UIViewController {
|
||||||
|
func promptForSearchText(title: String, current: String, apply: @escaping (String) -> Void) {
|
||||||
|
let alert = UIAlertController(title: title, message: nil, preferredStyle: .alert)
|
||||||
|
alert.addTextField { field in
|
||||||
|
field.text = current
|
||||||
|
field.placeholder = "Search text"
|
||||||
|
field.accessibilityLabel = "Search text"
|
||||||
|
field.clearButtonMode = .whileEditing
|
||||||
|
field.returnKeyType = .search
|
||||||
|
}
|
||||||
|
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||||
|
alert.addAction(UIAlertAction(title: "Apply", style: .default) { [weak alert] _ in
|
||||||
|
apply(alert?.textFields?.first?.text ?? "")
|
||||||
|
})
|
||||||
|
present(alert, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@MainActor
|
||||||
|
protocol IssueSwipeActionHost: AnyObject {
|
||||||
|
var context: AppContext { get }
|
||||||
|
var owner: String { get }
|
||||||
|
var repository: String { get }
|
||||||
|
var issueMutationTask: Task<Void, Never>? { get set }
|
||||||
|
func reloadIssuesAfterMutation()
|
||||||
|
}
|
||||||
|
|
||||||
|
extension IssueSwipeActionHost where Self: UIViewController {
|
||||||
|
func issueSwipeActions(for row: IssueRow) -> UISwipeActionsConfiguration {
|
||||||
|
let close = row.state != .closed
|
||||||
|
let stateAction = UIContextualAction(
|
||||||
|
style: .normal,
|
||||||
|
title: close ? "Close" : "Open"
|
||||||
|
) { [weak self] _, _, completion in
|
||||||
|
self?.setIssue(row, closed: close, completion: completion) ?? completion(false)
|
||||||
|
}
|
||||||
|
stateAction.image = context.symbol(close ? "checkmark.circle" : "arrow.uturn.left.circle")
|
||||||
|
stateAction.backgroundColor = close ? .systemPurple : .systemGreen
|
||||||
|
|
||||||
|
let deleteAction = UIContextualAction(style: .destructive, title: "Delete") {
|
||||||
|
[weak self] _, _, completion in
|
||||||
|
self?.confirmDeleteIssue(row, completion: completion) ?? completion(false)
|
||||||
|
}
|
||||||
|
deleteAction.image = context.symbol("trash")
|
||||||
|
|
||||||
|
let configuration = UISwipeActionsConfiguration(actions: [stateAction, deleteAction])
|
||||||
|
configuration.performsFirstActionWithFullSwipe = true
|
||||||
|
return configuration
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setIssue(
|
||||||
|
_ row: IssueRow,
|
||||||
|
closed: Bool,
|
||||||
|
completion: @escaping (Bool) -> Void
|
||||||
|
) {
|
||||||
|
issueMutationTask?.cancel()
|
||||||
|
issueMutationTask = Task {
|
||||||
|
do {
|
||||||
|
try await context.core.setIssueClosed(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
number: row.number,
|
||||||
|
closed: closed
|
||||||
|
)
|
||||||
|
guard !Task.isCancelled else {
|
||||||
|
completion(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
completion(true)
|
||||||
|
reloadIssuesAfterMutation()
|
||||||
|
} catch {
|
||||||
|
completion(false)
|
||||||
|
if !Task.isCancelled { show(error: error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func confirmDeleteIssue(_ row: IssueRow, completion: @escaping (Bool) -> Void) {
|
||||||
|
let alert = UIAlertController(
|
||||||
|
title: "Delete Issue #\(row.number)?",
|
||||||
|
message: "“\(row.title)” will be permanently deleted. This can’t be undone.",
|
||||||
|
preferredStyle: .alert
|
||||||
|
)
|
||||||
|
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in completion(false) })
|
||||||
|
alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in
|
||||||
|
guard let self else {
|
||||||
|
completion(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.issueMutationTask?.cancel()
|
||||||
|
self.issueMutationTask = Task {
|
||||||
|
do {
|
||||||
|
try await self.context.core.deleteIssue(
|
||||||
|
owner: self.owner,
|
||||||
|
repository: self.repository,
|
||||||
|
number: row.number
|
||||||
|
)
|
||||||
|
guard !Task.isCancelled else {
|
||||||
|
completion(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
completion(true)
|
||||||
|
self.reloadIssuesAfterMutation()
|
||||||
|
} catch {
|
||||||
|
completion(false)
|
||||||
|
if !Task.isCancelled { self.show(error: error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
present(alert, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
352
ios/Sources/IssueEditorViewController.swift
Normal file
352
ios/Sources/IssueEditorViewController.swift
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
import Highlighter
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class IssueEditorViewController: UIViewController, UITextFieldDelegate, UITextViewDelegate {
|
||||||
|
private let context: AppContext
|
||||||
|
private let owner: String
|
||||||
|
private let repository: String
|
||||||
|
private let number: Int64?
|
||||||
|
private let saved: (Int64) -> Void
|
||||||
|
private let scrollView = UIScrollView()
|
||||||
|
private let stack = UIStackView()
|
||||||
|
private let titleField = UITextField()
|
||||||
|
private let modeControl = UISegmentedControl(items: ["Write", "Preview"])
|
||||||
|
private let bodyView = UITextView()
|
||||||
|
private let previewView = UIView()
|
||||||
|
private let labelsButton = UIButton(type: .system)
|
||||||
|
private let milestoneButton = UIButton(type: .system)
|
||||||
|
private let closedSwitch = UISwitch()
|
||||||
|
private let dueSwitch = UISwitch()
|
||||||
|
private let duePicker = UIDatePicker()
|
||||||
|
private let spinner = UIActivityIndicatorView(style: .medium)
|
||||||
|
private var page: IssueEditorPage?
|
||||||
|
private var selectedLabels = Set<Int64>()
|
||||||
|
private var selectedMilestone: Int64?
|
||||||
|
private var task: Task<Void, Never>?
|
||||||
|
private var previewController: UIViewController?
|
||||||
|
private var editorFont: UIFont {
|
||||||
|
UIFontMetrics(forTextStyle: .body).scaledFont(
|
||||||
|
for: .monospacedSystemFont(ofSize: 15, weight: .regular)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
context: AppContext,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
number: Int64? = nil,
|
||||||
|
saved: @escaping (Int64) -> Void
|
||||||
|
) {
|
||||||
|
self.context = context
|
||||||
|
self.owner = owner
|
||||||
|
self.repository = repository
|
||||||
|
self.number = number
|
||||||
|
self.saved = saved
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
title = number == nil ? "New Issue" : "Edit Issue"
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
deinit { task?.cancel() }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
view.backgroundColor = .systemGroupedBackground
|
||||||
|
navigationItem.leftBarButtonItem = UIBarButtonItem(
|
||||||
|
barButtonSystemItem: .cancel,
|
||||||
|
target: self,
|
||||||
|
action: #selector(cancel)
|
||||||
|
)
|
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
|
title: "Save",
|
||||||
|
style: .done,
|
||||||
|
target: self,
|
||||||
|
action: #selector(save)
|
||||||
|
)
|
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(customView: spinner)
|
||||||
|
spinner.startAnimating()
|
||||||
|
configureForm()
|
||||||
|
task = Task { await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureForm() {
|
||||||
|
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
scrollView.keyboardDismissMode = .interactive
|
||||||
|
view.addSubview(scrollView)
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = 12
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
stack.isHidden = true
|
||||||
|
scrollView.addSubview(stack)
|
||||||
|
|
||||||
|
titleField.placeholder = "Title"
|
||||||
|
titleField.delegate = self
|
||||||
|
titleField.font = .preferredFont(forTextStyle: .headline)
|
||||||
|
titleField.adjustsFontForContentSizeCategory = true
|
||||||
|
titleField.borderStyle = .roundedRect
|
||||||
|
titleField.autocapitalizationType = .sentences
|
||||||
|
titleField.returnKeyType = .next
|
||||||
|
titleField.accessibilityLabel = "Issue title"
|
||||||
|
titleField.addTarget(self, action: #selector(titleChanged), for: .editingChanged)
|
||||||
|
|
||||||
|
[labelsButton, milestoneButton].forEach { button in
|
||||||
|
var configuration = UIButton.Configuration.tinted()
|
||||||
|
configuration.imagePlacement = .leading
|
||||||
|
configuration.imagePadding = 8
|
||||||
|
button.configuration = configuration
|
||||||
|
button.showsMenuAsPrimaryAction = true
|
||||||
|
button.contentHorizontalAlignment = .leading
|
||||||
|
}
|
||||||
|
labelsButton.configuration?.image = context.symbol("tag")
|
||||||
|
milestoneButton.configuration?.image = context.symbol("flag")
|
||||||
|
|
||||||
|
let closedLabel = UILabel()
|
||||||
|
closedLabel.text = "Closed"
|
||||||
|
closedLabel.font = .preferredFont(forTextStyle: .body)
|
||||||
|
closedLabel.adjustsFontForContentSizeCategory = true
|
||||||
|
let closedRow = UIStackView(arrangedSubviews: [closedLabel, closedSwitch])
|
||||||
|
closedRow.alignment = .center
|
||||||
|
closedSwitch.accessibilityLabel = "Closed issue"
|
||||||
|
|
||||||
|
let dueLabel = UILabel()
|
||||||
|
dueLabel.text = "Due Date"
|
||||||
|
dueLabel.font = .preferredFont(forTextStyle: .body)
|
||||||
|
dueLabel.adjustsFontForContentSizeCategory = true
|
||||||
|
let dueRow = UIStackView(arrangedSubviews: [dueLabel, dueSwitch])
|
||||||
|
dueRow.alignment = .center
|
||||||
|
dueSwitch.accessibilityLabel = "Set due date"
|
||||||
|
dueSwitch.addTarget(self, action: #selector(dueChanged), for: .valueChanged)
|
||||||
|
duePicker.datePickerMode = .date
|
||||||
|
duePicker.preferredDatePickerStyle = .inline
|
||||||
|
duePicker.isHidden = true
|
||||||
|
|
||||||
|
modeControl.selectedSegmentIndex = 0
|
||||||
|
modeControl.addTarget(self, action: #selector(modeChanged), for: .valueChanged)
|
||||||
|
bodyView.delegate = self
|
||||||
|
bodyView.font = editorFont
|
||||||
|
bodyView.adjustsFontForContentSizeCategory = true
|
||||||
|
bodyView.backgroundColor = .secondarySystemGroupedBackground
|
||||||
|
bodyView.layer.cornerRadius = 10
|
||||||
|
bodyView.textContainerInset = UIEdgeInsets(top: 12, left: 8, bottom: 12, right: 8)
|
||||||
|
bodyView.autocapitalizationType = .sentences
|
||||||
|
bodyView.accessibilityLabel = "Issue description in Markdown"
|
||||||
|
previewView.backgroundColor = .secondarySystemGroupedBackground
|
||||||
|
previewView.layer.cornerRadius = 10
|
||||||
|
previewView.accessibilityLabel = "Issue description preview"
|
||||||
|
previewView.isHidden = true
|
||||||
|
|
||||||
|
stack.addArrangedSubview(titleField)
|
||||||
|
stack.addArrangedSubview(labelsButton)
|
||||||
|
stack.addArrangedSubview(milestoneButton)
|
||||||
|
stack.addArrangedSubview(closedRow)
|
||||||
|
stack.addArrangedSubview(dueRow)
|
||||||
|
stack.addArrangedSubview(duePicker)
|
||||||
|
stack.addArrangedSubview(modeControl)
|
||||||
|
stack.addArrangedSubview(bodyView)
|
||||||
|
stack.addArrangedSubview(previewView)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
scrollView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
||||||
|
scrollView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
||||||
|
scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||||
|
scrollView.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
|
||||||
|
stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 16),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -16),
|
||||||
|
stack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 16),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -16),
|
||||||
|
stack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -32),
|
||||||
|
bodyView.heightAnchor.constraint(greaterThanOrEqualToConstant: 280),
|
||||||
|
previewView.heightAnchor.constraint(greaterThanOrEqualToConstant: 280),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
do {
|
||||||
|
let page = try await context.core.issueEditor(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
number: number
|
||||||
|
)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
self.page = page
|
||||||
|
titleField.text = page.title
|
||||||
|
bodyView.text = page.body
|
||||||
|
closedSwitch.isOn = page.closed
|
||||||
|
selectedLabels = Set(page.labels.filter(\.selected).map(\.id))
|
||||||
|
selectedMilestone = page.milestones.first(where: \.selected)?.id
|
||||||
|
if let timestamp = page.dueDate {
|
||||||
|
dueSwitch.isOn = true
|
||||||
|
duePicker.date = localDate(forUTCTimestamp: timestamp)
|
||||||
|
duePicker.isHidden = false
|
||||||
|
}
|
||||||
|
updateLabelsMenu()
|
||||||
|
updateMilestoneMenu()
|
||||||
|
highlightBody()
|
||||||
|
stack.isHidden = false
|
||||||
|
navigationItem.rightBarButtonItem?.isEnabled = !page.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled { show(error: error) }
|
||||||
|
}
|
||||||
|
restoreSaveButton()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateLabelsMenu() {
|
||||||
|
guard let page else { return }
|
||||||
|
labelsButton.configuration?.title = selectedLabels.isEmpty
|
||||||
|
? "Labels"
|
||||||
|
: "Labels (\(selectedLabels.count))"
|
||||||
|
labelsButton.accessibilityValue = selectedLabels.isEmpty
|
||||||
|
? "None"
|
||||||
|
: page.labels.filter { selectedLabels.contains($0.id) }.map(\.name).joined(separator: ", ")
|
||||||
|
let actions = page.labels.map { label in
|
||||||
|
UIAction(
|
||||||
|
title: label.name,
|
||||||
|
attributes: .keepsMenuPresented,
|
||||||
|
state: selectedLabels.contains(label.id) ? .on : .off
|
||||||
|
) { [weak self] action in
|
||||||
|
guard let self else { return }
|
||||||
|
if self.selectedLabels.remove(label.id) == nil { self.selectedLabels.insert(label.id) }
|
||||||
|
action.state = self.selectedLabels.contains(label.id) ? .on : .off
|
||||||
|
self.updateLabelsMenu()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
labelsButton.menu = UIMenu(children: actions.isEmpty
|
||||||
|
? [UIAction(title: "No labels", attributes: .disabled) { _ in }]
|
||||||
|
: actions)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateMilestoneMenu() {
|
||||||
|
guard let page else { return }
|
||||||
|
milestoneButton.configuration?.title = page.milestones
|
||||||
|
.first(where: { $0.id == selectedMilestone })?.title ?? "Milestone"
|
||||||
|
milestoneButton.accessibilityValue = page.milestones
|
||||||
|
.first(where: { $0.id == selectedMilestone })?.title ?? "None"
|
||||||
|
let none = UIAction(title: "No Milestone", state: selectedMilestone == nil ? .on : .off) {
|
||||||
|
[weak self] _ in
|
||||||
|
self?.selectedMilestone = nil
|
||||||
|
self?.updateMilestoneMenu()
|
||||||
|
}
|
||||||
|
milestoneButton.menu = UIMenu(options: .singleSelection, children: [none] + page.milestones.map { milestone in
|
||||||
|
UIAction(
|
||||||
|
title: milestone.title,
|
||||||
|
state: milestone.id == selectedMilestone ? .on : .off
|
||||||
|
) { [weak self] _ in
|
||||||
|
self?.selectedMilestone = milestone.id
|
||||||
|
self?.updateMilestoneMenu()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func textViewDidChange(_ textView: UITextView) {
|
||||||
|
highlightBody()
|
||||||
|
}
|
||||||
|
|
||||||
|
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||||
|
bodyView.becomeFirstResponder()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func highlightBody() {
|
||||||
|
let source = bodyView.text ?? ""
|
||||||
|
let selection = bodyView.selectedRange
|
||||||
|
let highlighter = Highlighter()
|
||||||
|
highlighter?.setTheme(traitCollection.userInterfaceStyle == .dark ? "atom-one-dark" : "atom-one-light")
|
||||||
|
highlighter?.theme.setCodeFont(editorFont)
|
||||||
|
bodyView.attributedText = highlighter?.highlight(source, as: "markdown")
|
||||||
|
?? NSAttributedString(string: source, attributes: [
|
||||||
|
.font: editorFont,
|
||||||
|
.foregroundColor: UIColor.label,
|
||||||
|
])
|
||||||
|
bodyView.selectedRange = selection
|
||||||
|
bodyView.typingAttributes = [
|
||||||
|
.font: editorFont,
|
||||||
|
.foregroundColor: UIColor.label,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func modeChanged() {
|
||||||
|
let preview = modeControl.selectedSegmentIndex == 1
|
||||||
|
if preview {
|
||||||
|
previewController?.willMove(toParent: nil)
|
||||||
|
previewController?.view.removeFromSuperview()
|
||||||
|
previewController?.removeFromParent()
|
||||||
|
let controller = UIHostingController(
|
||||||
|
rootView: RepositoryMarkdownPreview(source: bodyView.text ?? "")
|
||||||
|
)
|
||||||
|
addChild(controller)
|
||||||
|
controller.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
controller.view.layer.cornerRadius = 10
|
||||||
|
controller.view.clipsToBounds = true
|
||||||
|
previewView.addSubview(controller.view)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
controller.view.leadingAnchor.constraint(equalTo: previewView.leadingAnchor),
|
||||||
|
controller.view.trailingAnchor.constraint(equalTo: previewView.trailingAnchor),
|
||||||
|
controller.view.topAnchor.constraint(equalTo: previewView.topAnchor),
|
||||||
|
controller.view.bottomAnchor.constraint(equalTo: previewView.bottomAnchor),
|
||||||
|
])
|
||||||
|
controller.didMove(toParent: self)
|
||||||
|
previewController = controller
|
||||||
|
}
|
||||||
|
bodyView.isHidden = preview
|
||||||
|
previewView.isHidden = !preview
|
||||||
|
view.endEditing(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func dueChanged() {
|
||||||
|
duePicker.isHidden = !dueSwitch.isOn
|
||||||
|
if dueSwitch.isOn, page?.dueDate == nil { duePicker.date = Date() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func titleChanged() {
|
||||||
|
navigationItem.rightBarButtonItem?.isEnabled = !(titleField.text ?? "")
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func save() {
|
||||||
|
view.endEditing(true)
|
||||||
|
navigationItem.leftBarButtonItem?.isEnabled = false
|
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(customView: spinner)
|
||||||
|
spinner.startAnimating()
|
||||||
|
task?.cancel()
|
||||||
|
task = Task {
|
||||||
|
do {
|
||||||
|
let number = try await context.core.saveIssue(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
number: number,
|
||||||
|
title: titleField.text ?? "",
|
||||||
|
body: bodyView.text ?? "",
|
||||||
|
labelIds: Array(selectedLabels),
|
||||||
|
milestoneId: selectedMilestone,
|
||||||
|
dueDate: dueSwitch.isOn ? utcTimestamp(forLocalDate: duePicker.date) : nil,
|
||||||
|
closed: closedSwitch.isOn
|
||||||
|
)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
saved(number)
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled {
|
||||||
|
show(error: error)
|
||||||
|
restoreSaveButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restoreSaveButton() {
|
||||||
|
spinner.stopAnimating()
|
||||||
|
navigationItem.leftBarButtonItem?.isEnabled = true
|
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
|
title: "Save",
|
||||||
|
style: .done,
|
||||||
|
target: self,
|
||||||
|
action: #selector(save)
|
||||||
|
)
|
||||||
|
titleChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func cancel() { dismiss(animated: true) }
|
||||||
|
|
||||||
|
}
|
||||||
439
ios/Sources/IssueScreens.swift
Normal file
439
ios/Sources/IssueScreens.swift
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class IssuesViewController: RefreshingTableViewController, IssueSwipeActionHost {
|
||||||
|
let context: AppContext
|
||||||
|
let owner: String
|
||||||
|
let repository: String
|
||||||
|
private var rows: [IssueRow] = []
|
||||||
|
private var filterOptions: IssueFilterOptions?
|
||||||
|
private var filterTask: Task<Void, Never>?
|
||||||
|
var issueMutationTask: Task<Void, Never>?
|
||||||
|
private var currentPage: UInt32 = 0
|
||||||
|
private lazy var filterButton = UIBarButtonItem(
|
||||||
|
image: context.symbol("line.3.horizontal.decrease.circle")
|
||||||
|
)
|
||||||
|
|
||||||
|
init(context: AppContext, owner: String, repository: String) {
|
||||||
|
self.context = context
|
||||||
|
self.owner = owner
|
||||||
|
self.repository = repository
|
||||||
|
super.init()
|
||||||
|
title = repository
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
filterTask?.cancel()
|
||||||
|
issueMutationTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
tableView.register(IssueCell.self, forCellReuseIdentifier: "issue")
|
||||||
|
let addButton = UIBarButtonItem(
|
||||||
|
barButtonSystemItem: .add,
|
||||||
|
target: self,
|
||||||
|
action: #selector(createIssue)
|
||||||
|
)
|
||||||
|
addButton.accessibilityLabel = "New issue"
|
||||||
|
navigationItem.rightBarButtonItems = [addButton, filterButton]
|
||||||
|
updateFilterMenu()
|
||||||
|
loadContent(refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadContent(refreshing: Bool) {
|
||||||
|
loadFilterOptions()
|
||||||
|
loadIssues(refreshing: refreshing)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadIssues(refreshing: Bool) {
|
||||||
|
loadIssues(page: 1, refreshing: refreshing)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadMoreContent() {
|
||||||
|
loadIssues(page: currentPage + 1, refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadIssues(page: UInt32, refreshing: Bool) {
|
||||||
|
if page == 1 {
|
||||||
|
resetPagination()
|
||||||
|
beginLoading(refreshing: refreshing)
|
||||||
|
}
|
||||||
|
loadingTask?.cancel()
|
||||||
|
loadingTask = Task {
|
||||||
|
do {
|
||||||
|
let result = try await context.core.issues(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
page: page
|
||||||
|
)
|
||||||
|
if page == 1 { rows = result.rows } else { rows.append(contentsOf: result.rows) }
|
||||||
|
currentPage = page
|
||||||
|
finishPagination(hasMore: result.hasMore)
|
||||||
|
tableView.reloadData()
|
||||||
|
updateEmptyState()
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled {
|
||||||
|
show(error: error)
|
||||||
|
failPagination()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if page == 1 { endLoading() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadFilterOptions() {
|
||||||
|
filterTask?.cancel()
|
||||||
|
filterTask = Task {
|
||||||
|
do {
|
||||||
|
let options = try await context.core.issueFilters(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository
|
||||||
|
)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
filterOptions = options
|
||||||
|
updateFilterMenu()
|
||||||
|
updateEmptyState()
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled { show(error: error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
rows.count
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
cellForRowAt indexPath: IndexPath
|
||||||
|
) -> UITableViewCell {
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: "issue", for: indexPath) as! IssueCell
|
||||||
|
cell.configure(rows[indexPath.row])
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
|
navigationController?.pushViewController(
|
||||||
|
IssueViewController(
|
||||||
|
context: context,
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
number: rows[indexPath.row].number
|
||||||
|
),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath
|
||||||
|
) -> UISwipeActionsConfiguration? {
|
||||||
|
issueSwipeActions(for: rows[indexPath.row])
|
||||||
|
}
|
||||||
|
|
||||||
|
func reloadIssuesAfterMutation() {
|
||||||
|
loadIssues(refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateFilterMenu() {
|
||||||
|
let current = context.core.settings().issueStatus
|
||||||
|
let status = UIMenu(
|
||||||
|
title: "Status",
|
||||||
|
image: filterMenuImage("circle.lefthalf.filled", active: current != "open"),
|
||||||
|
options: .singleSelection,
|
||||||
|
children: ["open", "closed"].map { status in
|
||||||
|
UIAction(
|
||||||
|
title: status.capitalized,
|
||||||
|
state: current == status ? .on : .off
|
||||||
|
) { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
do {
|
||||||
|
try self.context.core.setIssueStatus(status: status)
|
||||||
|
self.updateFilterMenu()
|
||||||
|
self.loadIssues(refreshing: false)
|
||||||
|
} catch {
|
||||||
|
self.show(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
var children: [UIMenuElement] = [status]
|
||||||
|
if let filterOptions {
|
||||||
|
children.insert(searchAction(filterOptions), at: 0)
|
||||||
|
children.append(milestoneMenu(filterOptions))
|
||||||
|
children.append(labelMenu(filterOptions))
|
||||||
|
} else {
|
||||||
|
children.append(UIAction(title: "Loading filters…", attributes: .disabled) { _ in })
|
||||||
|
}
|
||||||
|
children.append(clearFiltersMenu())
|
||||||
|
filterButton.menu = UIMenu(children: children)
|
||||||
|
filterButton.accessibilityLabel = "Filter issues"
|
||||||
|
updateFilterTint()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func searchAction(_ options: IssueFilterOptions) -> UIAction {
|
||||||
|
UIAction(
|
||||||
|
title: "Search Text",
|
||||||
|
subtitle: options.searchText.isEmpty ? "Any text" : options.searchText,
|
||||||
|
image: filterMenuImage("magnifyingglass", active: !options.searchText.isEmpty)
|
||||||
|
) { [weak self] _ in
|
||||||
|
self?.promptForSearchText(
|
||||||
|
title: "Search Issues",
|
||||||
|
current: options.searchText
|
||||||
|
) { [weak self] searchText in
|
||||||
|
self?.setSearchText(searchText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func milestoneMenu(_ options: IssueFilterOptions) -> UIMenu {
|
||||||
|
let selected = options.selectedMilestone
|
||||||
|
let all = UIAction(title: "All Milestones", state: selected.isEmpty ? .on : .off) {
|
||||||
|
[weak self] _ in self?.selectMilestone("")
|
||||||
|
}
|
||||||
|
let actions = options.milestones.map { milestone in
|
||||||
|
UIAction(
|
||||||
|
title: milestone,
|
||||||
|
state: selected == milestone ? .on : .off
|
||||||
|
) { [weak self] _ in self?.selectMilestone(milestone) }
|
||||||
|
}
|
||||||
|
return UIMenu(
|
||||||
|
title: "Milestone",
|
||||||
|
image: filterMenuImage("flag", active: !selected.isEmpty),
|
||||||
|
options: .singleSelection,
|
||||||
|
children: [all] + actions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func labelMenu(_ options: IssueFilterOptions) -> UIMenu {
|
||||||
|
let selected = Set(options.selectedLabels)
|
||||||
|
let actions = options.labels.map { label in
|
||||||
|
UIAction(
|
||||||
|
title: options.unavailableLabels.contains(label) ? "\(label) (Unavailable)" : label,
|
||||||
|
attributes: .keepsMenuPresented,
|
||||||
|
state: selected.contains(label) ? .on : .off
|
||||||
|
) { [weak self] action in
|
||||||
|
guard let self, let options = self.filterOptions else { return }
|
||||||
|
self.filterTask?.cancel()
|
||||||
|
var labels = Set(options.selectedLabels)
|
||||||
|
if labels.remove(label) == nil { labels.insert(label) }
|
||||||
|
do {
|
||||||
|
try self.saveFilters(
|
||||||
|
milestone: options.selectedMilestone,
|
||||||
|
labels: labels,
|
||||||
|
searchText: options.searchText
|
||||||
|
)
|
||||||
|
self.filterOptions?.selectedLabels = Array(labels)
|
||||||
|
action.state = labels.contains(label) ? .on : .off
|
||||||
|
self.updateFilterMenu()
|
||||||
|
self.loadIssues(refreshing: false)
|
||||||
|
} catch {
|
||||||
|
self.show(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return UIMenu(
|
||||||
|
title: "Labels",
|
||||||
|
image: filterMenuImage("tag", active: !selected.isEmpty),
|
||||||
|
children: actions.isEmpty
|
||||||
|
? [UIAction(title: "No labels", attributes: .disabled) { _ in }]
|
||||||
|
: actions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func selectMilestone(_ milestone: String) {
|
||||||
|
guard let options = filterOptions else { return }
|
||||||
|
filterTask?.cancel()
|
||||||
|
do {
|
||||||
|
try saveFilters(
|
||||||
|
milestone: milestone,
|
||||||
|
labels: Set(options.selectedLabels),
|
||||||
|
searchText: options.searchText
|
||||||
|
)
|
||||||
|
filterOptions?.selectedMilestone = milestone
|
||||||
|
updateFilterMenu()
|
||||||
|
loadIssues(refreshing: false)
|
||||||
|
} catch {
|
||||||
|
show(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setSearchText(_ searchText: String) {
|
||||||
|
guard let options = filterOptions else { return }
|
||||||
|
filterTask?.cancel()
|
||||||
|
do {
|
||||||
|
try saveFilters(
|
||||||
|
milestone: options.selectedMilestone,
|
||||||
|
labels: Set(options.selectedLabels),
|
||||||
|
searchText: searchText
|
||||||
|
)
|
||||||
|
filterOptions?.searchText = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
updateFilterMenu()
|
||||||
|
loadIssues(refreshing: false)
|
||||||
|
} catch {
|
||||||
|
show(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveFilters(
|
||||||
|
milestone: String,
|
||||||
|
labels: Set<String>,
|
||||||
|
searchText: String
|
||||||
|
) throws {
|
||||||
|
try context.core.setIssueFilters(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
milestone: milestone,
|
||||||
|
labels: Array(labels),
|
||||||
|
searchText: searchText
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearFiltersMenu() -> UIMenu {
|
||||||
|
UIMenu(
|
||||||
|
options: .displayInline,
|
||||||
|
children: [
|
||||||
|
UIAction(
|
||||||
|
title: "Clear Filters",
|
||||||
|
image: context.symbol("xmark.circle"),
|
||||||
|
attributes: filtersActive ? [] : .disabled
|
||||||
|
) { [weak self] _ in self?.clearFilters() },
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearFilters() {
|
||||||
|
filterTask?.cancel()
|
||||||
|
do {
|
||||||
|
try context.core.clearIssueFilters(owner: owner, repository: repository)
|
||||||
|
filterOptions?.selectedMilestone = ""
|
||||||
|
filterOptions?.selectedLabels = []
|
||||||
|
filterOptions?.searchText = ""
|
||||||
|
updateFilterMenu()
|
||||||
|
loadIssues(refreshing: false)
|
||||||
|
} catch {
|
||||||
|
show(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var filtersActive: Bool {
|
||||||
|
(try? context.core.issueFiltersActive(owner: owner, repository: repository)) ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateFilterTint() {
|
||||||
|
filterButton.tintColor = filtersActive ? .tintColor : .secondaryLabel
|
||||||
|
filterButton.accessibilityValue = filtersActive
|
||||||
|
? "Filters active"
|
||||||
|
: "Default filters"
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func createIssue() {
|
||||||
|
let editor = IssueEditorViewController(
|
||||||
|
context: context,
|
||||||
|
owner: owner,
|
||||||
|
repository: repository
|
||||||
|
) { [weak self] number in
|
||||||
|
guard let self else { return }
|
||||||
|
self.dismiss(animated: true) {
|
||||||
|
self.loadContent(refreshing: false)
|
||||||
|
self.navigationController?.pushViewController(
|
||||||
|
IssueViewController(
|
||||||
|
context: self.context,
|
||||||
|
owner: self.owner,
|
||||||
|
repository: self.repository,
|
||||||
|
number: number
|
||||||
|
),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
present(UINavigationController(rootViewController: editor), animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateEmptyState() {
|
||||||
|
guard rows.isEmpty else {
|
||||||
|
tableView.backgroundView = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let status = context.core.settings().issueStatus
|
||||||
|
tableView.backgroundView = EmptyBackgroundView(
|
||||||
|
title: "No \(status) issues",
|
||||||
|
detail: filtersActive
|
||||||
|
? "No issues match the selected filters."
|
||||||
|
: "This repository has no \(status) issues."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class IssueCell: UITableViewCell {
|
||||||
|
private let stateIcon = UIImageView()
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let summaryLabel = UILabel()
|
||||||
|
private let labels = UIStackView()
|
||||||
|
private let metaLabel = UILabel()
|
||||||
|
private let milestoneLabel = UILabel()
|
||||||
|
|
||||||
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
|
accessoryType = .disclosureIndicator
|
||||||
|
titleLabel.font = .preferredFont(forTextStyle: .headline)
|
||||||
|
titleLabel.numberOfLines = 2
|
||||||
|
let titleStack = UIStackView(arrangedSubviews: [stateIcon, titleLabel])
|
||||||
|
titleStack.alignment = .firstBaseline
|
||||||
|
titleStack.spacing = 8
|
||||||
|
summaryLabel.font = .preferredFont(forTextStyle: .subheadline)
|
||||||
|
summaryLabel.textColor = .secondaryLabel
|
||||||
|
summaryLabel.numberOfLines = 2
|
||||||
|
labels.axis = .horizontal
|
||||||
|
labels.spacing = 5
|
||||||
|
metaLabel.font = .preferredFont(forTextStyle: .caption1)
|
||||||
|
metaLabel.textColor = .tertiaryLabel
|
||||||
|
metaLabel.numberOfLines = 2
|
||||||
|
milestoneLabel.font = .preferredFont(forTextStyle: .caption1)
|
||||||
|
milestoneLabel.adjustsFontForContentSizeCategory = true
|
||||||
|
let stack = UIStackView(
|
||||||
|
arrangedSubviews: [titleStack, summaryLabel, labels, metaLabel, milestoneLabel]
|
||||||
|
)
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = 6
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
contentView.addSubview(stack)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8),
|
||||||
|
stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
func configure(_ row: IssueRow) {
|
||||||
|
configureIssueStateIcon(stateIcon, state: row.state, textStyle: .headline)
|
||||||
|
titleLabel.text = row.title
|
||||||
|
summaryLabel.text = row.summary
|
||||||
|
metaLabel.text = row.meta
|
||||||
|
milestoneLabel.isHidden = row.milestone.isEmpty
|
||||||
|
milestoneLabel.attributedText = symbolText(
|
||||||
|
"flag.fill",
|
||||||
|
text: row.milestone,
|
||||||
|
font: milestoneLabel.font,
|
||||||
|
color: .tertiaryLabel
|
||||||
|
)
|
||||||
|
milestoneLabel.accessibilityLabel = row.milestone.isEmpty
|
||||||
|
? nil
|
||||||
|
: "Milestone \(row.milestone)"
|
||||||
|
labels.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||||
|
labels.isHidden = row.labels.isEmpty
|
||||||
|
for label in row.labels.prefix(3) {
|
||||||
|
labels.addArrangedSubview(issueLabelView(label))
|
||||||
|
}
|
||||||
|
labels.addArrangedSubview(UIView())
|
||||||
|
}
|
||||||
|
}
|
||||||
212
ios/Sources/MilestoneEditorViewController.swift
Normal file
212
ios/Sources/MilestoneEditorViewController.swift
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class MilestoneEditorViewController: UIViewController, UITextFieldDelegate {
|
||||||
|
private let context: AppContext
|
||||||
|
private let owner: String
|
||||||
|
private let repository: String
|
||||||
|
private let id: Int64?
|
||||||
|
private let saved: (Int64) -> Void
|
||||||
|
private let scrollView = UIScrollView()
|
||||||
|
private let stack = UIStackView()
|
||||||
|
private let titleField = UITextField()
|
||||||
|
private let descriptionView = UITextView()
|
||||||
|
private let closedSwitch = UISwitch()
|
||||||
|
private let dueSwitch = UISwitch()
|
||||||
|
private let duePicker = UIDatePicker()
|
||||||
|
private let spinner = UIActivityIndicatorView(style: .medium)
|
||||||
|
private var task: Task<Void, Never>?
|
||||||
|
|
||||||
|
init(
|
||||||
|
context: AppContext,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
id: Int64? = nil,
|
||||||
|
saved: @escaping (Int64) -> Void
|
||||||
|
) {
|
||||||
|
self.context = context
|
||||||
|
self.owner = owner
|
||||||
|
self.repository = repository
|
||||||
|
self.id = id
|
||||||
|
self.saved = saved
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
title = id == nil ? "New Milestone" : "Edit Milestone"
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
deinit { task?.cancel() }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
view.backgroundColor = .systemGroupedBackground
|
||||||
|
navigationItem.leftBarButtonItem = UIBarButtonItem(
|
||||||
|
barButtonSystemItem: .cancel,
|
||||||
|
target: self,
|
||||||
|
action: #selector(cancel)
|
||||||
|
)
|
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(customView: spinner)
|
||||||
|
spinner.startAnimating()
|
||||||
|
configureForm()
|
||||||
|
task = Task { await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
if id == nil { titleField.becomeFirstResponder() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureForm() {
|
||||||
|
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
scrollView.keyboardDismissMode = .interactive
|
||||||
|
view.addSubview(scrollView)
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = 12
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
stack.isHidden = true
|
||||||
|
scrollView.addSubview(stack)
|
||||||
|
|
||||||
|
titleField.placeholder = "Title"
|
||||||
|
titleField.delegate = self
|
||||||
|
titleField.font = .preferredFont(forTextStyle: .headline)
|
||||||
|
titleField.adjustsFontForContentSizeCategory = true
|
||||||
|
titleField.borderStyle = .roundedRect
|
||||||
|
titleField.autocapitalizationType = .sentences
|
||||||
|
titleField.returnKeyType = .next
|
||||||
|
titleField.accessibilityLabel = "Milestone title"
|
||||||
|
titleField.addTarget(self, action: #selector(titleChanged), for: .editingChanged)
|
||||||
|
|
||||||
|
let descriptionLabel = UILabel()
|
||||||
|
descriptionLabel.text = "Description"
|
||||||
|
descriptionLabel.font = .preferredFont(forTextStyle: .headline)
|
||||||
|
descriptionLabel.adjustsFontForContentSizeCategory = true
|
||||||
|
descriptionView.font = .preferredFont(forTextStyle: .body)
|
||||||
|
descriptionView.adjustsFontForContentSizeCategory = true
|
||||||
|
descriptionView.backgroundColor = .secondarySystemGroupedBackground
|
||||||
|
descriptionView.layer.cornerRadius = 10
|
||||||
|
descriptionView.textContainerInset = UIEdgeInsets(top: 12, left: 8, bottom: 12, right: 8)
|
||||||
|
descriptionView.autocapitalizationType = .sentences
|
||||||
|
descriptionView.accessibilityLabel = "Milestone description"
|
||||||
|
|
||||||
|
let closedLabel = UILabel()
|
||||||
|
closedLabel.text = "Closed"
|
||||||
|
closedLabel.font = .preferredFont(forTextStyle: .body)
|
||||||
|
closedLabel.adjustsFontForContentSizeCategory = true
|
||||||
|
let closedRow = UIStackView(arrangedSubviews: [closedLabel, closedSwitch])
|
||||||
|
closedRow.alignment = .center
|
||||||
|
closedSwitch.accessibilityLabel = "Closed milestone"
|
||||||
|
|
||||||
|
let dueLabel = UILabel()
|
||||||
|
dueLabel.text = "Due Date"
|
||||||
|
dueLabel.font = .preferredFont(forTextStyle: .body)
|
||||||
|
dueLabel.adjustsFontForContentSizeCategory = true
|
||||||
|
let dueRow = UIStackView(arrangedSubviews: [dueLabel, dueSwitch])
|
||||||
|
dueRow.alignment = .center
|
||||||
|
dueSwitch.accessibilityLabel = "Set due date"
|
||||||
|
dueSwitch.addTarget(self, action: #selector(dueChanged), for: .valueChanged)
|
||||||
|
duePicker.datePickerMode = .date
|
||||||
|
duePicker.preferredDatePickerStyle = .inline
|
||||||
|
duePicker.isHidden = true
|
||||||
|
|
||||||
|
stack.addArrangedSubview(titleField)
|
||||||
|
stack.addArrangedSubview(descriptionLabel)
|
||||||
|
stack.addArrangedSubview(descriptionView)
|
||||||
|
stack.addArrangedSubview(closedRow)
|
||||||
|
stack.addArrangedSubview(dueRow)
|
||||||
|
stack.addArrangedSubview(duePicker)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
scrollView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
||||||
|
scrollView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
||||||
|
scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||||
|
scrollView.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
|
||||||
|
stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 16),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -16),
|
||||||
|
stack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 16),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -16),
|
||||||
|
stack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -32),
|
||||||
|
descriptionView.heightAnchor.constraint(greaterThanOrEqualToConstant: 180),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
do {
|
||||||
|
let page = try await context.core.milestoneEditor(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
id: id
|
||||||
|
)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
titleField.text = page.title
|
||||||
|
descriptionView.text = page.description
|
||||||
|
closedSwitch.isOn = page.closed
|
||||||
|
if let timestamp = page.dueDate {
|
||||||
|
dueSwitch.isOn = true
|
||||||
|
duePicker.date = localDate(forUTCTimestamp: timestamp)
|
||||||
|
duePicker.isHidden = false
|
||||||
|
}
|
||||||
|
stack.isHidden = false
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled { show(error: error) }
|
||||||
|
}
|
||||||
|
restoreSaveButton()
|
||||||
|
}
|
||||||
|
|
||||||
|
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||||
|
descriptionView.becomeFirstResponder()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func dueChanged() {
|
||||||
|
duePicker.isHidden = !dueSwitch.isOn
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func titleChanged() {
|
||||||
|
navigationItem.rightBarButtonItem?.isEnabled = !(titleField.text ?? "")
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func save() {
|
||||||
|
view.endEditing(true)
|
||||||
|
navigationItem.leftBarButtonItem?.isEnabled = false
|
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(customView: spinner)
|
||||||
|
spinner.startAnimating()
|
||||||
|
task?.cancel()
|
||||||
|
task = Task {
|
||||||
|
do {
|
||||||
|
let id = try await context.core.saveMilestone(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
id: id,
|
||||||
|
draft: MilestoneEditorPage(
|
||||||
|
title: titleField.text ?? "",
|
||||||
|
description: descriptionView.text ?? "",
|
||||||
|
dueDate: dueSwitch.isOn ? utcTimestamp(forLocalDate: duePicker.date) : nil,
|
||||||
|
closed: closedSwitch.isOn
|
||||||
|
)
|
||||||
|
)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
saved(id)
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled {
|
||||||
|
show(error: error)
|
||||||
|
restoreSaveButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restoreSaveButton() {
|
||||||
|
spinner.stopAnimating()
|
||||||
|
navigationItem.leftBarButtonItem?.isEnabled = true
|
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
|
title: "Save",
|
||||||
|
style: .done,
|
||||||
|
target: self,
|
||||||
|
action: #selector(save)
|
||||||
|
)
|
||||||
|
titleChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func cancel() { dismiss(animated: true) }
|
||||||
|
}
|
||||||
466
ios/Sources/MilestoneScreens.swift
Normal file
466
ios/Sources/MilestoneScreens.swift
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class MilestonesViewController: RefreshingTableViewController {
|
||||||
|
private let context: AppContext
|
||||||
|
private let owner: String
|
||||||
|
private let repository: String
|
||||||
|
private var rows: [MilestoneRow] = []
|
||||||
|
private var mutationTask: Task<Void, Never>?
|
||||||
|
private var currentPage: UInt32 = 0
|
||||||
|
|
||||||
|
init(context: AppContext, owner: String, repository: String) {
|
||||||
|
self.context = context
|
||||||
|
self.owner = owner
|
||||||
|
self.repository = repository
|
||||||
|
super.init()
|
||||||
|
title = repository
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
deinit { mutationTask?.cancel() }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
tableView.register(MilestoneCell.self, forCellReuseIdentifier: "milestone")
|
||||||
|
tableView.rowHeight = UITableView.automaticDimension
|
||||||
|
tableView.estimatedRowHeight = 118
|
||||||
|
let addButton = UIBarButtonItem(
|
||||||
|
barButtonSystemItem: .add,
|
||||||
|
target: self,
|
||||||
|
action: #selector(createMilestone)
|
||||||
|
)
|
||||||
|
addButton.accessibilityLabel = "New milestone"
|
||||||
|
navigationItem.rightBarButtonItem = addButton
|
||||||
|
loadContent(refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func createMilestone() {
|
||||||
|
let editor = MilestoneEditorViewController(
|
||||||
|
context: context,
|
||||||
|
owner: owner,
|
||||||
|
repository: repository
|
||||||
|
) { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
self.dismiss(animated: true) { self.loadContent(refreshing: false) }
|
||||||
|
}
|
||||||
|
present(UINavigationController(rootViewController: editor), animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadContent(refreshing: Bool) {
|
||||||
|
loadPage(1, refreshing: refreshing)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadMoreContent() {
|
||||||
|
loadPage(currentPage + 1, refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadPage(_ page: UInt32, refreshing: Bool) {
|
||||||
|
if page == 1 {
|
||||||
|
resetPagination()
|
||||||
|
beginLoading(refreshing: refreshing)
|
||||||
|
}
|
||||||
|
loadingTask?.cancel()
|
||||||
|
loadingTask = Task {
|
||||||
|
do {
|
||||||
|
let result = try await context.core.milestones(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
page: page
|
||||||
|
)
|
||||||
|
if page == 1 { rows = result.rows } else { rows.append(contentsOf: result.rows) }
|
||||||
|
currentPage = page
|
||||||
|
finishPagination(hasMore: result.hasMore)
|
||||||
|
tableView.reloadData()
|
||||||
|
tableView.backgroundView = rows.isEmpty
|
||||||
|
? EmptyBackgroundView(
|
||||||
|
title: "No milestones",
|
||||||
|
detail: "This repository does not have any milestones."
|
||||||
|
)
|
||||||
|
: nil
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled {
|
||||||
|
show(error: error)
|
||||||
|
failPagination()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if page == 1 { endLoading() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
rows.count
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
cellForRowAt indexPath: IndexPath
|
||||||
|
) -> UITableViewCell {
|
||||||
|
let cell = tableView.dequeueReusableCell(
|
||||||
|
withIdentifier: "milestone",
|
||||||
|
for: indexPath
|
||||||
|
) as! MilestoneCell
|
||||||
|
cell.configure(rows[indexPath.row], disclosure: true)
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
|
navigationController?.pushViewController(
|
||||||
|
MilestoneViewController(
|
||||||
|
context: context,
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
id: rows[indexPath.row].id
|
||||||
|
),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath
|
||||||
|
) -> UISwipeActionsConfiguration? {
|
||||||
|
let row = rows[indexPath.row]
|
||||||
|
let close = row.state != .closed
|
||||||
|
let stateAction = UIContextualAction(
|
||||||
|
style: .normal,
|
||||||
|
title: close ? "Close" : "Open"
|
||||||
|
) { [weak self] _, _, completion in
|
||||||
|
self?.setMilestone(row, closed: close, completion: completion) ?? completion(false)
|
||||||
|
}
|
||||||
|
stateAction.image = context.symbol(close ? "checkmark.circle" : "arrow.uturn.left.circle")
|
||||||
|
stateAction.backgroundColor = close ? .systemPurple : .systemGreen
|
||||||
|
|
||||||
|
let deleteAction = UIContextualAction(style: .destructive, title: "Delete") {
|
||||||
|
[weak self] _, _, completion in
|
||||||
|
self?.confirmDelete(row, completion: completion) ?? completion(false)
|
||||||
|
}
|
||||||
|
deleteAction.image = context.symbol("trash")
|
||||||
|
|
||||||
|
let configuration = UISwipeActionsConfiguration(actions: [stateAction, deleteAction])
|
||||||
|
configuration.performsFirstActionWithFullSwipe = true
|
||||||
|
return configuration
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setMilestone(
|
||||||
|
_ row: MilestoneRow,
|
||||||
|
closed: Bool,
|
||||||
|
completion: @escaping (Bool) -> Void
|
||||||
|
) {
|
||||||
|
mutationTask?.cancel()
|
||||||
|
mutationTask = Task {
|
||||||
|
do {
|
||||||
|
try await context.core.setMilestoneClosed(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
id: row.id,
|
||||||
|
closed: closed
|
||||||
|
)
|
||||||
|
guard !Task.isCancelled else {
|
||||||
|
completion(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
completion(true)
|
||||||
|
loadContent(refreshing: false)
|
||||||
|
} catch {
|
||||||
|
completion(false)
|
||||||
|
if !Task.isCancelled { show(error: error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func confirmDelete(_ row: MilestoneRow, completion: @escaping (Bool) -> Void) {
|
||||||
|
guard !row.hasIssues else {
|
||||||
|
let alert = UIAlertController(
|
||||||
|
title: "Milestone Can’t Be Deleted",
|
||||||
|
message: "Remove all assigned issues and pull requests first.",
|
||||||
|
preferredStyle: .alert
|
||||||
|
)
|
||||||
|
alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in completion(false) })
|
||||||
|
present(alert, animated: true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let alert = UIAlertController(
|
||||||
|
title: "Delete \(row.title)?",
|
||||||
|
message: "This milestone will be permanently deleted. This can’t be undone.",
|
||||||
|
preferredStyle: .alert
|
||||||
|
)
|
||||||
|
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in completion(false) })
|
||||||
|
alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in
|
||||||
|
guard let self else {
|
||||||
|
completion(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.mutationTask?.cancel()
|
||||||
|
self.mutationTask = Task {
|
||||||
|
do {
|
||||||
|
try await self.context.core.deleteMilestone(
|
||||||
|
owner: self.owner,
|
||||||
|
repository: self.repository,
|
||||||
|
id: row.id
|
||||||
|
)
|
||||||
|
guard !Task.isCancelled else {
|
||||||
|
completion(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
completion(true)
|
||||||
|
self.loadContent(refreshing: false)
|
||||||
|
} catch {
|
||||||
|
completion(false)
|
||||||
|
if !Task.isCancelled { self.show(error: error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
present(alert, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class MilestoneViewController: RefreshingTableViewController, IssueSwipeActionHost {
|
||||||
|
let context: AppContext
|
||||||
|
let owner: String
|
||||||
|
let repository: String
|
||||||
|
private let id: Int64
|
||||||
|
private var page: MilestonePage?
|
||||||
|
var issueMutationTask: Task<Void, Never>?
|
||||||
|
private var currentPage: UInt32 = 0
|
||||||
|
|
||||||
|
init(context: AppContext, owner: String, repository: String, id: Int64) {
|
||||||
|
self.context = context
|
||||||
|
self.owner = owner
|
||||||
|
self.repository = repository
|
||||||
|
self.id = id
|
||||||
|
super.init()
|
||||||
|
title = "Milestone"
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
deinit { issueMutationTask?.cancel() }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
tableView.register(MilestoneCell.self, forCellReuseIdentifier: "milestone")
|
||||||
|
tableView.register(IssueCell.self, forCellReuseIdentifier: "issue")
|
||||||
|
tableView.register(PullCell.self, forCellReuseIdentifier: "pull")
|
||||||
|
tableView.rowHeight = UITableView.automaticDimension
|
||||||
|
tableView.estimatedRowHeight = 118
|
||||||
|
let editButton = UIBarButtonItem(
|
||||||
|
image: context.symbol("pencil"),
|
||||||
|
style: .plain,
|
||||||
|
target: self,
|
||||||
|
action: #selector(editMilestone)
|
||||||
|
)
|
||||||
|
editButton.accessibilityLabel = "Edit milestone"
|
||||||
|
navigationItem.rightBarButtonItem = editButton
|
||||||
|
loadContent(refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func editMilestone() {
|
||||||
|
let editor = MilestoneEditorViewController(
|
||||||
|
context: context,
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
id: id
|
||||||
|
) { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
self.dismiss(animated: true) { self.loadContent(refreshing: false) }
|
||||||
|
}
|
||||||
|
present(UINavigationController(rootViewController: editor), animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadContent(refreshing: Bool) {
|
||||||
|
loadPage(1, refreshing: refreshing)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadMoreContent() {
|
||||||
|
loadPage(currentPage + 1, refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadPage(_ requestedPage: UInt32, refreshing: Bool) {
|
||||||
|
if requestedPage == 1 {
|
||||||
|
resetPagination()
|
||||||
|
beginLoading(refreshing: refreshing)
|
||||||
|
}
|
||||||
|
loadingTask?.cancel()
|
||||||
|
loadingTask = Task {
|
||||||
|
do {
|
||||||
|
let result = try await context.core.milestone(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
id: id,
|
||||||
|
page: requestedPage
|
||||||
|
)
|
||||||
|
if requestedPage == 1 {
|
||||||
|
page = result
|
||||||
|
} else {
|
||||||
|
page?.issues.append(contentsOf: result.issues)
|
||||||
|
page?.pulls.append(contentsOf: result.pulls)
|
||||||
|
page?.hasMore = result.hasMore
|
||||||
|
}
|
||||||
|
currentPage = requestedPage
|
||||||
|
finishPagination(hasMore: result.hasMore)
|
||||||
|
title = page?.milestone.title
|
||||||
|
tableView.reloadData()
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled {
|
||||||
|
show(error: error)
|
||||||
|
failPagination()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if requestedPage == 1 { endLoading() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func numberOfSections(in tableView: UITableView) -> Int { page == nil ? 0 : 3 }
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
switch section {
|
||||||
|
case 0: 1
|
||||||
|
case 1: page?.issues.count ?? 0
|
||||||
|
default: page?.pulls.count ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||||
|
switch section {
|
||||||
|
case 1: "Issues"
|
||||||
|
case 2: "Pull Requests"
|
||||||
|
default: nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
|
||||||
|
if section == 1, page?.issues.isEmpty == true {
|
||||||
|
return "No issues are assigned to this milestone."
|
||||||
|
}
|
||||||
|
if section == 2, page?.pulls.isEmpty == true {
|
||||||
|
return "No pull requests are assigned to this milestone."
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
cellForRowAt indexPath: IndexPath
|
||||||
|
) -> UITableViewCell {
|
||||||
|
guard let page else { return UITableViewCell() }
|
||||||
|
if indexPath.section == 0 {
|
||||||
|
let cell = tableView.dequeueReusableCell(
|
||||||
|
withIdentifier: "milestone",
|
||||||
|
for: indexPath
|
||||||
|
) as! MilestoneCell
|
||||||
|
cell.configure(page.milestone, disclosure: false)
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
if indexPath.section == 1 {
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: "issue", for: indexPath) as! IssueCell
|
||||||
|
cell.configure(page.issues[indexPath.row])
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: "pull", for: indexPath) as! PullCell
|
||||||
|
cell.configure(page.pulls[indexPath.row])
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
|
if indexPath.section == 1, let issue = page?.issues[indexPath.row] {
|
||||||
|
navigationController?.pushViewController(
|
||||||
|
IssueViewController(
|
||||||
|
context: context,
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
number: issue.number
|
||||||
|
),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
} else if indexPath.section == 2, let pull = page?.pulls[indexPath.row] {
|
||||||
|
navigationController?.pushViewController(
|
||||||
|
PullViewController(
|
||||||
|
context: context,
|
||||||
|
owner: pull.owner,
|
||||||
|
repository: pull.repository,
|
||||||
|
number: pull.number
|
||||||
|
),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath
|
||||||
|
) -> UISwipeActionsConfiguration? {
|
||||||
|
guard indexPath.section == 1, let issue = page?.issues[indexPath.row] else { return nil }
|
||||||
|
return issueSwipeActions(for: issue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func reloadIssuesAfterMutation() {
|
||||||
|
loadContent(refreshing: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class MilestoneCell: UITableViewCell {
|
||||||
|
private let stateIcon = UIImageView()
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let stack = UIStackView()
|
||||||
|
private var descriptionView: UIView?
|
||||||
|
private let metaLabel = UILabel()
|
||||||
|
private let progress = UIProgressView(progressViewStyle: .bar)
|
||||||
|
|
||||||
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
|
titleLabel.font = .preferredFont(forTextStyle: .headline)
|
||||||
|
titleLabel.numberOfLines = 2
|
||||||
|
let titleStack = UIStackView(arrangedSubviews: [stateIcon, titleLabel])
|
||||||
|
titleStack.alignment = .firstBaseline
|
||||||
|
titleStack.spacing = 8
|
||||||
|
metaLabel.font = .preferredFont(forTextStyle: .caption1)
|
||||||
|
metaLabel.textColor = .tertiaryLabel
|
||||||
|
metaLabel.numberOfLines = 2
|
||||||
|
progress.progressTintColor = .systemGreen
|
||||||
|
stack.addArrangedSubview(titleStack)
|
||||||
|
stack.addArrangedSubview(progress)
|
||||||
|
stack.addArrangedSubview(metaLabel)
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = 7
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
contentView.addSubview(stack)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
|
||||||
|
stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -12),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
func configure(_ row: MilestoneRow, disclosure: Bool) {
|
||||||
|
accessoryType = disclosure ? .disclosureIndicator : .none
|
||||||
|
configureOpenClosedStateIcon(
|
||||||
|
stateIcon,
|
||||||
|
state: row.state,
|
||||||
|
subject: "milestone",
|
||||||
|
textStyle: .headline
|
||||||
|
)
|
||||||
|
titleLabel.text = row.title
|
||||||
|
descriptionView?.removeFromSuperview()
|
||||||
|
if !row.description.isEmpty {
|
||||||
|
let view = markdownView(row.description)
|
||||||
|
stack.insertArrangedSubview(view, at: 1)
|
||||||
|
descriptionView = view
|
||||||
|
}
|
||||||
|
metaLabel.text = row.meta
|
||||||
|
progress.progress = Float(row.progress)
|
||||||
|
progress.trackTintColor = row.hasIssues ? .systemOrange : .systemGray5
|
||||||
|
progress.accessibilityLabel = "Milestone progress"
|
||||||
|
progress.accessibilityValue = row.progressAccessibility
|
||||||
|
}
|
||||||
|
}
|
||||||
130
ios/Sources/NavigationSettingsViewController.swift
Normal file
130
ios/Sources/NavigationSettingsViewController.swift
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class NavigationSettingsViewController: UITableViewController {
|
||||||
|
private let context: AppContext
|
||||||
|
private var selected: [PrimaryDestination]
|
||||||
|
|
||||||
|
init(context: AppContext) {
|
||||||
|
self.context = context
|
||||||
|
selected = context.primaryDestinations
|
||||||
|
super.init(style: .insetGrouped)
|
||||||
|
title = "Primary Navigation"
|
||||||
|
navigationItem.rightBarButtonItem = editButtonItem
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
private var available: [PrimaryDestination] {
|
||||||
|
PrimaryDestination.allCases.filter { !selected.contains($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override func numberOfSections(in tableView: UITableView) -> Int { 2 }
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
section == 0 ? selected.count : available.count
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||||
|
section == 0 ? "Shown After Home" : "Available Destinations"
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
|
||||||
|
section == 0
|
||||||
|
? "Tap Edit to add, remove, or reorder up to four destinations. Home is always first."
|
||||||
|
: "Destinations not in the tab bar remain available as buttons on Home."
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
cellForRowAt indexPath: IndexPath
|
||||||
|
) -> UITableViewCell {
|
||||||
|
let destination = indexPath.section == 0 ? selected[indexPath.row] : available[indexPath.row]
|
||||||
|
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
|
||||||
|
var content = cell.defaultContentConfiguration()
|
||||||
|
content.text = destination.title
|
||||||
|
content.image = UIImage(systemName: destination.symbolName)
|
||||||
|
content.imageProperties.tintColor = .tintColor
|
||||||
|
if indexPath.section == 1 && selected.count == 4 {
|
||||||
|
content.textProperties.color = .secondaryLabel
|
||||||
|
content.imageProperties.tintColor = .tertiaryLabel
|
||||||
|
}
|
||||||
|
cell.selectionStyle = .none
|
||||||
|
cell.contentConfiguration = content
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
|
||||||
|
tableView.isEditing && (indexPath.section == 0 || selected.count < 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool {
|
||||||
|
tableView.isEditing && indexPath.section == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
moveRowAt sourceIndexPath: IndexPath,
|
||||||
|
to destinationIndexPath: IndexPath
|
||||||
|
) {
|
||||||
|
guard sourceIndexPath.section == 0, destinationIndexPath.section == 0 else {
|
||||||
|
tableView.reloadData()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let destination = selected.remove(at: sourceIndexPath.row)
|
||||||
|
selected.insert(destination, at: destinationIndexPath.row)
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
targetIndexPathForMoveFromRowAt sourceIndexPath: IndexPath,
|
||||||
|
toProposedIndexPath proposedDestinationIndexPath: IndexPath
|
||||||
|
) -> IndexPath {
|
||||||
|
guard proposedDestinationIndexPath.section == 0 else {
|
||||||
|
return IndexPath(row: max(selected.count - 1, 0), section: 0)
|
||||||
|
}
|
||||||
|
return proposedDestinationIndexPath
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
editingStyleForRowAt indexPath: IndexPath
|
||||||
|
) -> UITableViewCell.EditingStyle {
|
||||||
|
if indexPath.section == 0 { return .delete }
|
||||||
|
return selected.count < 4 ? .insert : .none
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
commit editingStyle: UITableViewCell.EditingStyle,
|
||||||
|
forRowAt indexPath: IndexPath
|
||||||
|
) {
|
||||||
|
switch (editingStyle, indexPath.section) {
|
||||||
|
case (.delete, 0):
|
||||||
|
selected.remove(at: indexPath.row)
|
||||||
|
case (.insert, 1) where selected.count < 4:
|
||||||
|
selected.append(available[indexPath.row])
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
save()
|
||||||
|
tableView.reloadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
do {
|
||||||
|
try context.core.setPrimaryDestinations(destinations: selected)
|
||||||
|
context.applyPrimaryDestinations()
|
||||||
|
} catch {
|
||||||
|
selected = context.primaryDestinations
|
||||||
|
tableView.reloadData()
|
||||||
|
show(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
212
ios/Sources/NotificationCoordinator.swift
Normal file
212
ios/Sources/NotificationCoordinator.swift
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import BackgroundTasks
|
||||||
|
import UIKit
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class NotificationCoordinator: NSObject, @preconcurrency UNUserNotificationCenterDelegate {
|
||||||
|
static let refreshIdentifier = "de.rfc1437.gotcha.notifications.refresh"
|
||||||
|
|
||||||
|
private unowned let context: AppContext
|
||||||
|
private let center = UNUserNotificationCenter.current()
|
||||||
|
private var timer: Timer?
|
||||||
|
private var pollingTask: Task<Void, Never>?
|
||||||
|
#if DEBUG
|
||||||
|
private let validatesBackgroundNotifications = ProcessInfo.processInfo.arguments.contains(
|
||||||
|
"--validate-background-notifications"
|
||||||
|
)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
init(context: AppContext) {
|
||||||
|
self.context = context
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
center.delegate = self
|
||||||
|
BGTaskScheduler.shared.register(
|
||||||
|
forTaskWithIdentifier: Self.refreshIdentifier,
|
||||||
|
using: nil
|
||||||
|
) { [weak self] task in
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let self, let task = task as? BGAppRefreshTask else {
|
||||||
|
task.setTaskCompleted(success: false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.handle(task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
timer = Timer.scheduledTimer(withTimeInterval: 5 * 60, repeats: true) {
|
||||||
|
[weak self] _ in
|
||||||
|
Task { @MainActor in self?.refresh(deliverAlerts: false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationDidBecomeActive() {
|
||||||
|
#if DEBUG
|
||||||
|
guard !validatesBackgroundNotifications else { return }
|
||||||
|
#endif
|
||||||
|
refresh(deliverAlerts: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationDidEnterBackground() {
|
||||||
|
scheduleBackgroundRefresh()
|
||||||
|
#if DEBUG
|
||||||
|
guard validatesBackgroundNotifications else { return }
|
||||||
|
let identifier = UIApplication.shared.beginBackgroundTask()
|
||||||
|
pollingTask?.cancel()
|
||||||
|
pollingTask = Task {
|
||||||
|
defer { UIApplication.shared.endBackgroundTask(identifier) }
|
||||||
|
try? await poll(deliverAlerts: true)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
func setEnabled(_ enabled: Bool) async throws -> Bool {
|
||||||
|
if !enabled {
|
||||||
|
try context.core.setNotificationsEnabled(enabled: false)
|
||||||
|
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.refreshIdentifier)
|
||||||
|
center.removeAllPendingNotificationRequests()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings = await center.notificationSettings()
|
||||||
|
if settings.authorizationStatus == .notDetermined {
|
||||||
|
_ = try await center.requestAuthorization(options: [.alert, .sound])
|
||||||
|
settings = await center.notificationSettings()
|
||||||
|
}
|
||||||
|
guard Self.isAuthorized(settings.authorizationStatus) else { return false }
|
||||||
|
try context.core.setNotificationsEnabled(enabled: true)
|
||||||
|
scheduleBackgroundRefresh()
|
||||||
|
try await poll(deliverAlerts: false)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func authorizationDescription() async -> String {
|
||||||
|
switch await center.notificationSettings().authorizationStatus {
|
||||||
|
case .notDetermined: return "Not requested"
|
||||||
|
case .denied: return "Disabled in iOS Settings"
|
||||||
|
case .authorized: return "Allowed"
|
||||||
|
case .provisional: return "Delivered quietly"
|
||||||
|
case .ephemeral: return "Allowed temporarily"
|
||||||
|
@unknown default: return "Managed by iOS"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openSystemSettings() {
|
||||||
|
guard let url = URL(string: UIApplication.openNotificationSettingsURLString) else { return }
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refresh(deliverAlerts: Bool) {
|
||||||
|
pollingTask?.cancel()
|
||||||
|
pollingTask = Task {
|
||||||
|
do {
|
||||||
|
let settings = await center.notificationSettings()
|
||||||
|
guard
|
||||||
|
context.core.settings().notificationsEnabled,
|
||||||
|
Self.isAuthorized(settings.authorizationStatus)
|
||||||
|
else { return }
|
||||||
|
try await poll(deliverAlerts: deliverAlerts)
|
||||||
|
} catch {
|
||||||
|
// Foreground screens surface API errors when the user explicitly refreshes them.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func poll(deliverAlerts: Bool) async throws {
|
||||||
|
let rows = try await context.core.pollNotifications()
|
||||||
|
guard deliverAlerts else { return }
|
||||||
|
for row in rows where row.target != .none {
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = title(for: row.target)
|
||||||
|
content.body = "Open Gotcha to view the update."
|
||||||
|
content.sound = .default
|
||||||
|
content.threadIdentifier = "gotcha.\(row.serverId)"
|
||||||
|
content.userInfo = [
|
||||||
|
"serverId": row.serverId,
|
||||||
|
"threadId": row.id,
|
||||||
|
"target": targetName(row.target),
|
||||||
|
"owner": row.owner,
|
||||||
|
"repository": row.repository,
|
||||||
|
"number": row.number,
|
||||||
|
"sha": row.sha,
|
||||||
|
]
|
||||||
|
let request = UNNotificationRequest(
|
||||||
|
identifier: "gotcha.\(row.serverId).\(row.id)",
|
||||||
|
content: content,
|
||||||
|
trigger: nil
|
||||||
|
)
|
||||||
|
try await center.add(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleBackgroundRefresh() {
|
||||||
|
guard context.core.settings().notificationsEnabled else { return }
|
||||||
|
let request = BGAppRefreshTaskRequest(identifier: Self.refreshIdentifier)
|
||||||
|
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
|
||||||
|
try? BGTaskScheduler.shared.submit(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handle(_ backgroundTask: BGAppRefreshTask) {
|
||||||
|
scheduleBackgroundRefresh()
|
||||||
|
pollingTask?.cancel()
|
||||||
|
let task = Task {
|
||||||
|
do {
|
||||||
|
let settings = await center.notificationSettings()
|
||||||
|
guard Self.isAuthorized(settings.authorizationStatus) else {
|
||||||
|
backgroundTask.setTaskCompleted(success: true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try await poll(deliverAlerts: true)
|
||||||
|
backgroundTask.setTaskCompleted(success: true)
|
||||||
|
} catch {
|
||||||
|
backgroundTask.setTaskCompleted(success: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pollingTask = task
|
||||||
|
backgroundTask.expirationHandler = { task.cancel() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func userNotificationCenter(
|
||||||
|
_ center: UNUserNotificationCenter,
|
||||||
|
willPresent notification: UNNotification
|
||||||
|
) async -> UNNotificationPresentationOptions {
|
||||||
|
[]
|
||||||
|
}
|
||||||
|
|
||||||
|
func userNotificationCenter(
|
||||||
|
_ center: UNUserNotificationCenter,
|
||||||
|
didReceive response: UNNotificationResponse
|
||||||
|
) async {
|
||||||
|
let userInfo = response.notification.request.content.userInfo
|
||||||
|
context.route(notificationUserInfo: userInfo)
|
||||||
|
guard
|
||||||
|
let serverId = userInfo["serverId"] as? String,
|
||||||
|
let id = userInfo["threadId"] as? NSNumber
|
||||||
|
else { return }
|
||||||
|
try? await context.core.markNotificationRead(serverId: serverId, id: id.int64Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isAuthorized(_ status: UNAuthorizationStatus) -> Bool {
|
||||||
|
status == .authorized || status == .provisional || status == .ephemeral
|
||||||
|
}
|
||||||
|
|
||||||
|
private func title(for target: ActivityTargetKind) -> String {
|
||||||
|
switch target {
|
||||||
|
case .repository: return "New repository notification"
|
||||||
|
case .issue: return "New issue notification"
|
||||||
|
case .pullRequest: return "New pull request notification"
|
||||||
|
case .commit: return "New commit notification"
|
||||||
|
case .none: return "New server notification"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func targetName(_ target: ActivityTargetKind) -> String {
|
||||||
|
switch target {
|
||||||
|
case .repository: return "repository"
|
||||||
|
case .issue: return "issue"
|
||||||
|
case .pullRequest: return "pull"
|
||||||
|
case .commit: return "commit"
|
||||||
|
case .none: return "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
179
ios/Sources/NotificationsScreen.swift
Normal file
179
ios/Sources/NotificationsScreen.swift
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
final class NotificationCell: UITableViewCell {
|
||||||
|
private let icon = UIImageView()
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let detailLabel = UILabel()
|
||||||
|
private let metaLabel = UILabel()
|
||||||
|
|
||||||
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
|
icon.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .headline)
|
||||||
|
icon.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
titleLabel.font = .preferredFont(forTextStyle: .headline)
|
||||||
|
titleLabel.numberOfLines = 2
|
||||||
|
detailLabel.font = .preferredFont(forTextStyle: .subheadline)
|
||||||
|
detailLabel.textColor = .secondaryLabel
|
||||||
|
detailLabel.numberOfLines = 2
|
||||||
|
metaLabel.font = .preferredFont(forTextStyle: .caption1)
|
||||||
|
metaLabel.textColor = .tertiaryLabel
|
||||||
|
[titleLabel, detailLabel, metaLabel].forEach {
|
||||||
|
$0.adjustsFontForContentSizeCategory = true
|
||||||
|
}
|
||||||
|
let labels = UIStackView(arrangedSubviews: [titleLabel, detailLabel, metaLabel])
|
||||||
|
labels.axis = .vertical
|
||||||
|
labels.spacing = 4
|
||||||
|
let stack = UIStackView(arrangedSubviews: [icon, labels])
|
||||||
|
stack.alignment = .top
|
||||||
|
stack.spacing = 12
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
contentView.addSubview(stack)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8),
|
||||||
|
stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10),
|
||||||
|
icon.widthAnchor.constraint(equalToConstant: 24),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
func configure(_ row: NotificationRow) {
|
||||||
|
icon.image = UIImage(systemName: symbolName(for: row.target))
|
||||||
|
icon.tintColor = row.unread ? .tintColor : .secondaryLabel
|
||||||
|
titleLabel.text = row.title
|
||||||
|
detailLabel.text = row.detail
|
||||||
|
metaLabel.text = row.meta
|
||||||
|
accessoryType = row.target == .none ? .none : .disclosureIndicator
|
||||||
|
selectionStyle = row.target == .none ? .none : .default
|
||||||
|
accessibilityValue = row.unread ? "Open" : "Closed"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func symbolName(for target: ActivityTargetKind) -> String {
|
||||||
|
switch target {
|
||||||
|
case .repository: return "books.vertical"
|
||||||
|
case .issue: return "exclamationmark.circle"
|
||||||
|
case .pullRequest: return "arrow.triangle.pull"
|
||||||
|
case .commit: return "point.topleft.down.to.point.bottomright.curvepath"
|
||||||
|
case .none: return "bell"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class NotificationsViewController: RefreshingTableViewController {
|
||||||
|
private let context: AppContext
|
||||||
|
private let statusControl = UISegmentedControl(items: ["Open", "Closed"])
|
||||||
|
private var rows: [NotificationRow] = []
|
||||||
|
private var currentPage: UInt32 = 0
|
||||||
|
|
||||||
|
init(context: AppContext) {
|
||||||
|
self.context = context
|
||||||
|
super.init()
|
||||||
|
setPanelTitle(self, "Notifications", server: context.core.activeServerName())
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
tableView.register(NotificationCell.self, forCellReuseIdentifier: "notification")
|
||||||
|
tableView.rowHeight = UITableView.automaticDimension
|
||||||
|
tableView.estimatedRowHeight = 92
|
||||||
|
statusControl.selectedSegmentIndex = 0
|
||||||
|
statusControl.addTarget(self, action: #selector(statusChanged), for: .valueChanged)
|
||||||
|
statusControl.accessibilityLabel = "Notification status"
|
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(customView: statusControl)
|
||||||
|
loadContent(refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
guard currentPage > 0 else { return }
|
||||||
|
loadNotifications(page: 1, refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadContent(refreshing: Bool) {
|
||||||
|
loadNotifications(page: 1, refreshing: refreshing)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadMoreContent() {
|
||||||
|
loadNotifications(page: currentPage + 1, refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadNotifications(page: UInt32, refreshing: Bool) {
|
||||||
|
if page == 1 {
|
||||||
|
resetPagination()
|
||||||
|
beginLoading(refreshing: refreshing)
|
||||||
|
}
|
||||||
|
loadingTask?.cancel()
|
||||||
|
loadingTask = Task {
|
||||||
|
do {
|
||||||
|
let result = try await context.core.notifications(
|
||||||
|
status: statusControl.selectedSegmentIndex == 0 ? .open : .closed,
|
||||||
|
page: page
|
||||||
|
)
|
||||||
|
if page == 1 { rows = result.rows } else { rows.append(contentsOf: result.rows) }
|
||||||
|
currentPage = page
|
||||||
|
finishPagination(hasMore: result.hasMore)
|
||||||
|
tableView.reloadData()
|
||||||
|
let status = statusControl.selectedSegmentIndex == 0 ? "open" : "closed"
|
||||||
|
tableView.backgroundView = rows.isEmpty
|
||||||
|
? EmptyBackgroundView(
|
||||||
|
title: "No \(status) notifications",
|
||||||
|
detail: "This server has no \(status) notifications."
|
||||||
|
)
|
||||||
|
: nil
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled {
|
||||||
|
show(error: error)
|
||||||
|
failPagination()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if page == 1 { endLoading() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
rows.count
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
cellForRowAt indexPath: IndexPath
|
||||||
|
) -> UITableViewCell {
|
||||||
|
let cell = tableView.dequeueReusableCell(
|
||||||
|
withIdentifier: "notification",
|
||||||
|
for: indexPath
|
||||||
|
) as! NotificationCell
|
||||||
|
cell.configure(rows[indexPath.row])
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
|
let row = rows[indexPath.row]
|
||||||
|
guard row.target != .none else { return }
|
||||||
|
guard row.unread else {
|
||||||
|
context.route(row)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loadingTask?.cancel()
|
||||||
|
loadingTask = Task {
|
||||||
|
do {
|
||||||
|
try await context.core.markNotificationRead(serverId: row.serverId, id: row.id)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
context.route(row)
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled { show(error: error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func statusChanged() {
|
||||||
|
loadNotifications(page: 1, refreshing: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
331
ios/Sources/PullScreens.swift
Normal file
331
ios/Sources/PullScreens.swift
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
final class PullCell: UITableViewCell {
|
||||||
|
private let stateIcon = UIImageView()
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let summaryLabel = UILabel()
|
||||||
|
private let metaLabel = UILabel()
|
||||||
|
|
||||||
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
|
accessoryType = .disclosureIndicator
|
||||||
|
titleLabel.font = .preferredFont(forTextStyle: .headline)
|
||||||
|
titleLabel.numberOfLines = 2
|
||||||
|
let titleStack = UIStackView(arrangedSubviews: [stateIcon, titleLabel])
|
||||||
|
titleStack.alignment = .firstBaseline
|
||||||
|
titleStack.spacing = 8
|
||||||
|
summaryLabel.font = .preferredFont(forTextStyle: .subheadline)
|
||||||
|
summaryLabel.textColor = .secondaryLabel
|
||||||
|
summaryLabel.numberOfLines = 2
|
||||||
|
metaLabel.font = .preferredFont(forTextStyle: .caption1)
|
||||||
|
metaLabel.textColor = .tertiaryLabel
|
||||||
|
metaLabel.numberOfLines = 2
|
||||||
|
[titleLabel, summaryLabel, metaLabel].forEach {
|
||||||
|
$0.adjustsFontForContentSizeCategory = true
|
||||||
|
}
|
||||||
|
let stack = UIStackView(arrangedSubviews: [titleStack, summaryLabel, metaLabel])
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = 6
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
contentView.addSubview(stack)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8),
|
||||||
|
stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
func configure(_ row: PullRow) {
|
||||||
|
configureOpenClosedStateIcon(
|
||||||
|
stateIcon,
|
||||||
|
state: row.state,
|
||||||
|
subject: "pull request",
|
||||||
|
textStyle: .headline
|
||||||
|
)
|
||||||
|
titleLabel.text = row.title
|
||||||
|
summaryLabel.text = row.summary
|
||||||
|
metaLabel.text = row.meta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@MainActor
|
||||||
|
final class PullsViewController: RefreshingTableViewController {
|
||||||
|
private let context: AppContext
|
||||||
|
private var rows: [PullRow] = []
|
||||||
|
private var filterOptions: PullFilterOptions?
|
||||||
|
private var filterTask: Task<Void, Never>?
|
||||||
|
private var currentPage: UInt32 = 0
|
||||||
|
|
||||||
|
init(context: AppContext) {
|
||||||
|
self.context = context
|
||||||
|
super.init()
|
||||||
|
setPanelTitle(self, "Pull Requests", server: context.core.activeServerName())
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
deinit { filterTask?.cancel() }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
tableView.register(PullCell.self, forCellReuseIdentifier: "pull")
|
||||||
|
tableView.rowHeight = UITableView.automaticDimension
|
||||||
|
tableView.estimatedRowHeight = 116
|
||||||
|
updateFilterMenu()
|
||||||
|
loadContent(refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
if navigationController?.viewControllers.first === self {
|
||||||
|
navigationItem.leftBarButtonItem = UIBarButtonItem(
|
||||||
|
image: context.symbol("server.rack"),
|
||||||
|
primaryAction: UIAction { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
self.navigationController?.pushViewController(
|
||||||
|
ServersViewController(context: self.context),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
navigationItem.leftBarButtonItem = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadContent(refreshing: Bool) {
|
||||||
|
guard context.core.activeServerIndex() != nil else {
|
||||||
|
tableView.backgroundView = EmptyBackgroundView(
|
||||||
|
title: "No server selected",
|
||||||
|
detail: "Open Issues or Repos to select a server or add your first one."
|
||||||
|
)
|
||||||
|
refreshControl?.endRefreshing()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loadFilterOptions()
|
||||||
|
loadPulls(refreshing: refreshing)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadPulls(refreshing: Bool) {
|
||||||
|
loadPulls(page: 1, refreshing: refreshing)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadMoreContent() {
|
||||||
|
loadPulls(page: currentPage + 1, refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadPulls(page: UInt32, refreshing: Bool) {
|
||||||
|
if page == 1 {
|
||||||
|
resetPagination()
|
||||||
|
beginLoading(refreshing: refreshing)
|
||||||
|
}
|
||||||
|
loadingTask?.cancel()
|
||||||
|
loadingTask = Task {
|
||||||
|
do {
|
||||||
|
let result = try await context.core.pulls(page: page)
|
||||||
|
if page == 1 { rows = result.rows } else { rows.append(contentsOf: result.rows) }
|
||||||
|
currentPage = page
|
||||||
|
finishPagination(hasMore: result.hasMore)
|
||||||
|
tableView.reloadData()
|
||||||
|
let status = context.core.settings().pullStatus
|
||||||
|
tableView.backgroundView = rows.isEmpty
|
||||||
|
? EmptyBackgroundView(
|
||||||
|
title: "No \(status) pull requests",
|
||||||
|
detail: filtersActive
|
||||||
|
? "No pull requests match the selected filters."
|
||||||
|
: "No pull requests match the selected status."
|
||||||
|
)
|
||||||
|
: nil
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled {
|
||||||
|
show(error: error)
|
||||||
|
failPagination()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if page == 1 { endLoading() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadFilterOptions() {
|
||||||
|
filterTask?.cancel()
|
||||||
|
filterTask = Task {
|
||||||
|
do {
|
||||||
|
filterOptions = try await context.core.pullFilters()
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
updateFilterMenu()
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled { show(error: error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
rows.count
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
cellForRowAt indexPath: IndexPath
|
||||||
|
) -> UITableViewCell {
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: "pull", for: indexPath) as! PullCell
|
||||||
|
cell.configure(rows[indexPath.row])
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
|
let row = rows[indexPath.row]
|
||||||
|
navigationController?.pushViewController(
|
||||||
|
PullViewController(
|
||||||
|
context: context,
|
||||||
|
owner: row.owner,
|
||||||
|
repository: row.repository,
|
||||||
|
number: row.number
|
||||||
|
),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateFilterMenu() {
|
||||||
|
let current = context.core.settings().pullStatus
|
||||||
|
let status = UIMenu(
|
||||||
|
title: "Status",
|
||||||
|
image: filterMenuImage("circle.lefthalf.filled", active: current != "open"),
|
||||||
|
options: .singleSelection,
|
||||||
|
children: ["open", "closed"].map { status in
|
||||||
|
UIAction(title: status.capitalized, state: current == status ? .on : .off) {
|
||||||
|
[weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
do {
|
||||||
|
try self.context.core.setPullStatus(status: status)
|
||||||
|
self.updateFilterMenu()
|
||||||
|
self.loadPulls(refreshing: false)
|
||||||
|
} catch {
|
||||||
|
self.show(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let milestone: UIMenuElement = filterOptions.map(milestoneMenu)
|
||||||
|
?? UIAction(title: "Loading milestones…", attributes: .disabled) { _ in }
|
||||||
|
let item = navigationItem.rightBarButtonItem ?? UIBarButtonItem(
|
||||||
|
image: context.symbol("line.3.horizontal.decrease.circle")
|
||||||
|
)
|
||||||
|
var children: [UIMenuElement] = [status]
|
||||||
|
if let filterOptions {
|
||||||
|
children.insert(searchAction(filterOptions), at: 0)
|
||||||
|
}
|
||||||
|
children.append(milestone)
|
||||||
|
children.append(clearFiltersMenu())
|
||||||
|
item.menu = UIMenu(children: children)
|
||||||
|
item.accessibilityLabel = "Filter pull requests"
|
||||||
|
navigationItem.rightBarButtonItem = item
|
||||||
|
updateFilterTint()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func searchAction(_ options: PullFilterOptions) -> UIAction {
|
||||||
|
UIAction(
|
||||||
|
title: "Search Text",
|
||||||
|
subtitle: options.searchText.isEmpty ? "Any text" : options.searchText,
|
||||||
|
image: filterMenuImage("magnifyingglass", active: !options.searchText.isEmpty)
|
||||||
|
) { [weak self] _ in
|
||||||
|
self?.promptForSearchText(
|
||||||
|
title: "Search Pull Requests",
|
||||||
|
current: options.searchText
|
||||||
|
) { [weak self] searchText in
|
||||||
|
self?.setSearchText(searchText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func milestoneMenu(_ options: PullFilterOptions) -> UIMenu {
|
||||||
|
let selected = options.selectedMilestone
|
||||||
|
let all = UIAction(title: "All Milestones", state: selected.isEmpty ? .on : .off) {
|
||||||
|
[weak self] _ in self?.selectMilestone("")
|
||||||
|
}
|
||||||
|
let milestones = options.milestones.map { milestone in
|
||||||
|
UIAction(title: milestone, state: selected == milestone ? .on : .off) {
|
||||||
|
[weak self] _ in self?.selectMilestone(milestone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return UIMenu(
|
||||||
|
title: "Milestone",
|
||||||
|
image: filterMenuImage("flag", active: !selected.isEmpty),
|
||||||
|
options: .singleSelection,
|
||||||
|
children: [all] + milestones
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func selectMilestone(_ milestone: String) {
|
||||||
|
guard let options = filterOptions else { return }
|
||||||
|
filterTask?.cancel()
|
||||||
|
do {
|
||||||
|
try context.core.setPullFilters(
|
||||||
|
milestone: milestone,
|
||||||
|
searchText: options.searchText
|
||||||
|
)
|
||||||
|
filterOptions?.selectedMilestone = milestone
|
||||||
|
updateFilterMenu()
|
||||||
|
loadPulls(refreshing: false)
|
||||||
|
} catch {
|
||||||
|
show(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setSearchText(_ searchText: String) {
|
||||||
|
guard let options = filterOptions else { return }
|
||||||
|
filterTask?.cancel()
|
||||||
|
do {
|
||||||
|
try context.core.setPullFilters(
|
||||||
|
milestone: options.selectedMilestone,
|
||||||
|
searchText: searchText
|
||||||
|
)
|
||||||
|
filterOptions?.searchText = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
updateFilterMenu()
|
||||||
|
loadPulls(refreshing: false)
|
||||||
|
} catch {
|
||||||
|
show(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearFiltersMenu() -> UIMenu {
|
||||||
|
UIMenu(
|
||||||
|
options: .displayInline,
|
||||||
|
children: [
|
||||||
|
UIAction(
|
||||||
|
title: "Clear Filters",
|
||||||
|
image: context.symbol("xmark.circle"),
|
||||||
|
attributes: filtersActive ? [] : .disabled
|
||||||
|
) { [weak self] _ in self?.clearFilters() },
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearFilters() {
|
||||||
|
filterTask?.cancel()
|
||||||
|
do {
|
||||||
|
try context.core.clearPullFilters()
|
||||||
|
filterOptions?.selectedMilestone = ""
|
||||||
|
filterOptions?.searchText = ""
|
||||||
|
updateFilterMenu()
|
||||||
|
loadPulls(refreshing: false)
|
||||||
|
} catch {
|
||||||
|
show(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var filtersActive: Bool {
|
||||||
|
(try? context.core.pullFiltersActive()) ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateFilterTint() {
|
||||||
|
navigationItem.rightBarButtonItem?.tintColor = filtersActive ? .tintColor : .secondaryLabel
|
||||||
|
navigationItem.rightBarButtonItem?.accessibilityValue = filtersActive
|
||||||
|
? "Filters active"
|
||||||
|
: "Default filters"
|
||||||
|
}
|
||||||
|
}
|
||||||
199
ios/Sources/RepositoryDirectoryScreen.swift
Normal file
199
ios/Sources/RepositoryDirectoryScreen.swift
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class RepositoryDirectoryViewController: RefreshingTableViewController {
|
||||||
|
private enum Mode: Int { case files, history }
|
||||||
|
|
||||||
|
private let context: AppContext
|
||||||
|
private let owner: String
|
||||||
|
private let repository: String
|
||||||
|
private let path: String
|
||||||
|
private var mode = Mode.files
|
||||||
|
private var rows: [RepositoryContentRow] = []
|
||||||
|
private var history: CommitPage?
|
||||||
|
private var currentPage: UInt32 = 0
|
||||||
|
private var loadedFiles = false
|
||||||
|
|
||||||
|
init(context: AppContext, owner: String, repository: String, path: String, name: String) {
|
||||||
|
self.context = context
|
||||||
|
self.owner = owner
|
||||||
|
self.repository = repository
|
||||||
|
self.path = path
|
||||||
|
super.init()
|
||||||
|
title = name
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
tableView.register(CommitCell.self, forCellReuseIdentifier: "commit")
|
||||||
|
navigationItem.prompt = title
|
||||||
|
let modeControl = UISegmentedControl(items: ["Files", "History"])
|
||||||
|
modeControl.selectedSegmentIndex = mode.rawValue
|
||||||
|
modeControl.addTarget(self, action: #selector(modeChanged(_:)), for: .valueChanged)
|
||||||
|
modeControl.accessibilityLabel = "Directory view"
|
||||||
|
navigationItem.titleView = modeControl
|
||||||
|
loadContent(refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadContent(refreshing: Bool) {
|
||||||
|
switch mode {
|
||||||
|
case .files: loadFiles(refreshing: refreshing)
|
||||||
|
case .history: loadHistory(page: 1, refreshing: refreshing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadMoreContent() {
|
||||||
|
guard mode == .history else { return }
|
||||||
|
loadHistory(page: currentPage + 1, refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadFiles(refreshing: Bool) {
|
||||||
|
resetPagination()
|
||||||
|
beginLoading(refreshing: refreshing)
|
||||||
|
loadingTask?.cancel()
|
||||||
|
loadingTask = Task {
|
||||||
|
do {
|
||||||
|
let rows = try await context.core.repositoryContents(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
path: path
|
||||||
|
)
|
||||||
|
guard !Task.isCancelled, mode == .files else {
|
||||||
|
endLoading()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.rows = rows
|
||||||
|
loadedFiles = true
|
||||||
|
tableView.reloadData()
|
||||||
|
updateEmptyView()
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled { show(error: error) }
|
||||||
|
}
|
||||||
|
endLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadHistory(page requestedPage: UInt32, refreshing: Bool) {
|
||||||
|
if requestedPage == 1 {
|
||||||
|
resetPagination()
|
||||||
|
beginLoading(refreshing: refreshing)
|
||||||
|
}
|
||||||
|
loadingTask?.cancel()
|
||||||
|
loadingTask = Task {
|
||||||
|
do {
|
||||||
|
let history = try await context.core.commits(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
branch: nil,
|
||||||
|
path: path,
|
||||||
|
pages: requestedPage
|
||||||
|
)
|
||||||
|
guard !Task.isCancelled, mode == .history else {
|
||||||
|
if requestedPage == 1 { endLoading() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.history = history
|
||||||
|
currentPage = requestedPage
|
||||||
|
finishPagination(hasMore: history.hasMore)
|
||||||
|
tableView.reloadData()
|
||||||
|
updateEmptyView()
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled {
|
||||||
|
show(error: error)
|
||||||
|
failPagination()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if requestedPage == 1 { endLoading() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
mode == .files ? rows.count : history?.commits.count ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
cellForRowAt indexPath: IndexPath
|
||||||
|
) -> UITableViewCell {
|
||||||
|
switch mode {
|
||||||
|
case .files:
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: "repository-content")
|
||||||
|
?? UITableViewCell(style: .subtitle, reuseIdentifier: "repository-content")
|
||||||
|
configureRepositoryContentCell(cell, row: rows[indexPath.row])
|
||||||
|
return cell
|
||||||
|
case .history:
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: "commit", for: indexPath) as! CommitCell
|
||||||
|
if let history {
|
||||||
|
cell.configure(history.commits[indexPath.row], laneCount: history.laneCount)
|
||||||
|
}
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||||
|
mode == .history ? 86 : UITableView.automaticDimension
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
|
switch mode {
|
||||||
|
case .files:
|
||||||
|
showRepositoryContent(
|
||||||
|
rows[indexPath.row],
|
||||||
|
context: context,
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
navigationController: navigationController
|
||||||
|
)
|
||||||
|
case .history:
|
||||||
|
guard let commit = history?.commits[indexPath.row] else { return }
|
||||||
|
navigationController?.pushViewController(
|
||||||
|
FilesViewController(
|
||||||
|
context: context,
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
sha: commit.sha,
|
||||||
|
branch: commit.branchLabel
|
||||||
|
),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func modeChanged(_ sender: UISegmentedControl) {
|
||||||
|
guard let mode = Mode(rawValue: sender.selectedSegmentIndex), mode != self.mode else { return }
|
||||||
|
loadingTask?.cancel()
|
||||||
|
endLoading()
|
||||||
|
self.mode = mode
|
||||||
|
resetPagination()
|
||||||
|
tableView.backgroundView = nil
|
||||||
|
tableView.reloadData()
|
||||||
|
switch mode {
|
||||||
|
case .files:
|
||||||
|
if loadedFiles { updateEmptyView() } else { loadFiles(refreshing: false) }
|
||||||
|
case .history:
|
||||||
|
if let history {
|
||||||
|
finishPagination(hasMore: history.hasMore)
|
||||||
|
updateEmptyView()
|
||||||
|
} else {
|
||||||
|
loadHistory(page: 1, refreshing: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateEmptyView() {
|
||||||
|
switch mode {
|
||||||
|
case .files:
|
||||||
|
tableView.backgroundView = loadedFiles && rows.isEmpty
|
||||||
|
? EmptyBackgroundView(title: "Empty folder", detail: "This folder does not contain any files.")
|
||||||
|
: nil
|
||||||
|
case .history:
|
||||||
|
tableView.backgroundView = history?.commits.isEmpty == true
|
||||||
|
? EmptyBackgroundView(title: "No commits", detail: "No commits affect this folder.")
|
||||||
|
: nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
383
ios/Sources/RepositoryFileScreens.swift
Normal file
383
ios/Sources/RepositoryFileScreens.swift
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
import Highlighter
|
||||||
|
import QuickLook
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class RepositoryHistoryViewController: RefreshingTableViewController {
|
||||||
|
private let context: AppContext
|
||||||
|
private let owner: String
|
||||||
|
private let repository: String
|
||||||
|
private let path: String
|
||||||
|
private let emptyDetail: String
|
||||||
|
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
|
||||||
|
private var page: CommitPage?
|
||||||
|
private var currentPage: UInt32 = 0
|
||||||
|
|
||||||
|
init(
|
||||||
|
context: AppContext,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
path: String,
|
||||||
|
emptyDetail: String
|
||||||
|
) {
|
||||||
|
self.context = context
|
||||||
|
self.owner = owner
|
||||||
|
self.repository = repository
|
||||||
|
self.path = path
|
||||||
|
self.emptyDetail = emptyDetail
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
tableView.register(CommitCell.self, forCellReuseIdentifier: "commit")
|
||||||
|
loadContent(refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadContent(refreshing: Bool) {
|
||||||
|
loadPage(1, refreshing: refreshing)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadMoreContent() {
|
||||||
|
loadPage(currentPage + 1, refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadPage(_ requestedPage: UInt32, refreshing: Bool) {
|
||||||
|
if requestedPage == 1 {
|
||||||
|
resetPagination()
|
||||||
|
if !refreshing {
|
||||||
|
loadingIndicator.startAnimating()
|
||||||
|
tableView.backgroundView = loadingIndicator
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadingTask?.cancel()
|
||||||
|
loadingTask = Task {
|
||||||
|
do {
|
||||||
|
let page = try await context.core.commits(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
branch: nil,
|
||||||
|
path: path,
|
||||||
|
pages: requestedPage
|
||||||
|
)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
self.page = page
|
||||||
|
currentPage = requestedPage
|
||||||
|
finishPagination(hasMore: page.hasMore)
|
||||||
|
tableView.reloadData()
|
||||||
|
updateEmptyView()
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled {
|
||||||
|
show(error: error)
|
||||||
|
failPagination()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if requestedPage == 1 {
|
||||||
|
loadingIndicator.stopAnimating()
|
||||||
|
refreshControl?.endRefreshing()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
page?.commits.count ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
cellForRowAt indexPath: IndexPath
|
||||||
|
) -> UITableViewCell {
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: "commit", for: indexPath) as! CommitCell
|
||||||
|
if let page { cell.configure(page.commits[indexPath.row], laneCount: page.laneCount) }
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||||
|
86
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
|
guard let commit = page?.commits[indexPath.row] else { return }
|
||||||
|
navigationController?.pushViewController(
|
||||||
|
FilesViewController(
|
||||||
|
context: context,
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
sha: commit.sha,
|
||||||
|
branch: commit.branchLabel
|
||||||
|
),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateEmptyView() {
|
||||||
|
tableView.backgroundView = page?.commits.isEmpty == true
|
||||||
|
? EmptyBackgroundView(title: "No commits", detail: emptyDetail)
|
||||||
|
: nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class CodeScrollView: UIScrollView {
|
||||||
|
private let textView = UITextView(frame: .zero)
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
alwaysBounceVertical = true
|
||||||
|
alwaysBounceHorizontal = true
|
||||||
|
isDirectionalLockEnabled = true
|
||||||
|
showsHorizontalScrollIndicator = true
|
||||||
|
contentInsetAdjustmentBehavior = .never
|
||||||
|
textView.textContainer.lineBreakMode = .byClipping
|
||||||
|
textView.textContainer.widthTracksTextView = false
|
||||||
|
textView.isEditable = false
|
||||||
|
textView.isScrollEnabled = false
|
||||||
|
textView.isSelectable = true
|
||||||
|
addSubview(textView)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
func display(_ text: NSAttributedString) {
|
||||||
|
let text = NSMutableAttributedString(attributedString: text)
|
||||||
|
let paragraph = NSMutableParagraphStyle()
|
||||||
|
paragraph.lineBreakMode = .byClipping
|
||||||
|
text.addAttribute(.paragraphStyle, value: paragraph, range: NSRange(location: 0, length: text.length))
|
||||||
|
textView.textStorage.setAttributedString(text)
|
||||||
|
setNeedsLayout()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
textView.textContainer.size = CGSize(
|
||||||
|
width: CGFloat.greatestFiniteMagnitude,
|
||||||
|
height: CGFloat.greatestFiniteMagnitude
|
||||||
|
)
|
||||||
|
textView.layoutManager.ensureLayout(for: textView.textContainer)
|
||||||
|
let textSize = textView.layoutManager.usedRect(for: textView.textContainer).size
|
||||||
|
let size = CGSize(
|
||||||
|
width: max(
|
||||||
|
bounds.width,
|
||||||
|
ceil(textSize.width + textView.textContainerInset.left + textView.textContainerInset.right)
|
||||||
|
),
|
||||||
|
height: max(
|
||||||
|
bounds.height,
|
||||||
|
ceil(textSize.height + textView.textContainerInset.top + textView.textContainerInset.bottom)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
textView.frame = CGRect(origin: .zero, size: size)
|
||||||
|
contentSize = size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class RepositoryFileViewController: UIViewController, QLPreviewControllerDataSource {
|
||||||
|
private enum Mode: String {
|
||||||
|
case preview = "Preview"
|
||||||
|
case source = "Source"
|
||||||
|
case content = "Content"
|
||||||
|
case history = "History"
|
||||||
|
}
|
||||||
|
|
||||||
|
private let context: AppContext
|
||||||
|
private let owner: String
|
||||||
|
private let repository: String
|
||||||
|
private let path: String
|
||||||
|
private let spinner = UIActivityIndicatorView(style: .medium)
|
||||||
|
private let modeControl = UISegmentedControl()
|
||||||
|
private var loadingTask: Task<Void, Never>?
|
||||||
|
private var page: RepositoryFilePage?
|
||||||
|
private var previewURL: URL?
|
||||||
|
private var fileLanguage: String?
|
||||||
|
private var historyController: RepositoryHistoryViewController?
|
||||||
|
private var displayedController: UIViewController?
|
||||||
|
private var displayedView: UIView?
|
||||||
|
private var modes: [Mode] = []
|
||||||
|
|
||||||
|
init(context: AppContext, owner: String, repository: String, path: String, name: String) {
|
||||||
|
self.context = context
|
||||||
|
self.owner = owner
|
||||||
|
self.repository = repository
|
||||||
|
self.path = path
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
title = name
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
view.backgroundColor = .systemBackground
|
||||||
|
beginNavigationLoading(spinner)
|
||||||
|
loadingTask = Task {
|
||||||
|
do {
|
||||||
|
let page = try await context.core.repositoryFile(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
path: path
|
||||||
|
)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
self.page = page
|
||||||
|
title = page.name
|
||||||
|
fileLanguage = page.language.isEmpty ? nil : page.language
|
||||||
|
configureModes(for: page)
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled { show(error: error) }
|
||||||
|
}
|
||||||
|
endNavigationLoading(spinner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
loadingTask?.cancel()
|
||||||
|
if let previewURL { try? FileManager.default.removeItem(at: previewURL) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
|
||||||
|
previewURL == nil ? 0 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
|
||||||
|
previewURL! as NSURL
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureModes(for page: RepositoryFilePage) {
|
||||||
|
navigationItem.prompt = title
|
||||||
|
modeControl.removeAllSegments()
|
||||||
|
modes = page.kind == .markdown
|
||||||
|
? [.preview, .source, .history]
|
||||||
|
: [.content, .history]
|
||||||
|
for (index, mode) in modes.enumerated() {
|
||||||
|
modeControl.insertSegment(withTitle: mode.rawValue, at: index, animated: false)
|
||||||
|
}
|
||||||
|
modeControl.selectedSegmentIndex = 0
|
||||||
|
modeControl.addTarget(self, action: #selector(fileModeChanged(_:)), for: .valueChanged)
|
||||||
|
modeControl.accessibilityLabel = "File view"
|
||||||
|
navigationItem.titleView = modeControl
|
||||||
|
showContent(page, mode: modes[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func fileModeChanged(_ sender: UISegmentedControl) {
|
||||||
|
guard let page, modes.indices.contains(sender.selectedSegmentIndex) else { return }
|
||||||
|
showContent(page, mode: modes[sender.selectedSegmentIndex])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showContent(_ page: RepositoryFilePage, mode: Mode) {
|
||||||
|
switch mode {
|
||||||
|
case .preview: showMarkdownPreview(page.text)
|
||||||
|
case .source: showSource(page.text)
|
||||||
|
case .content:
|
||||||
|
if page.kind == .source {
|
||||||
|
showSource(page.text)
|
||||||
|
} else {
|
||||||
|
do {
|
||||||
|
try showQuickLook(page.data, name: page.name)
|
||||||
|
} catch {
|
||||||
|
show(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .history: showHistory()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showHistory() {
|
||||||
|
removeDisplayedView()
|
||||||
|
let history = historyController ?? RepositoryHistoryViewController(
|
||||||
|
context: context,
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
path: path,
|
||||||
|
emptyDetail: "No commits affect this file."
|
||||||
|
)
|
||||||
|
historyController = history
|
||||||
|
addChild(history)
|
||||||
|
history.view.frame = view.bounds
|
||||||
|
history.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||||
|
displayedController = history
|
||||||
|
displayedView = history.view
|
||||||
|
view.addSubview(history.view)
|
||||||
|
history.didMove(toParent: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showMarkdownPreview(_ source: String) {
|
||||||
|
removeDisplayedView()
|
||||||
|
let preview = UIHostingController(rootView: RepositoryMarkdownPreview(source: source))
|
||||||
|
addChild(preview)
|
||||||
|
preview.view.frame = view.bounds
|
||||||
|
preview.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||||
|
displayedController = preview
|
||||||
|
displayedView = preview.view
|
||||||
|
view.addSubview(preview.view)
|
||||||
|
preview.didMove(toParent: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showSource(_ source: String) {
|
||||||
|
removeDisplayedView()
|
||||||
|
let highlighter = Highlighter()
|
||||||
|
highlighter?.setTheme(
|
||||||
|
traitCollection.userInterfaceStyle == .dark ? "atom-one-dark" : "atom-one-light"
|
||||||
|
)
|
||||||
|
highlighter?.theme.setCodeFont(.monospacedSystemFont(ofSize: 13, weight: .regular))
|
||||||
|
let highlighted = highlighter?.highlight(source, as: fileLanguage)
|
||||||
|
?? NSAttributedString(string: source, attributes: [
|
||||||
|
.font: UIFont.monospacedSystemFont(ofSize: 13, weight: .regular),
|
||||||
|
.foregroundColor: UIColor.label,
|
||||||
|
])
|
||||||
|
let codeView = CodeScrollView()
|
||||||
|
codeView.display(highlighted)
|
||||||
|
displayedView = codeView
|
||||||
|
view.addSubview(codeView)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
codeView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
||||||
|
codeView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
||||||
|
codeView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||||
|
codeView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeDisplayedView() {
|
||||||
|
displayedController?.willMove(toParent: nil)
|
||||||
|
displayedView?.removeFromSuperview()
|
||||||
|
displayedController?.removeFromParent()
|
||||||
|
displayedController = nil
|
||||||
|
displayedView = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showQuickLook(_ data: Data, name: String) throws {
|
||||||
|
removeDisplayedView()
|
||||||
|
if previewURL == nil {
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("\(UUID().uuidString)-\(name)")
|
||||||
|
try data.write(to: url, options: .atomic)
|
||||||
|
previewURL = url
|
||||||
|
}
|
||||||
|
let preview = QLPreviewController()
|
||||||
|
preview.dataSource = self
|
||||||
|
addChild(preview)
|
||||||
|
preview.view.frame = view.bounds
|
||||||
|
preview.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||||
|
displayedController = preview
|
||||||
|
displayedView = preview.view
|
||||||
|
view.addSubview(preview.view)
|
||||||
|
preview.didMove(toParent: self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RepositoryMarkdownPreview: View {
|
||||||
|
let source: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
MarkdownContent(source: source)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.background(Color(uiColor: .systemBackground))
|
||||||
|
}
|
||||||
|
}
|
||||||
161
ios/Sources/RepositoryListScreen.swift
Normal file
161
ios/Sources/RepositoryListScreen.swift
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class RepositoriesViewController: RefreshingTableViewController {
|
||||||
|
private let context: AppContext
|
||||||
|
private let mode: RepositoryPane
|
||||||
|
private var rows: [RepositoryRow] = []
|
||||||
|
private var currentPage: UInt32 = 0
|
||||||
|
|
||||||
|
private var panelTitle: String {
|
||||||
|
switch mode {
|
||||||
|
case .issues: return "Issues"
|
||||||
|
case .commits: return "Repositories"
|
||||||
|
case .milestones: return "Milestones"
|
||||||
|
case .actions: return "Actions"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init(context: AppContext, mode: RepositoryPane) {
|
||||||
|
self.context = context
|
||||||
|
self.mode = mode
|
||||||
|
super.init()
|
||||||
|
setPanelTitle(self, panelTitle, server: context.core.activeServerName())
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
loadContent(refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
if navigationController?.viewControllers.first === self {
|
||||||
|
navigationItem.leftBarButtonItem = UIBarButtonItem(
|
||||||
|
image: context.symbol("server.rack"),
|
||||||
|
primaryAction: UIAction { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
self.navigationController?.pushViewController(
|
||||||
|
ServersViewController(context: self.context),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
navigationItem.leftBarButtonItem = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadContent(refreshing: Bool) {
|
||||||
|
loadPage(1, refreshing: refreshing)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadMoreContent() {
|
||||||
|
loadPage(currentPage + 1, refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadPage(_ page: UInt32, refreshing: Bool) {
|
||||||
|
if page == 1 {
|
||||||
|
resetPagination()
|
||||||
|
beginLoading(refreshing: refreshing)
|
||||||
|
}
|
||||||
|
loadingTask?.cancel()
|
||||||
|
loadingTask = Task {
|
||||||
|
do {
|
||||||
|
let result = try await context.core.repositories(page: page, pane: mode)
|
||||||
|
rows = result.rows
|
||||||
|
currentPage = page
|
||||||
|
finishPagination(hasMore: result.hasMore)
|
||||||
|
tableView.reloadData()
|
||||||
|
tableView.backgroundView = rows.isEmpty
|
||||||
|
? EmptyBackgroundView(
|
||||||
|
title: "No repositories",
|
||||||
|
detail: "This account does not own any repositories on this server."
|
||||||
|
)
|
||||||
|
: nil
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled {
|
||||||
|
show(error: error)
|
||||||
|
failPagination()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if page == 1 { endLoading() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
rows.count
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
cellForRowAt indexPath: IndexPath
|
||||||
|
) -> UITableViewCell {
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: "repository")
|
||||||
|
?? UITableViewCell(style: .subtitle, reuseIdentifier: "repository")
|
||||||
|
let row = rows[indexPath.row]
|
||||||
|
configureTextCell(cell, title: row.name, detail: "\(row.description)\n\(row.meta)")
|
||||||
|
let button = UIButton(type: .system, primaryAction: UIAction { [weak self] _ in
|
||||||
|
self?.toggleFavorite(row)
|
||||||
|
})
|
||||||
|
button.setImage(context.symbol(row.favorite ? "star.fill" : "star"), for: .normal)
|
||||||
|
button.tintColor = row.favorite ? .systemYellow : .tertiaryLabel
|
||||||
|
button.frame.size = CGSize(width: 44, height: 44)
|
||||||
|
cell.accessoryView = button
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||||
|
96
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
|
let row = rows[indexPath.row]
|
||||||
|
let destination: UIViewController
|
||||||
|
switch mode {
|
||||||
|
case .issues:
|
||||||
|
destination = IssuesViewController(
|
||||||
|
context: context,
|
||||||
|
owner: row.owner,
|
||||||
|
repository: row.name
|
||||||
|
)
|
||||||
|
case .commits:
|
||||||
|
destination = CommitsViewController(
|
||||||
|
context: context,
|
||||||
|
owner: row.owner,
|
||||||
|
repository: row.name
|
||||||
|
)
|
||||||
|
case .milestones:
|
||||||
|
destination = MilestonesViewController(
|
||||||
|
context: context,
|
||||||
|
owner: row.owner,
|
||||||
|
repository: row.name
|
||||||
|
)
|
||||||
|
case .actions:
|
||||||
|
destination = ActionsViewController(
|
||||||
|
context: context,
|
||||||
|
owner: row.owner,
|
||||||
|
repository: row.name,
|
||||||
|
defaultBranch: row.defaultBranch
|
||||||
|
)
|
||||||
|
}
|
||||||
|
navigationController?.pushViewController(destination, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toggleFavorite(_ row: RepositoryRow) {
|
||||||
|
do {
|
||||||
|
rows = try context.core.toggleFavorite(
|
||||||
|
owner: row.owner,
|
||||||
|
repository: row.name,
|
||||||
|
pane: mode
|
||||||
|
)
|
||||||
|
tableView.reloadData()
|
||||||
|
} catch {
|
||||||
|
show(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
104
ios/Sources/ServerActivityScreen.swift
Normal file
104
ios/Sources/ServerActivityScreen.swift
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ServerActivityViewController: RefreshingTableViewController {
|
||||||
|
private let context: AppContext
|
||||||
|
private var rows: [ActivityRow] = []
|
||||||
|
private var nextPage: UInt32?
|
||||||
|
private var loaded = false
|
||||||
|
|
||||||
|
init(context: AppContext) {
|
||||||
|
self.context = context
|
||||||
|
super.init()
|
||||||
|
setPanelTitle(self, "Server Activity", server: context.core.activeServerName())
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
guard !loaded else { return }
|
||||||
|
loadContent(refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadContent(refreshing: Bool) {
|
||||||
|
loadPage(1, refreshing: refreshing)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadMoreContent() {
|
||||||
|
guard let nextPage else { return }
|
||||||
|
loadPage(nextPage, refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadPage(_ requestedPage: UInt32, refreshing: Bool) {
|
||||||
|
if requestedPage == 1 {
|
||||||
|
resetPagination()
|
||||||
|
beginLoading(refreshing: refreshing)
|
||||||
|
}
|
||||||
|
loadingTask?.cancel()
|
||||||
|
loadingTask = Task {
|
||||||
|
do {
|
||||||
|
let page = try await context.core.serverActivity(page: requestedPage)
|
||||||
|
if requestedPage == 1 {
|
||||||
|
rows = page.rows
|
||||||
|
loaded = true
|
||||||
|
} else {
|
||||||
|
rows.append(contentsOf: page.rows)
|
||||||
|
}
|
||||||
|
nextPage = page.hasMore ? requestedPage + 1 : nil
|
||||||
|
finishPagination(hasMore: page.hasMore)
|
||||||
|
updateRows()
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled {
|
||||||
|
show(error: error)
|
||||||
|
failPagination()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if requestedPage == 1 { endLoading() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
rows.count
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
cellForRowAt indexPath: IndexPath
|
||||||
|
) -> UITableViewCell {
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: "server-activity")
|
||||||
|
?? UITableViewCell(style: .subtitle, reuseIdentifier: "server-activity")
|
||||||
|
let row = rows[indexPath.row]
|
||||||
|
configureTextCell(
|
||||||
|
cell,
|
||||||
|
title: row.title,
|
||||||
|
detail: "\(row.detail)\n\(row.meta)",
|
||||||
|
image: context.symbol(activitySymbolName(for: row.icon))
|
||||||
|
)
|
||||||
|
cell.accessoryType = row.target == .none ? .none : .disclosureIndicator
|
||||||
|
cell.selectionStyle = row.target == .none ? .none : .default
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||||
|
92
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
let row = rows[indexPath.row]
|
||||||
|
guard row.target != .none else { return }
|
||||||
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
|
context.route(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateRows() {
|
||||||
|
tableView.reloadData()
|
||||||
|
tableView.backgroundView = rows.isEmpty
|
||||||
|
? EmptyBackgroundView(
|
||||||
|
title: "No server activity",
|
||||||
|
detail: "No activity is visible to this server account."
|
||||||
|
)
|
||||||
|
: nil
|
||||||
|
}
|
||||||
|
}
|
||||||
311
ios/Sources/ServerScreens.swift
Normal file
311
ios/Sources/ServerScreens.swift
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ServersViewController: UITableViewController {
|
||||||
|
private let context: AppContext
|
||||||
|
private var servers: [ServerRow] = []
|
||||||
|
private var mutationTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
init(context: AppContext) {
|
||||||
|
self.context = context
|
||||||
|
super.init(style: .plain)
|
||||||
|
title = "Servers"
|
||||||
|
tableView.backgroundColor = .systemGroupedBackground
|
||||||
|
tableView.separatorInset = .zero
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
mutationTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
reloadServers()
|
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
|
systemItem: .add,
|
||||||
|
primaryAction: UIAction { [weak self] _ in self?.showServerEditor() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
servers.count
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
cellForRowAt indexPath: IndexPath
|
||||||
|
) -> UITableViewCell {
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: "server")
|
||||||
|
?? UITableViewCell(style: .subtitle, reuseIdentifier: "server")
|
||||||
|
let server = servers[indexPath.row]
|
||||||
|
configureTextCell(cell, title: server.name, detail: server.url)
|
||||||
|
cell.accessoryType = .disclosureIndicator
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
|
do {
|
||||||
|
try context.selectServer(index: UInt32(indexPath.row))
|
||||||
|
} catch {
|
||||||
|
show(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath
|
||||||
|
) -> UISwipeActionsConfiguration? {
|
||||||
|
let server = servers[indexPath.row]
|
||||||
|
let deleteAction = UIContextualAction(style: .destructive, title: "Delete") {
|
||||||
|
[weak self] _, _, completion in
|
||||||
|
self?.confirmDelete(server, index: indexPath.row, completion: completion)
|
||||||
|
?? completion(false)
|
||||||
|
}
|
||||||
|
deleteAction.image = context.symbol("trash")
|
||||||
|
|
||||||
|
let editAction = UIContextualAction(style: .normal, title: "Edit") {
|
||||||
|
[weak self] _, _, completion in
|
||||||
|
self?.showServerEditor(index: indexPath.row, completion: completion)
|
||||||
|
?? completion(false)
|
||||||
|
}
|
||||||
|
editAction.image = context.symbol("pencil")
|
||||||
|
|
||||||
|
let configuration = UISwipeActionsConfiguration(actions: [deleteAction, editAction])
|
||||||
|
configuration.performsFirstActionWithFullSwipe = true
|
||||||
|
return configuration
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reloadServers() {
|
||||||
|
servers = context.core.servers()
|
||||||
|
tableView.reloadData()
|
||||||
|
tableView.backgroundView = servers.isEmpty
|
||||||
|
? EmptyBackgroundView(title: "No servers", detail: "Add a code hosting server to get started.")
|
||||||
|
: nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showServerEditor(
|
||||||
|
index: Int? = nil,
|
||||||
|
completion swipeCompletion: ((Bool) -> Void)? = nil
|
||||||
|
) {
|
||||||
|
do {
|
||||||
|
let editor = try index.map { try context.core.serverEditor(index: UInt32($0)) }
|
||||||
|
let controller = ServerEditorViewController(
|
||||||
|
context: context,
|
||||||
|
index: index.map(UInt32.init),
|
||||||
|
editor: editor
|
||||||
|
) { [weak self] in
|
||||||
|
self?.reloadServers()
|
||||||
|
}
|
||||||
|
present(UINavigationController(rootViewController: controller), animated: true) {
|
||||||
|
swipeCompletion?(true)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
swipeCompletion?(false)
|
||||||
|
show(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func confirmDelete(
|
||||||
|
_ server: ServerRow,
|
||||||
|
index: Int,
|
||||||
|
completion: @escaping (Bool) -> Void
|
||||||
|
) {
|
||||||
|
let alert = UIAlertController(
|
||||||
|
title: "Delete “\(server.name)”?",
|
||||||
|
message: "This removes the server configuration and access token from this device. It doesn’t change anything on the server.",
|
||||||
|
preferredStyle: .alert
|
||||||
|
)
|
||||||
|
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in completion(false) })
|
||||||
|
alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in
|
||||||
|
guard let self else {
|
||||||
|
completion(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.mutationTask?.cancel()
|
||||||
|
self.mutationTask = Task {
|
||||||
|
do {
|
||||||
|
try await self.context.core.deleteServer(index: UInt32(index))
|
||||||
|
guard !Task.isCancelled else {
|
||||||
|
completion(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
completion(true)
|
||||||
|
self.reloadServers()
|
||||||
|
self.context.reloadAfterServerChange()
|
||||||
|
} catch {
|
||||||
|
completion(false)
|
||||||
|
if !Task.isCancelled { self.show(error: error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
present(alert, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@MainActor
|
||||||
|
final class ServerEditorViewController: UITableViewController, UITextFieldDelegate {
|
||||||
|
private let context: AppContext
|
||||||
|
private let index: UInt32?
|
||||||
|
private let editor: ServerEditor?
|
||||||
|
private let completion: () -> Void
|
||||||
|
private let nameField = UITextField()
|
||||||
|
private let urlField = UITextField()
|
||||||
|
private let tokenField = UITextField()
|
||||||
|
private let providerButton = UIButton(type: .system)
|
||||||
|
private var provider = ServerProvider.gitea
|
||||||
|
private var saveButton: UIBarButtonItem!
|
||||||
|
|
||||||
|
init(
|
||||||
|
context: AppContext,
|
||||||
|
index: UInt32?,
|
||||||
|
editor: ServerEditor?,
|
||||||
|
completion: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
self.context = context
|
||||||
|
self.index = index
|
||||||
|
self.editor = editor
|
||||||
|
self.completion = completion
|
||||||
|
provider = editor?.provider ?? .gitea
|
||||||
|
super.init(style: .insetGrouped)
|
||||||
|
title = index == nil ? "Add Server" : "Edit Server"
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
navigationItem.leftBarButtonItem = UIBarButtonItem(
|
||||||
|
systemItem: .cancel,
|
||||||
|
primaryAction: UIAction { [weak self] _ in self?.dismiss(animated: true) }
|
||||||
|
)
|
||||||
|
saveButton = UIBarButtonItem(
|
||||||
|
title: index == nil ? "Add" : "Save",
|
||||||
|
style: .done,
|
||||||
|
target: self,
|
||||||
|
action: #selector(save)
|
||||||
|
)
|
||||||
|
navigationItem.rightBarButtonItem = saveButton
|
||||||
|
configure(nameField, placeholder: "Work", contentType: .name)
|
||||||
|
configure(urlField, placeholder: "https://gitea.example.com", contentType: .URL)
|
||||||
|
urlField.keyboardType = .URL
|
||||||
|
urlField.autocapitalizationType = .none
|
||||||
|
configure(tokenField, placeholder: "Access token", contentType: nil)
|
||||||
|
tokenField.isSecureTextEntry = true
|
||||||
|
tokenField.autocapitalizationType = .none
|
||||||
|
tokenField.returnKeyType = .done
|
||||||
|
configureProviderButton()
|
||||||
|
nameField.text = editor?.name
|
||||||
|
urlField.text = editor?.url
|
||||||
|
if editor != nil {
|
||||||
|
tokenField.placeholder = "Leave unchanged"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func numberOfSections(in tableView: UITableView) -> Int { 4 }
|
||||||
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 1 }
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||||
|
["API provider", "Name", "Server URL", "Access token"][section]
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
cellForRowAt indexPath: IndexPath
|
||||||
|
) -> UITableViewCell {
|
||||||
|
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
|
||||||
|
let control: UIView = indexPath.section == 0
|
||||||
|
? providerButton
|
||||||
|
: [nameField, urlField, tokenField][indexPath.section - 1]
|
||||||
|
control.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
cell.contentView.addSubview(control)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
control.leadingAnchor.constraint(equalTo: cell.contentView.leadingAnchor, constant: 16),
|
||||||
|
control.trailingAnchor.constraint(equalTo: cell.contentView.trailingAnchor, constant: -16),
|
||||||
|
control.topAnchor.constraint(equalTo: cell.contentView.topAnchor),
|
||||||
|
control.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor),
|
||||||
|
cell.contentView.heightAnchor.constraint(greaterThanOrEqualToConstant: 48),
|
||||||
|
])
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||||
|
if textField === nameField { urlField.becomeFirstResponder() }
|
||||||
|
else if textField === urlField { tokenField.becomeFirstResponder() }
|
||||||
|
else { save() }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func save() {
|
||||||
|
view.endEditing(true)
|
||||||
|
saveButton.isEnabled = false
|
||||||
|
let spinner = UIActivityIndicatorView(style: .medium)
|
||||||
|
spinner.startAnimating()
|
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(customView: spinner)
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
if let index {
|
||||||
|
try await context.core.updateServer(
|
||||||
|
index: index,
|
||||||
|
name: nameField.text ?? "",
|
||||||
|
url: urlField.text ?? "",
|
||||||
|
token: tokenField.text ?? "",
|
||||||
|
provider: provider
|
||||||
|
)
|
||||||
|
context.reloadAfterServerChange()
|
||||||
|
} else {
|
||||||
|
let index = try await context.core.addServer(
|
||||||
|
name: nameField.text ?? "",
|
||||||
|
url: urlField.text ?? "",
|
||||||
|
token: tokenField.text ?? "",
|
||||||
|
provider: provider
|
||||||
|
)
|
||||||
|
try context.didAddServer(index: index)
|
||||||
|
}
|
||||||
|
completion()
|
||||||
|
dismiss(animated: true)
|
||||||
|
} catch {
|
||||||
|
navigationItem.rightBarButtonItem = saveButton
|
||||||
|
saveButton.isEnabled = true
|
||||||
|
show(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configure(
|
||||||
|
_ field: UITextField,
|
||||||
|
placeholder: String,
|
||||||
|
contentType: UITextContentType?
|
||||||
|
) {
|
||||||
|
field.placeholder = placeholder
|
||||||
|
field.textContentType = contentType
|
||||||
|
field.clearButtonMode = .whileEditing
|
||||||
|
field.delegate = self
|
||||||
|
field.returnKeyType = .next
|
||||||
|
field.adjustsFontForContentSizeCategory = true
|
||||||
|
field.font = .preferredFont(forTextStyle: .body)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureProviderButton() {
|
||||||
|
providerButton.contentHorizontalAlignment = .leading
|
||||||
|
providerButton.showsMenuAsPrimaryAction = true
|
||||||
|
providerButton.changesSelectionAsPrimaryAction = true
|
||||||
|
providerButton.accessibilityLabel = "API provider"
|
||||||
|
providerButton.accessibilityValue = provider == .gitea ? "Gitea" : "Forgejo"
|
||||||
|
providerButton.menu = UIMenu(options: .singleSelection, children: [
|
||||||
|
UIAction(title: "Gitea", state: provider == .gitea ? .on : .off) { [weak self] _ in
|
||||||
|
self?.provider = .gitea
|
||||||
|
self?.urlField.placeholder = "https://gitea.example.com"
|
||||||
|
self?.providerButton.accessibilityValue = "Gitea"
|
||||||
|
},
|
||||||
|
UIAction(title: "Forgejo", state: provider == .forgejo ? .on : .off) { [weak self] _ in
|
||||||
|
self?.provider = .forgejo
|
||||||
|
self?.urlField.placeholder = "https://forgejo.example.com"
|
||||||
|
self?.providerButton.accessibilityValue = "Forgejo"
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
160
ios/Sources/SettingsViewController.swift
Normal file
160
ios/Sources/SettingsViewController.swift
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class SettingsViewController: UITableViewController {
|
||||||
|
private let context: AppContext
|
||||||
|
private let appearanceControl = UISegmentedControl(items: ["Auto", "Light", "Dark"])
|
||||||
|
private let notificationSwitch = UISwitch()
|
||||||
|
private var notificationStatus = "Managed by iOS"
|
||||||
|
|
||||||
|
init(context: AppContext) {
|
||||||
|
self.context = context
|
||||||
|
super.init(style: .insetGrouped)
|
||||||
|
title = "Settings"
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
let settings = context.core.settings()
|
||||||
|
appearanceControl.selectedSegmentIndex = Int(settings.appearance)
|
||||||
|
appearanceControl.addTarget(self, action: #selector(appearanceChanged), for: .valueChanged)
|
||||||
|
notificationSwitch.isOn = settings.notificationsEnabled
|
||||||
|
notificationSwitch.accessibilityLabel = "Background notifications"
|
||||||
|
notificationSwitch.addTarget(self, action: #selector(notificationsChanged), for: .valueChanged)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
notificationSwitch.isOn = context.core.settings().notificationsEnabled
|
||||||
|
tableView.reloadSections(IndexSet(integer: 1), with: .none)
|
||||||
|
Task {
|
||||||
|
notificationStatus = await context.notifications.authorizationDescription()
|
||||||
|
updateNotificationSettingsCell()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func numberOfSections(in tableView: UITableView) -> Int { 3 }
|
||||||
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
section == 2 ? 2 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||||
|
["Appearance", "Navigation", "Notifications"][section]
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
|
||||||
|
if section == 0 {
|
||||||
|
return "Follow iOS automatically or choose a fixed appearance."
|
||||||
|
}
|
||||||
|
if section == 1 {
|
||||||
|
return "Home is always present. Choose and order up to four additional destinations."
|
||||||
|
}
|
||||||
|
return "Gotcha checks periodically for server notifications. iOS decides when background refresh runs; delivery style, sounds, Focus, and summaries remain under your control in iOS Settings."
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
cellForRowAt indexPath: IndexPath
|
||||||
|
) -> UITableViewCell {
|
||||||
|
if indexPath.section == 1 {
|
||||||
|
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||||
|
var content = cell.defaultContentConfiguration()
|
||||||
|
content.text = "Primary Destinations"
|
||||||
|
content.secondaryText = "\(context.primaryDestinations.count) selected"
|
||||||
|
cell.contentConfiguration = content
|
||||||
|
cell.accessoryType = .disclosureIndicator
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
guard indexPath.section == 0 else {
|
||||||
|
if indexPath.row == 0 {
|
||||||
|
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
|
||||||
|
var content = cell.defaultContentConfiguration()
|
||||||
|
content.text = "Background notifications"
|
||||||
|
cell.contentConfiguration = content
|
||||||
|
cell.accessoryView = notificationSwitch
|
||||||
|
cell.selectionStyle = .none
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||||
|
var content = cell.defaultContentConfiguration()
|
||||||
|
content.text = "Notification Settings"
|
||||||
|
content.secondaryText = notificationStatus
|
||||||
|
cell.contentConfiguration = content
|
||||||
|
cell.accessoryType = .disclosureIndicator
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
|
||||||
|
appearanceControl.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
cell.contentView.addSubview(appearanceControl)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
appearanceControl.leadingAnchor.constraint(equalTo: cell.contentView.leadingAnchor, constant: 16),
|
||||||
|
appearanceControl.trailingAnchor.constraint(equalTo: cell.contentView.trailingAnchor, constant: -16),
|
||||||
|
appearanceControl.topAnchor.constraint(equalTo: cell.contentView.topAnchor, constant: 8),
|
||||||
|
appearanceControl.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor, constant: -8),
|
||||||
|
])
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
|
if indexPath.section == 1 {
|
||||||
|
navigationController?.pushViewController(
|
||||||
|
NavigationSettingsViewController(context: context),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
} else if indexPath.section == 2, indexPath.row == 1 {
|
||||||
|
context.notifications.openSystemSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func appearanceChanged() {
|
||||||
|
do {
|
||||||
|
try context.core.setAppearance(index: UInt32(appearanceControl.selectedSegmentIndex))
|
||||||
|
context.applyAppearance()
|
||||||
|
} catch {
|
||||||
|
show(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func notificationsChanged() {
|
||||||
|
let requested = notificationSwitch.isOn
|
||||||
|
notificationSwitch.isEnabled = false
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let enabled = try await context.notifications.setEnabled(requested)
|
||||||
|
notificationSwitch.isOn = enabled
|
||||||
|
if requested && !enabled { showNotificationsDisabledAlert() }
|
||||||
|
} catch {
|
||||||
|
notificationSwitch.isOn = context.core.settings().notificationsEnabled
|
||||||
|
show(error: error)
|
||||||
|
}
|
||||||
|
notificationSwitch.isEnabled = true
|
||||||
|
notificationStatus = await context.notifications.authorizationDescription()
|
||||||
|
updateNotificationSettingsCell()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateNotificationSettingsCell() {
|
||||||
|
guard let cell = tableView.cellForRow(at: IndexPath(row: 1, section: 2)) else { return }
|
||||||
|
var content = cell.defaultContentConfiguration()
|
||||||
|
content.text = "Notification Settings"
|
||||||
|
content.secondaryText = notificationStatus
|
||||||
|
cell.contentConfiguration = content
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showNotificationsDisabledAlert() {
|
||||||
|
let alert = UIAlertController(
|
||||||
|
title: "Notifications Are Disabled",
|
||||||
|
message: "Allow notifications in iOS Settings, then turn Background notifications on again.",
|
||||||
|
preferredStyle: .alert
|
||||||
|
)
|
||||||
|
alert.addAction(UIAlertAction(title: "Not Now", style: .cancel))
|
||||||
|
alert.addAction(UIAlertAction(title: "Open Settings", style: .default) { [weak self] _ in
|
||||||
|
self?.context.notifications.openSystemSettings()
|
||||||
|
})
|
||||||
|
present(alert, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
427
ios/Sources/Support.swift
Normal file
427
ios/Sources/Support.swift
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
import MarkdownUI
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct MarkdownContent: View {
|
||||||
|
let source: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Markdown(source)
|
||||||
|
.markdownTheme(.gitHub)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func markdownView(_ source: String) -> UIView {
|
||||||
|
UIHostingConfiguration {
|
||||||
|
MarkdownContent(source: source)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.margins(.all, 0)
|
||||||
|
.makeContentView()
|
||||||
|
}
|
||||||
|
|
||||||
|
func localDate(forUTCTimestamp timestamp: Int64) -> Date {
|
||||||
|
var utc = Calendar(identifier: .gregorian)
|
||||||
|
utc.timeZone = TimeZone(secondsFromGMT: 0)!
|
||||||
|
let components = utc.dateComponents(
|
||||||
|
[.year, .month, .day],
|
||||||
|
from: Date(timeIntervalSince1970: TimeInterval(timestamp))
|
||||||
|
)
|
||||||
|
return Calendar.current.date(from: components) ?? Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
func utcTimestamp(forLocalDate date: Date) -> Int64 {
|
||||||
|
let components = Calendar.current.dateComponents([.year, .month, .day], from: date)
|
||||||
|
var utc = Calendar(identifier: .gregorian)
|
||||||
|
utc.timeZone = TimeZone(secondsFromGMT: 0)!
|
||||||
|
return Int64((utc.date(from: components) ?? date).timeIntervalSince1970)
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorAlert(_ message: String) -> UIAlertController {
|
||||||
|
let alert = UIAlertController(title: "Something went wrong", message: message, preferredStyle: .alert)
|
||||||
|
alert.addAction(UIAlertAction(title: "OK", style: .default))
|
||||||
|
return alert
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorMessage(_ error: Error) -> String {
|
||||||
|
if case let GotchaError.Message(message) = error { return message }
|
||||||
|
return error.localizedDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
extension UIViewController {
|
||||||
|
func show(error: Error) {
|
||||||
|
present(errorAlert(errorMessage(error)), animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func beginNavigationLoading(_ spinner: UIActivityIndicatorView) {
|
||||||
|
guard navigationItem.rightBarButtonItem == nil else { return }
|
||||||
|
spinner.startAnimating()
|
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(customView: spinner)
|
||||||
|
}
|
||||||
|
|
||||||
|
func endNavigationLoading(_ spinner: UIActivityIndicatorView) {
|
||||||
|
spinner.stopAnimating()
|
||||||
|
if navigationItem.rightBarButtonItem?.customView === spinner {
|
||||||
|
navigationItem.rightBarButtonItem = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class RefreshingTableViewController: UITableViewController {
|
||||||
|
private let spinner = UIActivityIndicatorView(style: .medium)
|
||||||
|
var loadingTask: Task<Void, Never>?
|
||||||
|
private lazy var moreButton = UIButton(
|
||||||
|
configuration: .plain(),
|
||||||
|
primaryAction: UIAction { [weak self] _ in self?.requestMoreContent() }
|
||||||
|
)
|
||||||
|
private var hasMoreContent = false
|
||||||
|
private var loadingMore = false
|
||||||
|
|
||||||
|
init() {
|
||||||
|
super.init(style: .plain)
|
||||||
|
tableView.backgroundColor = .systemGroupedBackground
|
||||||
|
tableView.separatorInset = .zero
|
||||||
|
tableView.refreshControl = UIRefreshControl()
|
||||||
|
tableView.refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged)
|
||||||
|
navigationItem.largeTitleDisplayMode = .never
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
deinit { loadingTask?.cancel() }
|
||||||
|
|
||||||
|
func loadContent(refreshing: Bool) {}
|
||||||
|
|
||||||
|
func loadMoreContent() {}
|
||||||
|
|
||||||
|
func beginLoading(refreshing: Bool) {
|
||||||
|
if !refreshing { beginNavigationLoading(spinner) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func endLoading() {
|
||||||
|
endNavigationLoading(spinner)
|
||||||
|
refreshControl?.endRefreshing()
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetPagination() {
|
||||||
|
hasMoreContent = false
|
||||||
|
loadingMore = false
|
||||||
|
tableView.tableFooterView = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func finishPagination(hasMore: Bool) {
|
||||||
|
hasMoreContent = hasMore
|
||||||
|
loadingMore = false
|
||||||
|
var configuration = moreButton.configuration
|
||||||
|
configuration?.title = "Pull up or tap to load more"
|
||||||
|
configuration?.showsActivityIndicator = false
|
||||||
|
moreButton.configuration = configuration
|
||||||
|
moreButton.accessibilityLabel = "Load more results"
|
||||||
|
tableView.tableFooterView = hasMore ? paginationFooter() : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func failPagination() {
|
||||||
|
loadingMore = false
|
||||||
|
finishPagination(hasMore: hasMoreContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||||
|
guard scrollView.isDragging, hasMoreContent, !loadingMore else { return }
|
||||||
|
let bottom = max(
|
||||||
|
-scrollView.adjustedContentInset.top,
|
||||||
|
scrollView.contentSize.height
|
||||||
|
+ scrollView.adjustedContentInset.bottom
|
||||||
|
- scrollView.bounds.height
|
||||||
|
)
|
||||||
|
if scrollView.contentOffset.y > bottom + 60 { requestMoreContent() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requestMoreContent() {
|
||||||
|
guard hasMoreContent, !loadingMore else { return }
|
||||||
|
loadingMore = true
|
||||||
|
var configuration = moreButton.configuration
|
||||||
|
configuration?.title = "Loading more…"
|
||||||
|
configuration?.showsActivityIndicator = true
|
||||||
|
moreButton.configuration = configuration
|
||||||
|
moreButton.accessibilityLabel = "Loading more results"
|
||||||
|
loadMoreContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func paginationFooter() -> UIView {
|
||||||
|
let footer = UIView(frame: CGRect(x: 0, y: 0, width: tableView.bounds.width, height: 56))
|
||||||
|
moreButton.frame = footer.bounds
|
||||||
|
moreButton.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||||
|
footer.addSubview(moreButton)
|
||||||
|
return footer
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func refreshRequested() {
|
||||||
|
loadContent(refreshing: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class EmptyBackgroundView: UIView {
|
||||||
|
init(title: String, detail: String) {
|
||||||
|
super.init(frame: .zero)
|
||||||
|
let titleLabel = UILabel()
|
||||||
|
titleLabel.text = title
|
||||||
|
titleLabel.font = .preferredFont(forTextStyle: .title2)
|
||||||
|
titleLabel.textAlignment = .center
|
||||||
|
let detailLabel = UILabel()
|
||||||
|
detailLabel.text = detail
|
||||||
|
detailLabel.font = .preferredFont(forTextStyle: .body)
|
||||||
|
detailLabel.textColor = .secondaryLabel
|
||||||
|
detailLabel.textAlignment = .center
|
||||||
|
detailLabel.numberOfLines = 0
|
||||||
|
let stack = UIStackView(arrangedSubviews: [titleLabel, detailLabel])
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = 8
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
addSubview(stack)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 28),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -28),
|
||||||
|
stack.centerYAnchor.constraint(equalTo: centerYAnchor, constant: -40),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension UIColor {
|
||||||
|
convenience init?(hex: String) {
|
||||||
|
let value = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
|
||||||
|
guard value.count == 6, let rgb = Int(value, radix: 16) else { return nil }
|
||||||
|
self.init(
|
||||||
|
red: CGFloat((rgb >> 16) & 0xff) / 255,
|
||||||
|
green: CGFloat((rgb >> 8) & 0xff) / 255,
|
||||||
|
blue: CGFloat(rgb & 0xff) / 255,
|
||||||
|
alpha: 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func configureTextCell(_ cell: UITableViewCell, title: String, detail: String, image: UIImage? = nil) {
|
||||||
|
var content = cell.defaultContentConfiguration()
|
||||||
|
content.text = title
|
||||||
|
content.secondaryText = detail
|
||||||
|
content.secondaryTextProperties.numberOfLines = 2
|
||||||
|
content.image = image
|
||||||
|
content.imageProperties.tintColor = .tintColor
|
||||||
|
cell.contentConfiguration = content
|
||||||
|
cell.backgroundColor = .systemBackground
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterMenuImage(_ symbol: String, active: Bool) -> UIImage? {
|
||||||
|
let image = UIImage(systemName: symbol)
|
||||||
|
return active
|
||||||
|
? image?.withTintColor(.systemBlue, renderingMode: .alwaysOriginal)
|
||||||
|
: image
|
||||||
|
}
|
||||||
|
|
||||||
|
func configureRepositoryContentCell(_ cell: UITableViewCell, row: RepositoryContentRow) {
|
||||||
|
let directory = row.kind == .directory
|
||||||
|
let detail = directory
|
||||||
|
? "Folder"
|
||||||
|
: ByteCountFormatter.string(fromByteCount: row.size, countStyle: .file)
|
||||||
|
configureTextCell(
|
||||||
|
cell,
|
||||||
|
title: row.name,
|
||||||
|
detail: detail,
|
||||||
|
image: UIImage(systemName: directory ? "folder.fill" : "doc")
|
||||||
|
)
|
||||||
|
cell.accessoryType = .disclosureIndicator
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func showRepositoryContent(
|
||||||
|
_ row: RepositoryContentRow,
|
||||||
|
context: AppContext,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
navigationController: UINavigationController?
|
||||||
|
) {
|
||||||
|
let destination: UIViewController = row.kind == .directory
|
||||||
|
? RepositoryDirectoryViewController(
|
||||||
|
context: context,
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
path: row.path,
|
||||||
|
name: row.name
|
||||||
|
)
|
||||||
|
: RepositoryFileViewController(
|
||||||
|
context: context,
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
path: row.path,
|
||||||
|
name: row.name
|
||||||
|
)
|
||||||
|
navigationController?.pushViewController(destination, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func symbolText(_ symbol: String, text: String, font: UIFont, color: UIColor) -> NSAttributedString {
|
||||||
|
let output = NSMutableAttributedString()
|
||||||
|
if let image = UIImage(systemName: symbol)?.withTintColor(color, renderingMode: .alwaysOriginal) {
|
||||||
|
let attachment = NSTextAttachment(image: image)
|
||||||
|
attachment.bounds = CGRect(x: 0, y: -2, width: font.pointSize, height: font.pointSize)
|
||||||
|
output.append(NSAttributedString(attachment: attachment))
|
||||||
|
output.append(NSAttributedString(string: " "))
|
||||||
|
}
|
||||||
|
output.append(NSAttributedString(string: text, attributes: [
|
||||||
|
.font: font,
|
||||||
|
.foregroundColor: color,
|
||||||
|
]))
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
func configureIssueStateIcon(
|
||||||
|
_ icon: UIImageView,
|
||||||
|
state: WorkItemState,
|
||||||
|
textStyle: UIFont.TextStyle
|
||||||
|
) {
|
||||||
|
configureOpenClosedStateIcon(icon, state: state, subject: "issue", textStyle: textStyle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func configureOpenClosedStateIcon(
|
||||||
|
_ icon: UIImageView,
|
||||||
|
state: WorkItemState,
|
||||||
|
subject: String,
|
||||||
|
textStyle: UIFont.TextStyle
|
||||||
|
) {
|
||||||
|
let symbol: String
|
||||||
|
let color: UIColor
|
||||||
|
let accessibilityLabel: String
|
||||||
|
switch state {
|
||||||
|
case .open:
|
||||||
|
(symbol, color, accessibilityLabel) = (
|
||||||
|
"exclamationmark.circle.fill", .systemGreen, "Open \(subject)"
|
||||||
|
)
|
||||||
|
case .closed:
|
||||||
|
(symbol, color, accessibilityLabel) = (
|
||||||
|
"checkmark.circle.fill", .systemPurple, "Closed \(subject)"
|
||||||
|
)
|
||||||
|
case .unknown:
|
||||||
|
(symbol, color, accessibilityLabel) = (
|
||||||
|
"questionmark.circle.fill", .systemGray, "Unknown \(subject) state"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
icon.image = UIImage(systemName: symbol)
|
||||||
|
icon.tintColor = color
|
||||||
|
icon.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: textStyle)
|
||||||
|
icon.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
icon.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
|
icon.isAccessibilityElement = true
|
||||||
|
icon.accessibilityLabel = accessibilityLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
func issueLabelView(_ label: LabelRow) -> UILabel {
|
||||||
|
let view = UILabel()
|
||||||
|
view.text = " \(label.name) "
|
||||||
|
view.font = .preferredFont(forTextStyle: .caption2)
|
||||||
|
view.adjustsFontForContentSizeCategory = true
|
||||||
|
view.textColor = label.light ? .black : .white
|
||||||
|
view.backgroundColor = UIColor(hex: label.color)
|
||||||
|
view.layer.cornerRadius = 9
|
||||||
|
view.clipsToBounds = true
|
||||||
|
view.accessibilityLabel = "Label \(label.name)"
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
final class IssueLabelsView: UIView {
|
||||||
|
private let labels: [UILabel]
|
||||||
|
private var contentHeight: CGFloat
|
||||||
|
private let spacing: CGFloat = 6
|
||||||
|
|
||||||
|
init(_ rows: [LabelRow]) {
|
||||||
|
labels = rows.map(issueLabelView)
|
||||||
|
contentHeight = labels.first?.intrinsicContentSize.height ?? 0
|
||||||
|
super.init(frame: .zero)
|
||||||
|
labels.forEach(addSubview)
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(contentSizeCategoryDidChange),
|
||||||
|
name: UIContentSizeCategory.didChangeNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
override var intrinsicContentSize: CGSize {
|
||||||
|
CGSize(width: UIView.noIntrinsicMetric, height: contentHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
guard bounds.width > 0 else { return }
|
||||||
|
var origin = CGPoint.zero
|
||||||
|
var rowHeight: CGFloat = 0
|
||||||
|
for label in labels {
|
||||||
|
let size = label.sizeThatFits(
|
||||||
|
CGSize(width: bounds.width, height: .greatestFiniteMagnitude)
|
||||||
|
)
|
||||||
|
if origin.x > 0, origin.x + size.width > bounds.width {
|
||||||
|
origin.x = 0
|
||||||
|
origin.y += rowHeight + spacing
|
||||||
|
rowHeight = 0
|
||||||
|
}
|
||||||
|
label.frame = CGRect(origin: origin, size: size)
|
||||||
|
origin.x += size.width + spacing
|
||||||
|
rowHeight = max(rowHeight, size.height)
|
||||||
|
}
|
||||||
|
let height = origin.y + rowHeight
|
||||||
|
if contentHeight != height {
|
||||||
|
contentHeight = height
|
||||||
|
invalidateIntrinsicContentSize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func contentSizeCategoryDidChange() {
|
||||||
|
labels.forEach { $0.font = .preferredFont(forTextStyle: .caption2) }
|
||||||
|
contentHeight = labels.first?.intrinsicContentSize.height ?? 0
|
||||||
|
invalidateIntrinsicContentSize()
|
||||||
|
setNeedsLayout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func separator() -> UIView {
|
||||||
|
let line = UIView()
|
||||||
|
line.backgroundColor = .separator
|
||||||
|
line.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale).isActive = true
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func setPanelTitle(_ viewController: UIViewController, _ title: String, server: String?) {
|
||||||
|
viewController.title = title
|
||||||
|
viewController.navigationItem.titleView = nil
|
||||||
|
if #available(iOS 26.0, *) {
|
||||||
|
viewController.navigationItem.subtitle = server.flatMap { $0.isEmpty ? nil : $0 }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let server, !server.isEmpty else { return }
|
||||||
|
let titleLabel = UILabel()
|
||||||
|
titleLabel.text = title
|
||||||
|
titleLabel.font = .preferredFont(forTextStyle: .headline)
|
||||||
|
titleLabel.adjustsFontForContentSizeCategory = true
|
||||||
|
titleLabel.textAlignment = .center
|
||||||
|
let serverLabel = UILabel()
|
||||||
|
serverLabel.text = server
|
||||||
|
serverLabel.font = .preferredFont(forTextStyle: .caption1)
|
||||||
|
serverLabel.adjustsFontForContentSizeCategory = true
|
||||||
|
serverLabel.textColor = .secondaryLabel
|
||||||
|
serverLabel.textAlignment = .center
|
||||||
|
let labels = UIStackView(arrangedSubviews: [titleLabel, serverLabel])
|
||||||
|
labels.axis = .vertical
|
||||||
|
labels.spacing = 0
|
||||||
|
labels.alignment = .center
|
||||||
|
labels.accessibilityLabel = "\(title), \(server)"
|
||||||
|
viewController.navigationItem.titleView = labels
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user