Compare commits
81 Commits
73b6882b47
...
v1.1.0
| 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
|
||||
.env
|
||||
|
||||
xcuserdata/
|
||||
*.xcuserstate
|
||||
/distribution/
|
||||
|
||||
109
AGENTS.md
109
AGENTS.md
@@ -1,5 +1,90 @@
|
||||
# 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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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.
|
||||
It contains 299 paths, 467 operations, 243 read/query operations, and 216 schema
|
||||
definitions.
|
||||
|
||||
`gotcha_gitea` exposes the complete typed Gitea 1.25 client through
|
||||
`gotcha_gitea::apis` and every generated request/response model through
|
||||
`gotcha_gitea::models`. `Client::configuration()` supplies the selected base URL
|
||||
and token to those generated functions.
|
||||
`gotcha_gitea` encapsulates the generated Gitea 1.25 client. Its concrete
|
||||
`Client` methods own the supported Gitea and Forgejo workflows and
|
||||
relationships, while
|
||||
`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 |
|
||||
| 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`
|
||||
- `repo list` → `userCurrentListRepos`
|
||||
- `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
|
||||
- pull list/show/create/edit/merge/commits/files/reviews
|
||||
|
||||
The remaining 229 query operations and 214 mutation operations are available to
|
||||
Rust callers through the typed API modules but do not yet have purpose-built CLI
|
||||
commands or text/input views. `api request` remains an explicit JSON escape hatch;
|
||||
it is not counted as typed CLI coverage.
|
||||
The remaining 229 query operations and 214 mutation operations are represented
|
||||
by the private generated client but do not yet have shared domain methods or
|
||||
purpose-built CLI commands. Add new workflows to `gotcha_gitea::Client` before
|
||||
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]
|
||||
members = ["crates/gitea", "crates/cli", "crates/app"]
|
||||
members = ["crates/gitea", "crates/cli", "crates/app", "crates/tui"]
|
||||
resolver = "3"
|
||||
|
||||
[workspace.package]
|
||||
@@ -11,7 +11,6 @@ rust-version = "1.92"
|
||||
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync"] }
|
||||
rpassword = "7"
|
||||
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 is a lightweight Gitea client built in Rust. Development starts with a
|
||||
reusable API crate and a CLI test bed; the same crate will back the Slint iOS
|
||||
application.
|
||||
Gotcha is a lightweight Gitea and Forgejo client with a reusable Rust core, a
|
||||
CLI, a Ratatui terminal interface, and a native iOS application. The iOS
|
||||
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
|
||||
|
||||
- `gotcha_gitea`: reusable asynchronous Gitea API client with the complete
|
||||
typed Gitea 1.25 API and model surface
|
||||
- `gotcha_gitea`: reusable asynchronous Gitea/Forgejo API client with the
|
||||
complete typed Gitea 1.25-compatible API and model surface
|
||||
- `gotcha`: CLI for typed common operations and arbitrary API requests
|
||||
- `gotcha-app`: Slint iOS client for servers, owned repositories, favorites,
|
||||
open issues, and issue details; bundle identifier `de.rfc1437.gotcha`
|
||||
- `gotcha-tui`: paned, keyboard-and-mouse terminal application
|
||||
- `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
|
||||
low-level request API remains available for newer instance-specific endpoints;
|
||||
@@ -21,31 +35,58 @@ separate API-crate and CLI coverage status.
|
||||
|
||||
## 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`:
|
||||
|
||||
```sh
|
||||
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 -- user show
|
||||
cargo run -p gotcha-cli -- repo list
|
||||
cargo run -p gotcha-cli -- repo show
|
||||
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 -- 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 POST user/repos '{"name":"demo"}'
|
||||
```
|
||||
|
||||
`auth login` derives `https://gitea.example.com` from the server name and reads
|
||||
the token from standard input with echo disabled. The resulting plain YAML file
|
||||
at `~/.config/gotcha/config` has mode `0600` and one entry per server:
|
||||
`auth login` derives `https://gitea.example.com` from the server name, discovers
|
||||
Gitea or Forgejo, and reads the token from standard input with echo disabled.
|
||||
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
|
||||
servers:
|
||||
gitea.example.com:
|
||||
url: https://gitea.example.com
|
||||
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
|
||||
@@ -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
|
||||
accepted as command-line arguments or environment variables.
|
||||
|
||||
## Direction
|
||||
## Terminal UI
|
||||
|
||||
1. Exercise and type API areas in the CLI: repositories, issues and pull
|
||||
requests, Actions, notifications, organizations, packages, administration.
|
||||
2. Add the Slint shell and move proven read workflows into touch-first screens.
|
||||
3. Add write workflows and iOS integrations such as sharing, notifications,
|
||||
and background refresh.
|
||||
Build and run the standalone TUI with an existing CLI server profile:
|
||||
|
||||
```sh
|
||||
cargo run -p gotcha-tui
|
||||
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
|
||||
|
||||
Server tokens are kept in the Apple Keychain; the JSON preferences contain only
|
||||
server metadata and favorites. The app requires Xcode, XcodeGen, an installed
|
||||
iOS Simulator runtime, and Rust's
|
||||
Prebuilt iPhone releases will be distributed through the
|
||||
[rfc1437 Apps AltStore PAL source](https://rfc1437.de/apps/). The source page
|
||||
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
|
||||
after installing those prerequisites:
|
||||
|
||||
@@ -76,3 +154,6 @@ open Gotcha.xcodeproj
|
||||
```
|
||||
|
||||
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]
|
||||
name = "gotcha-app"
|
||||
version = "0.1.0"
|
||||
description = "Lightweight Slint client for Gitea on iOS"
|
||||
version = "1.0.0"
|
||||
description = "Rust application core for the Gotcha iOS client"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "gotcha-app"
|
||||
path = "src/main.rs"
|
||||
[lib]
|
||||
name = "gotcha_core"
|
||||
crate-type = ["lib", "staticlib"]
|
||||
|
||||
[dependencies]
|
||||
gotcha_gitea = { path = "../gitea" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
security-framework = "3"
|
||||
slint.workspace = true
|
||||
thiserror = "2"
|
||||
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]
|
||||
name = "gotcha-cli"
|
||||
version = "0.1.0"
|
||||
description = "CLI test bed for the Gotcha Gitea client"
|
||||
version = "1.0.0"
|
||||
description = "Command-line Gitea client"
|
||||
edition.workspace = true
|
||||
license.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::{
|
||||
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();
|
||||
}
|
||||
}
|
||||
pub use gotcha_gitea::{Config, RepositoryId as RepositoryScope, Selection, server_url};
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
mod actions;
|
||||
mod config;
|
||||
mod work_items;
|
||||
|
||||
use std::{env, error::Error, process};
|
||||
|
||||
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;
|
||||
|
||||
const ROOT_HELP: &str = "\
|
||||
@@ -15,13 +16,14 @@ Usage:
|
||||
|
||||
Commands:
|
||||
auth Manage server authentication
|
||||
server Inspect the selected Gitea server
|
||||
server Inspect the selected Gitea or Forgejo server
|
||||
user Work with the authenticated user
|
||||
repo Work with repositories
|
||||
issue Work with issues and comments
|
||||
milestone Work with repository milestones
|
||||
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.
|
||||
|
||||
@@ -33,7 +35,8 @@ const AUTH_HELP: &str = "\
|
||||
Usage: gotcha auth SUBCOMMAND
|
||||
|
||||
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
|
||||
status Show the selected server and repository scope
|
||||
logout SERVER Remove a stored server and token";
|
||||
@@ -42,7 +45,7 @@ const SERVER_HELP: &str = "\
|
||||
Usage: gotcha server SUBCOMMAND
|
||||
|
||||
Subcommands:
|
||||
version Show the selected Gitea server version";
|
||||
version Show the selected server version";
|
||||
|
||||
const USER_HELP: &str = "\
|
||||
Usage: gotcha user SUBCOMMAND
|
||||
@@ -82,24 +85,26 @@ async fn run() -> Result<(), Box<dyn Error>> {
|
||||
}
|
||||
|
||||
let mut config = Config::load()?;
|
||||
match args.command.as_slice() {
|
||||
[domain, command, name] if domain == "auth" && command == "login" => {
|
||||
let url = server_url(name)?;
|
||||
let token = rpassword::prompt_password("Token: ")?;
|
||||
if token.is_empty() {
|
||||
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(());
|
||||
if let Some((name, fallback)) = login_command(&args.command)? {
|
||||
let url = server_url(name)?;
|
||||
let token = rpassword::prompt_password("Token: ")?;
|
||||
if token.is_empty() {
|
||||
return Err("token must not be empty".into());
|
||||
}
|
||||
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" => {
|
||||
for (name, server) in &config.servers {
|
||||
println!("{name}\t{}", server.url);
|
||||
println!("{name}\t{}\t{}", server.provider, server.url);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
@@ -118,7 +123,14 @@ async fn run() -> Result<(), Box<dyn Error>> {
|
||||
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? {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -160,6 +172,20 @@ fn print_version(version: &ServerVersion) {
|
||||
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) {
|
||||
field("login", user.login.as_deref());
|
||||
field("name", user.full_name.as_deref());
|
||||
@@ -169,20 +195,30 @@ fn print_user(user: &User) {
|
||||
}
|
||||
|
||||
fn print_repositories(repositories: &[Repository]) {
|
||||
println!("REPOSITORY\tVISIBILITY\tUPDATED\tDESCRIPTION");
|
||||
for repository in repositories {
|
||||
println!(
|
||||
"{}\t{}\t{}\t{}",
|
||||
repository.full_name.as_deref().unwrap_or("unknown"),
|
||||
if repository.private.unwrap_or(false) {
|
||||
"private"
|
||||
} else {
|
||||
"public"
|
||||
},
|
||||
repository.updated_at.as_deref().unwrap_or("-"),
|
||||
repository.description.as_deref().unwrap_or("")
|
||||
);
|
||||
}
|
||||
print_table(
|
||||
&[
|
||||
("REPOSITORY", 40),
|
||||
("VISIBILITY", 10),
|
||||
("UPDATED", 25),
|
||||
("DESCRIPTION", 60),
|
||||
],
|
||||
repositories
|
||||
.iter()
|
||||
.map(|repository| {
|
||||
vec![
|
||||
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) {
|
||||
@@ -216,6 +252,93 @@ fn print_json(value: &Value) -> Result<(), serde_json::Error> {
|
||||
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> {
|
||||
match command {
|
||||
[] => Ok(Some(ROOT_HELP)),
|
||||
@@ -250,14 +373,14 @@ fn domain_help(domain: &str) -> Option<&'static str> {
|
||||
"user" => Some(USER_HELP),
|
||||
"repo" => Some(REPO_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> {
|
||||
match (domain, subcommand) {
|
||||
("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") => {
|
||||
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(
|
||||
"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."),
|
||||
("repo", "list") => Some(
|
||||
"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(
|
||||
"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"
|
||||
}
|
||||
);
|
||||
println!("provider: {}", selection.provider);
|
||||
if let Some(scope) = &selection.repository {
|
||||
println!("repository: {}/{}", scope.owner, scope.repository);
|
||||
}
|
||||
@@ -366,31 +491,4 @@ impl Args {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
|
||||
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 gotcha_gitea::{
|
||||
Client, apis,
|
||||
Client, CreateIssue, EditIssue, IssueQuery,
|
||||
models::{
|
||||
ChangedFile, Comment, Commit, CreateIssueCommentOption, CreateIssueOption,
|
||||
CreateMilestoneOption, CreatePullRequestOption, EditIssueOption, EditMilestoneOption,
|
||||
EditPullRequestOption, Issue, MergePullRequestOption, Milestone, PullRequest, PullReview,
|
||||
CreateMilestoneOption, CreatePullRequestOption, EditMilestoneOption, EditPullRequestOption,
|
||||
MergePullRequestOption,
|
||||
},
|
||||
};
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
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(
|
||||
command: &[String],
|
||||
selection: &Selection,
|
||||
client: &Client,
|
||||
) -> Result<bool, Box<dyn Error>> {
|
||||
let configuration = client.configuration();
|
||||
match command {
|
||||
[domain, action] if domain == "issue" && action == "list" => {
|
||||
let scope = scope(selection, None)?;
|
||||
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, repository] if domain == "issue" && action == "list" => {
|
||||
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, arguments @ ..] if domain == "issue" && action == "list" => {
|
||||
let options = parse_issue_list(arguments)?;
|
||||
let scope = scope(selection, options.repository.as_deref())?;
|
||||
let issues = client
|
||||
.issues(
|
||||
&scope,
|
||||
&IssueQuery {
|
||||
state: options.state,
|
||||
labels: options.labels,
|
||||
keyword: options.keyword,
|
||||
kind: options.kind,
|
||||
milestones: options.milestones,
|
||||
from: options.from,
|
||||
until: options.until,
|
||||
author: options.author,
|
||||
assignee: options.assignee,
|
||||
mentions: options.mentions,
|
||||
page: options.page,
|
||||
limit: options.limit,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
print_issues(&issues.items);
|
||||
}
|
||||
[domain, action, index] if domain == "issue" && action == "show" => {
|
||||
let scope = scope(selection, None)?;
|
||||
print_issue(
|
||||
&apis::issue_api::issue_get_issue(
|
||||
&configuration,
|
||||
&scope.owner,
|
||||
&scope.repository,
|
||||
number(index)?,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
print_issue(&client.issue(&scope, number(index)?).await?);
|
||||
}
|
||||
[domain, action, index, repository] if domain == "issue" && action == "show" => {
|
||||
let scope = scope(selection, Some(repository))?;
|
||||
print_issue(
|
||||
&apis::issue_api::issue_get_issue(
|
||||
&configuration,
|
||||
&scope.owner,
|
||||
&scope.repository,
|
||||
number(index)?,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
print_issue(&client.issue(&scope, number(index)?).await?);
|
||||
}
|
||||
[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" => {
|
||||
create_issue(&configuration, &scope(selection, Some(repository))?).await?;
|
||||
create_issue(client, &scope(selection, Some(repository))?).await?;
|
||||
}
|
||||
[domain, action, index] if domain == "issue" && action == "edit" => {
|
||||
edit_issue(&configuration, &scope(selection, None)?, number(index)?).await?;
|
||||
[domain, action, arguments @ ..] if domain == "issue" && action == "edit" => {
|
||||
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" => {
|
||||
edit_issue(
|
||||
&configuration,
|
||||
&scope(selection, Some(repository))?,
|
||||
number(index)?,
|
||||
[domain, action, arguments @ ..]
|
||||
if domain == "issue" && matches!(action.as_str(), "close" | "reopen") =>
|
||||
{
|
||||
let (indexes, repository) = issue_targets(arguments)?;
|
||||
let scope = scope(selection, repository.as_deref())?;
|
||||
set_issue_state(
|
||||
client,
|
||||
&scope,
|
||||
&indexes,
|
||||
if action == "close" { "closed" } else { "open" },
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
[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" => {
|
||||
delete_issue(
|
||||
&configuration,
|
||||
&scope(selection, Some(repository))?,
|
||||
number(index)?,
|
||||
)
|
||||
.await?;
|
||||
delete_issue(client, &scope(selection, Some(repository))?, number(index)?).await?;
|
||||
}
|
||||
[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" => {
|
||||
list_comments(
|
||||
&configuration,
|
||||
&scope(selection, Some(repository))?,
|
||||
number(index)?,
|
||||
)
|
||||
.await?;
|
||||
list_comments(client, &scope(selection, Some(repository))?, number(index)?).await?;
|
||||
}
|
||||
[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" => {
|
||||
create_comment(
|
||||
&configuration,
|
||||
&scope(selection, Some(repository))?,
|
||||
number(index)?,
|
||||
)
|
||||
.await?;
|
||||
create_comment(client, &scope(selection, Some(repository))?, number(index)?).await?;
|
||||
}
|
||||
[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" => {
|
||||
list_milestones(&configuration, &scope(selection, Some(repository))?).await?;
|
||||
list_milestones(client, &scope(selection, Some(repository))?).await?;
|
||||
}
|
||||
[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" => {
|
||||
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" => {
|
||||
create_milestone(&configuration, &scope(selection, None)?).await?;
|
||||
create_milestone(client, &scope(selection, None)?).await?;
|
||||
}
|
||||
[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" => {
|
||||
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" => {
|
||||
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" => {
|
||||
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" => {
|
||||
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" => {
|
||||
list_pulls(&configuration, &scope(selection, None)?).await?;
|
||||
list_pulls(client, &scope(selection, None)?).await?;
|
||||
}
|
||||
[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" => {
|
||||
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" => {
|
||||
show_pull(
|
||||
&configuration,
|
||||
&scope(selection, Some(repository))?,
|
||||
number(index)?,
|
||||
)
|
||||
.await?;
|
||||
show_pull(client, &scope(selection, Some(repository))?, number(index)?).await?;
|
||||
}
|
||||
[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" => {
|
||||
create_pull(&configuration, &scope(selection, Some(repository))?).await?;
|
||||
create_pull(client, &scope(selection, Some(repository))?).await?;
|
||||
}
|
||||
[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" => {
|
||||
edit_pull(
|
||||
&configuration,
|
||||
&scope(selection, Some(repository))?,
|
||||
number(index)?,
|
||||
)
|
||||
.await?;
|
||||
edit_pull(client, &scope(selection, Some(repository))?, number(index)?).await?;
|
||||
}
|
||||
[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" => {
|
||||
merge_pull(
|
||||
&configuration,
|
||||
&scope(selection, Some(repository))?,
|
||||
number(index)?,
|
||||
)
|
||||
.await?;
|
||||
merge_pull(client, &scope(selection, Some(repository))?, number(index)?).await?;
|
||||
}
|
||||
[domain, action, index]
|
||||
if domain == "pull" && matches!(action.as_str(), "commits" | "files" | "reviews") =>
|
||||
{
|
||||
pull_details(
|
||||
&configuration,
|
||||
&scope(selection, None)?,
|
||||
number(index)?,
|
||||
action,
|
||||
)
|
||||
.await?;
|
||||
pull_details(client, &scope(selection, None)?, number(index)?, action).await?;
|
||||
}
|
||||
[domain, action, index, repository]
|
||||
if domain == "pull" && matches!(action.as_str(), "commits" | "files" | "reviews") =>
|
||||
{
|
||||
pull_details(
|
||||
&configuration,
|
||||
client,
|
||||
&scope(selection, Some(repository))?,
|
||||
number(index)?,
|
||||
action,
|
||||
@@ -363,491 +208,205 @@ fn parse_yaml<T: DeserializeOwned>(input: &str) -> Result<T, Box<dyn Error>> {
|
||||
Ok(serde_yaml::from_str(input)?)
|
||||
}
|
||||
|
||||
async fn create_issue(
|
||||
configuration: &apis::configuration::Configuration,
|
||||
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());
|
||||
}
|
||||
async fn create_issue(client: &Client, scope: &RepositoryScope) -> Result<(), Box<dyn Error>> {
|
||||
let input: CreateIssueInput = read_yaml()?;
|
||||
print_issue(
|
||||
&apis::issue_api::issue_create_issue(
|
||||
configuration,
|
||||
&scope.owner,
|
||||
&scope.repository,
|
||||
Some(body),
|
||||
)
|
||||
.await?,
|
||||
&client
|
||||
.create_issue(
|
||||
scope,
|
||||
CreateIssue {
|
||||
option: input.issue,
|
||||
label_names: input.label_names,
|
||||
milestone_name: input.milestone_name,
|
||||
},
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn edit_issue(
|
||||
configuration: &apis::configuration::Configuration,
|
||||
async fn edit_issues(
|
||||
client: &Client,
|
||||
scope: &RepositoryScope,
|
||||
index: i64,
|
||||
indexes: &[i64],
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let issue = apis::issue_api::issue_edit_issue(
|
||||
configuration,
|
||||
&scope.owner,
|
||||
&scope.repository,
|
||||
index,
|
||||
Some(read_yaml::<EditIssueOption>()?),
|
||||
)
|
||||
.await?;
|
||||
print_issue(&issue);
|
||||
let input: EditIssueInput = read_yaml()?;
|
||||
for issue in client
|
||||
.edit_issues(
|
||||
scope,
|
||||
indexes,
|
||||
EditIssue {
|
||||
option: input.issue,
|
||||
replace_labels: None,
|
||||
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(())
|
||||
}
|
||||
|
||||
async fn delete_issue(
|
||||
configuration: &apis::configuration::Configuration,
|
||||
client: &Client,
|
||||
scope: &RepositoryScope,
|
||||
index: i64,
|
||||
) -> 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}.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_comments(
|
||||
configuration: &apis::configuration::Configuration,
|
||||
client: &Client,
|
||||
scope: &RepositoryScope,
|
||||
index: i64,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let comments = apis::issue_api::issue_get_comments(
|
||||
configuration,
|
||||
&scope.owner,
|
||||
&scope.repository,
|
||||
index,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let comments = client.issue_comments(scope, index).await?;
|
||||
print_comments(&comments);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_comment(
|
||||
configuration: &apis::configuration::Configuration,
|
||||
client: &Client,
|
||||
scope: &RepositoryScope,
|
||||
index: i64,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let body = read_stdin()?.trim_end().to_owned();
|
||||
let comment = apis::issue_api::issue_create_comment(
|
||||
configuration,
|
||||
&scope.owner,
|
||||
&scope.repository,
|
||||
index,
|
||||
Some(CreateIssueCommentOption::new(body)),
|
||||
)
|
||||
.await?;
|
||||
let comment = client.create_issue_comment(scope, index, body).await?;
|
||||
print_comments(&[comment]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_milestones(
|
||||
configuration: &apis::configuration::Configuration,
|
||||
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?;
|
||||
async fn list_milestones(client: &Client, scope: &RepositoryScope) -> Result<(), Box<dyn Error>> {
|
||||
let milestones = client.milestones(scope).await?;
|
||||
print_milestones(&milestones);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn show_milestone(
|
||||
configuration: &apis::configuration::Configuration,
|
||||
client: &Client,
|
||||
scope: &RepositoryScope,
|
||||
id: &str,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let milestone =
|
||||
apis::issue_api::issue_get_milestone(configuration, &scope.owner, &scope.repository, id)
|
||||
.await?;
|
||||
let milestone = client.milestone(scope, number(id)?).await?;
|
||||
print_milestone(&milestone);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_milestone(
|
||||
configuration: &apis::configuration::Configuration,
|
||||
scope: &RepositoryScope,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
async fn create_milestone(client: &Client, scope: &RepositoryScope) -> Result<(), Box<dyn Error>> {
|
||||
let body: CreateMilestoneOption = read_yaml()?;
|
||||
if body.title.as_deref().unwrap_or_default().trim().is_empty() {
|
||||
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?;
|
||||
let milestone = client.create_milestone(scope, body).await?;
|
||||
print_milestone(&milestone);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn edit_milestone(
|
||||
configuration: &apis::configuration::Configuration,
|
||||
client: &Client,
|
||||
scope: &RepositoryScope,
|
||||
id: &str,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let milestone = apis::issue_api::issue_edit_milestone(
|
||||
configuration,
|
||||
&scope.owner,
|
||||
&scope.repository,
|
||||
id,
|
||||
Some(read_yaml::<EditMilestoneOption>()?),
|
||||
)
|
||||
.await?;
|
||||
let milestone = client
|
||||
.edit_milestone(scope, id, read_yaml::<EditMilestoneOption>()?)
|
||||
.await?;
|
||||
print_milestone(&milestone);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_milestone(
|
||||
configuration: &apis::configuration::Configuration,
|
||||
client: &Client,
|
||||
scope: &RepositoryScope,
|
||||
id: &str,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
apis::issue_api::issue_delete_milestone(configuration, &scope.owner, &scope.repository, id)
|
||||
.await?;
|
||||
client.delete_milestone(scope, id).await?;
|
||||
println!("Deleted milestone {id}.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_pulls(
|
||||
configuration: &apis::configuration::Configuration,
|
||||
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);
|
||||
async fn list_pulls(client: &Client, scope: &RepositoryScope) -> Result<(), Box<dyn Error>> {
|
||||
print_pulls(&client.repository_pulls(scope, "open", 1, 30).await?.items);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn show_pull(
|
||||
configuration: &apis::configuration::Configuration,
|
||||
client: &Client,
|
||||
scope: &RepositoryScope,
|
||||
index: i64,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let pull = apis::repository_api::repo_get_pull_request(
|
||||
configuration,
|
||||
&scope.owner,
|
||||
&scope.repository,
|
||||
index,
|
||||
)
|
||||
.await?;
|
||||
let pull = client.pull(scope, index).await?;
|
||||
print_pull(&pull);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_pull(
|
||||
configuration: &apis::configuration::Configuration,
|
||||
scope: &RepositoryScope,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
async fn create_pull(client: &Client, scope: &RepositoryScope) -> Result<(), Box<dyn Error>> {
|
||||
let body: CreatePullRequestOption = read_yaml()?;
|
||||
if [
|
||||
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?;
|
||||
let pull = client.create_pull(scope, body).await?;
|
||||
print_pull(&pull);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn edit_pull(
|
||||
configuration: &apis::configuration::Configuration,
|
||||
client: &Client,
|
||||
scope: &RepositoryScope,
|
||||
index: i64,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let pull = apis::repository_api::repo_edit_pull_request(
|
||||
configuration,
|
||||
&scope.owner,
|
||||
&scope.repository,
|
||||
index,
|
||||
Some(read_yaml::<EditPullRequestOption>()?),
|
||||
)
|
||||
.await?;
|
||||
let pull = client
|
||||
.edit_pull(scope, index, read_yaml::<EditPullRequestOption>()?)
|
||||
.await?;
|
||||
print_pull(&pull);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn merge_pull(
|
||||
configuration: &apis::configuration::Configuration,
|
||||
client: &Client,
|
||||
scope: &RepositoryScope,
|
||||
index: i64,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
apis::repository_api::repo_merge_pull_request(
|
||||
configuration,
|
||||
&scope.owner,
|
||||
&scope.repository,
|
||||
index,
|
||||
Some(read_yaml::<MergePullRequestOption>()?),
|
||||
)
|
||||
.await?;
|
||||
client
|
||||
.merge_pull(scope, index, read_yaml::<MergePullRequestOption>()?)
|
||||
.await?;
|
||||
println!("Merged pull request #{index}.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn pull_details(
|
||||
configuration: &apis::configuration::Configuration,
|
||||
client: &Client,
|
||||
scope: &RepositoryScope,
|
||||
index: i64,
|
||||
action: &str,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
match action {
|
||||
"commits" => print_commits(
|
||||
&apis::repository_api::repo_get_pull_request_commits(
|
||||
configuration,
|
||||
&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?,
|
||||
),
|
||||
"commits" => print_commits(&client.pull_commits(scope, index).await?),
|
||||
"files" => print_files(&client.pull_files(scope, index).await?),
|
||||
"reviews" => print_reviews(&client.pull_reviews(scope, index).await?),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_issues(issues: &[Issue]) {
|
||||
println!("INDEX\tSTATE\tTITLE\tUPDATED");
|
||||
for issue in issues {
|
||||
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("-")
|
||||
);
|
||||
}
|
||||
}
|
||||
mod arguments;
|
||||
mod help;
|
||||
mod output;
|
||||
|
||||
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()),
|
||||
);
|
||||
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()
|
||||
}
|
||||
use arguments::*;
|
||||
pub(crate) use help::{domain_help, subcommand_help};
|
||||
use output::*;
|
||||
|
||||
#[cfg(test)]
|
||||
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());
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
|
||||
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]
|
||||
name = "gotcha_gitea"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
description = "A small, reusable Rust client for the Gitea API"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -11,3 +11,5 @@ gitea-openapi = { package = "gitea-client", version = "=1.25.2", default-feature
|
||||
reqwest.workspace = true
|
||||
serde.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::{
|
||||
Client as HttpClient,
|
||||
header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderValue},
|
||||
};
|
||||
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};
|
||||
|
||||
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>;
|
||||
|
||||
#[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)]
|
||||
pub enum Error {
|
||||
Configuration(String),
|
||||
InvalidInput(String),
|
||||
Forbidden(String),
|
||||
Transport(reqwest::Error),
|
||||
Generated(String),
|
||||
Api { status: StatusCode, message: String },
|
||||
@@ -24,10 +101,12 @@ impl fmt::Display for Error {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
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::Generated(message) => formatter.write_str(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)]
|
||||
pub struct Client {
|
||||
api_url: Url,
|
||||
forgejo_api_url: Url,
|
||||
http: HttpClient,
|
||||
provider: Provider,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
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)
|
||||
.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") {
|
||||
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() {
|
||||
return Err(Error::Configuration(
|
||||
"Gitea URL must not contain credentials".into(),
|
||||
"Server URL must not contain credentials".into(),
|
||||
));
|
||||
}
|
||||
if !instance_url.path().ends_with('/') {
|
||||
@@ -75,27 +170,63 @@ impl Client {
|
||||
|
||||
let api_url = instance_url
|
||||
.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();
|
||||
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
|
||||
if let Some(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);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
api_url,
|
||||
forgejo_api_url,
|
||||
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 {
|
||||
&self.api_url
|
||||
}
|
||||
|
||||
pub fn provider(&self) -> Provider {
|
||||
self.provider
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
base_path: self.api_url.as_str().trim_end_matches('/').into(),
|
||||
client: self.http.clone(),
|
||||
@@ -142,11 +273,27 @@ impl Client {
|
||||
}
|
||||
|
||||
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())
|
||||
.await
|
||||
.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> {
|
||||
apis::user_api::user_get_current(&self.configuration())
|
||||
.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)]
|
||||
struct ApiError {
|
||||
#[serde(default)]
|
||||
@@ -184,7 +338,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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!(
|
||||
client.api_url().as_str(),
|
||||
@@ -201,7 +360,20 @@ mod tests {
|
||||
client.configuration().base_path,
|
||||
"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();
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
<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>
|
||||
|
||||
@@ -7,19 +7,173 @@
|
||||
objects = {
|
||||
|
||||
/* 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 */; };
|
||||
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 */
|
||||
|
||||
/* 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 */
|
||||
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; };
|
||||
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>"; };
|
||||
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 */
|
||||
|
||||
/* 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 */
|
||||
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 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DDAABE6B13ADC6D08D9438AF /* Assets.xcassets */,
|
||||
F121BE52F7C9C8780341F988 /* PrivacyInfo.xcprivacy */,
|
||||
94721140EFE7F8E7CD0F5C0B /* Generated */,
|
||||
710A50F51478401FC642E6E3 /* Sources */,
|
||||
1E34F71186DBE1669D42B546 /* Widgets */,
|
||||
F059299C038F3CAFCE470831 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
@@ -28,6 +182,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5FB3250A93766966A60A685E /* Gotcha.app */,
|
||||
52ABD1B07CEEF00263038653 /* GotchaWidgets.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -39,20 +194,44 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 7E8DF3DDA64C8C998FF3BB1D /* Build configuration list for PBXNativeTarget "Gotcha" */;
|
||||
buildPhases = (
|
||||
E01FE2CE8A64C51D25060BE9 /* Build Rust core */,
|
||||
409DBF67E9C2743801B8F8A4 /* Sources */,
|
||||
6334AD54CF86DAD2EC201EBC /* Build Rust app */,
|
||||
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 = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = Gotcha;
|
||||
name = GotchaWidgets;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = Gotcha;
|
||||
productReference = 5FB3250A93766966A60A685E /* Gotcha.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
productName = GotchaWidgets;
|
||||
productReference = 52ABD1B07CEEF00263038653 /* GotchaWidgets.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
@@ -63,6 +242,14 @@
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 1430;
|
||||
TargetAttributes = {
|
||||
60C9DF1AB4A7858833302BA5 = {
|
||||
DevelopmentTeam = MU22FMRGK8;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
AC111BD5DA5739822FCD7926 = {
|
||||
DevelopmentTeam = MU22FMRGK8;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 807BC8918EC1B0F31384F2B7 /* Build configuration list for PBXProject "Gotcha" */;
|
||||
@@ -74,12 +261,17 @@
|
||||
);
|
||||
mainGroup = A8558BC8DD12191B80F52573;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
85EFB86C5B2FDE8DEAD98A24 /* XCRemoteSwiftPackageReference "HighlighterSwift" */,
|
||||
0B96240AE4FB1E82EE0D6B17 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = F059299C038F3CAFCE470831 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
60C9DF1AB4A7858833302BA5 /* Gotcha */,
|
||||
AC111BD5DA5739822FCD7926 /* GotchaWidgets */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -90,13 +282,22 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
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;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
6334AD54CF86DAD2EC201EBC /* Build Rust app */ = {
|
||||
E01FE2CE8A64C51D25060BE9 /* Build Rust core */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -106,28 +307,93 @@
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Build Rust app";
|
||||
name = "Build Rust core";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH)",
|
||||
"$(DERIVED_FILE_DIR)/rust/libgotcha_core.a",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
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 */
|
||||
|
||||
/* 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 */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
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;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
D934DE8051607244EA19B70A /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = AC111BD5DA5739822FCD7926 /* GotchaWidgets */;
|
||||
targetProxy = EFB9473AB945D16F3AA3B49B /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
2F34E9C13F86A9A4A187A561 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
@@ -135,15 +401,21 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = Gotcha.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = MU22FMRGK8;
|
||||
INFOPLIST_FILE = Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
LIBRARY_SEARCH_PATHS = "$(inherited) $(DERIVED_FILE_DIR)/rust";
|
||||
OTHER_LDFLAGS = "$(inherited) -lgotcha_core";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.rfc1437.gotcha;
|
||||
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;
|
||||
};
|
||||
@@ -153,15 +425,21 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = Gotcha.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = MU22FMRGK8;
|
||||
INFOPLIST_FILE = Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
LIBRARY_SEARCH_PATHS = "$(inherited) $(DERIVED_FILE_DIR)/rust";
|
||||
OTHER_LDFLAGS = "$(inherited) -lgotcha_core";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.rfc1437.gotcha;
|
||||
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;
|
||||
};
|
||||
@@ -199,6 +477,7 @@
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
@@ -217,6 +496,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
MARKETING_VERSION = 1.1;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
@@ -228,6 +508,31 @@
|
||||
};
|
||||
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 */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -262,6 +567,7 @@
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
@@ -274,6 +580,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
MARKETING_VERSION = 1.1;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -284,9 +591,43 @@
|
||||
};
|
||||
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 */
|
||||
|
||||
/* 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" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
@@ -306,6 +647,38 @@
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
/* 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 */;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>de.rfc1437.gotcha.notifications.refresh</string>
|
||||
</array>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
@@ -17,9 +21,26 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<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>
|
||||
<string>1</string>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
</array>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<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