Compare commits

...

81 Commits

Author SHA1 Message Date
Georg Bauer
49d9fdcc9a Bump iOS version to 1.1
Some checks failed
Tagged release / prepare-release (push) Successful in 4s
Tagged release / build-unix (push) Successful in 21m37s
Tagged release / build-windows (push) Successful in 9m11s
Tagged release / publish-release (push) Successful in 2s
Weekly OSV dependency audit / dependency-audit (push) Failing after 5s
2026-08-15 20:32:56 +02:00
Georg Bauer
ac6de7d4f2 Fix primary navigation editing 2026-08-15 20:18:51 +02:00
Georg Bauer
59bcddfadf Add first-class Actions management (#63) 2026-08-15 20:06:52 +02:00
Georg Bauer
f84ab774e6 Add full-server activity to iOS home (#65) 2026-08-15 17:25:25 +02:00
Georg Bauer
6d271e7402 Add milestone progress to the TUI (#69) 2026-08-15 15:18:51 +02:00
Georg Bauer
e5131f663c Add activity type icons to the TUI (#68) 2026-08-15 15:10:17 +02:00
Georg Bauer
bac15ab34a Add tagged CLI and TUI releases (#67) 2026-08-15 14:27:21 +02:00
Georg Bauer
ced28ba1e6 Add native iOS notifications (#64) 2026-08-15 14:21:04 +02:00
Georg Bauer
69d1709523 Preserve detail navigation origin (#66) 2026-08-15 12:06:41 +02:00
Georg Bauer
2f5269a6e9 Document AltStore-only iPhone deployment 2026-08-15 11:56:50 +02:00
Georg Bauer
8e2feac18e Fix repository paths containing spaces (#62) 2026-08-15 11:41:04 +02:00
Georg Bauer
efdf5ee4c3 Document OSV bincode advisory exception
All checks were successful
Weekly OSV dependency audit / dependency-audit (push) Successful in 4s
2026-08-09 16:15:15 +02:00
Georg Bauer
fd79939751 Add first-class Actions CLI support 2026-08-09 16:03:18 +02:00
Georg Bauer
8ffb20a0d7 Add weekly OSV dependency audit 2026-08-09 15:51:32 +02:00
Georg Bauer
2b4a7673f7 docs: link AltStore distribution 2026-08-07 07:18:47 +02:00
Georg Bauer
c1cfaa3d23 Remember the TUI server selection 2026-08-05 17:50:09 +02:00
Georg Bauer
61e96cc6cf Format TUI commit activity previews 2026-08-04 19:12:57 +02:00
Georg Bauer
aafde7e7e8 Highlight TUI content previews 2026-08-04 19:04:30 +02:00
Georg Bauer
ccaa9204f0 Make q quit the TUI 2026-08-04 18:50:00 +02:00
Georg Bauer
9d7ce83cb1 Use widget space by family 2026-08-03 22:44:04 +02:00
Georg Bauer
1a43bf1ba8 Restore TUI key hints after updates 2026-08-03 22:24:30 +02:00
Georg Bauer
c7c44676fb Color TUI commit graph lanes 2026-08-03 22:20:38 +02:00
Georg Bauer
95526f8956 Align TUI mouse interactions 2026-08-03 22:13:10 +02:00
Georg Bauer
c67d274a95 Add configurable iPhone widgets 2026-08-03 22:06:32 +02:00
Georg Bauer
2534010d1d Add Ratatui terminal application 2026-08-03 21:15:16 +02:00
Georg Bauer
976742e173 Add server picker to Home 2026-08-03 20:38:35 +02:00
Georg Bauer
9a0b179a0e Add server editing and deletion 2026-08-03 20:34:39 +02:00
Georg Bauer
0f1d109af7 chore: updated readme 2026-08-03 19:53:09 +02:00
Georg Bauer
7376e6cee3 Use Forgejo-compatible repository contents API 2026-08-03 18:29:10 +02:00
Georg Bauer
d47b21d6a6 Support Forgejo servers 2026-08-03 18:14:21 +02:00
Georg Bauer
46cca8fdd2 Prepare Gotcha 1.0 for release 2026-08-03 17:54:07 +02:00
Georg Bauer
1c4a6efd13 Separate commit details from changed files 2026-08-03 17:14:41 +02:00
Georg Bauer
d3bc65dee5 chore(git): ignore Xcode user data 2026-08-01 21:03:09 +02:00
Georg Bauer
c0b03f26ac feat(ios): extend swipe actions to milestones 2026-08-01 20:57:16 +02:00
Georg Bauer
5a48456333 Close, reopen, and delete issues on iPhone 2026-08-01 15:48:14 +02:00
Georg Bauer
d0ec533da6 Render descriptions with shared Markdown preview
Use one Markdown presentation for free-form content.

- issue and pull-request descriptions and comments
- milestone and commit descriptions
2026-08-01 15:24:01 +02:00
Georg Bauer
047f0ab0dc Allow milestones to be closed and reopened 2026-08-01 14:58:49 +02:00
Georg Bauer
3a69b70395 Show commit details above changed files 2026-08-01 14:27:28 +02:00
Georg Bauer
5396a805b0 chore(docs): document CLI build 2026-07-31 21:35:53 +02:00
Georg Bauer
16ecf68bf8 Show branch starts in commit history 2026-07-31 21:30:29 +02:00
Georg Bauer
a3962449a2 Add server-side work item search 2026-07-31 21:03:01 +02:00
Georg Bauer
af392209e8 chore: more on the main usecase for this app 2026-07-31 20:31:11 +02:00
Georg Bauer
2c77f4eef2 chore: quick note on the name 2026-07-31 20:18:44 +02:00
Georg Bauer
128be97e20 Show milestone and pull request states 2026-07-31 18:39:10 +02:00
Georg Bauer
acf2c839ad Show labels on issue details 2026-07-31 18:27:57 +02:00
Georg Bauer
0a38dbe9f2 Scope repository favorites by pane 2026-07-31 18:19:02 +02:00
Georg Bauer
9c7cef3869 Center the Home activity strip 2026-07-31 18:00:51 +02:00
Georg Bauer
de49db0910 Show and clear active filters 2026-07-31 17:54:15 +02:00
Georg Bauer
eadbadd3cb Round commit graph connections 2026-07-31 17:44:09 +02:00
Georg Bauer
dba6359d30 Load filtered home activity streams 2026-07-31 17:33:45 +02:00
Georg Bauer
822cf1f769 Match pull request file list styling 2026-07-31 17:21:38 +02:00
Georg Bauer
5b29e18942 Show diffs without word wrapping 2026-07-31 17:14:34 +02:00
Georg Bauer
e39695fc60 Show issue state icons in lists 2026-07-31 17:07:13 +02:00
Georg Bauer
f38c3c4939 Centralize Gitea domain workflows 2026-07-31 16:44:16 +02:00
Georg Bauer
b468a2670c Complete Gotcha issue CLI workflow 2026-07-31 16:10:50 +02:00
Georg Bauer
0583d05170 Add path-filtered repository history 2026-07-31 15:43:07 +02:00
Georg Bauer
c4e64b5c0c Add milestone creation and editing 2026-07-31 15:30:45 +02:00
Georg Bauer
da0a3aecb4 Add issue comment editing 2026-07-31 15:17:09 +02:00
Georg Bauer
33f9eb809c Add paginated panel loading 2026-07-31 15:02:21 +02:00
Georg Bauer
228879da15 Add issue creation and editing 2026-07-31 14:39:17 +02:00
Georg Bauer
86200b4668 Add pull request milestone features 2026-07-31 14:22:21 +02:00
Georg Bauer
c5ecb8f87d Keep source files below navigation bar 2026-07-31 14:13:27 +02:00
Georg Bauer
a2615d5d83 Enforce Rust and Swift ownership boundary 2026-07-31 14:10:00 +02:00
Georg Bauer
6eb27537e8 Add issue label and milestone filters 2026-07-31 13:42:06 +02:00
Georg Bauer
6374636b04 Show issue state beside title 2026-07-31 12:58:54 +02:00
Georg Bauer
c44e5f19c7 Fix home pull activity filtering 2026-07-31 12:35:39 +02:00
Georg Bauer
5e00c108a4 Add home activity filters 2026-07-31 12:26:00 +02:00
Georg Bauer
9fc9e3f13c Document UI release verification 2026-07-31 12:07:43 +02:00
Georg Bauer
50bb8f94c2 Add Markdown file previews 2026-07-31 12:02:41 +02:00
Georg Bauer
178094fd0f Fix source view rendering while scrolling 2026-07-31 11:48:45 +02:00
Georg Bauer
f85f955a41 Fix horizontal source scrolling 2026-07-31 11:39:50 +02:00
Georg Bauer
7b2b431dfd Add repository file browser 2026-07-31 11:25:46 +02:00
Georg Bauer
4e4dfb0db9 Expand activity strip to nine months 2026-07-31 10:56:44 +02:00
Georg Bauer
82fadda983 Show six labeled activity months 2026-07-31 10:52:40 +02:00
Georg Bauer
201f32f125 Fix server selection from pull requests 2026-07-31 10:13:46 +02:00
Georg Bauer
f201814d54 Replace Slint UI with UIKit and add milestones
Fixes #2.\nFixes #9.
2026-07-31 09:52:24 +02:00
Georg Bauer
8f9a4dfc00 refactor: move UI into Slint files 2026-07-30 18:39:26 +02:00
Georg Bauer
1e3241bf65 feat: add adaptive app appearance 2026-07-30 18:28:47 +02:00
Georg Bauer
d8a63c00f5 feat: add selectable icon styles 2026-07-30 18:13:42 +02:00
Georg Bauer
8c383e3672 feat: improve repository history navigation 2026-07-30 17:40:34 +02:00
Georg Bauer
c7de716b8e Add pull request and repository browsing 2026-07-30 16:12:22 +02:00
106 changed files with 33091 additions and 5885 deletions

194
.gitea/scripts/release.py Normal file
View 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")

View 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 .

View 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
View File

@@ -1,3 +1,5 @@
/target
.env
xcuserdata/
*.xcuserstate
/distribution/

109
AGENTS.md
View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

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

View File

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

View 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?,
))
}
}

View 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?,
))
}
}

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

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

View File

@@ -0,0 +1,9 @@
mod actions;
mod content;
mod issues;
mod milestones;
mod notifications;
mod pulls;
mod repositories;
mod servers;
mod widgets;

View 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, &notifications);
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", &notifications),
"2026-08-15T10:00:01Z"
);
}
}

View 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?,
))
}
}

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

View 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());
}
}

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

View 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;

View 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);
}
}

View 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,
}
}

View 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(),
}
}

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

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

View 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,
}
}

View 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(&notification) {
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);
}
}

View 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
View 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();
}
}

View File

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

View File

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

View File

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

View File

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

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

View 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,
}
}

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

View 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());
}

View File

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

View 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
View 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
View 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
View 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
View 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;

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

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

View File

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

View 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);
}
}

View 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(&notification(
"Issue",
"https://gitea.example/api/v1/repos/octo/demo/issues/42"
)),
NotificationTarget::Issue {
owner: "octo".into(),
repository: "demo".into(),
number: 42,
}
);
assert_eq!(
target(&notification(
"Pull",
"https://gitea.example/api/v1/repos/octo/demo/pulls/7"
)),
NotificationTarget::Pull {
owner: "octo".into(),
repository: "demo".into(),
number: 7,
}
);
assert_eq!(
target(&notification(
"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
View 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)
}
}

View 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;

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

File diff suppressed because it is too large Load Diff

344
crates/tui/src/editor.rs Normal file
View 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
View 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
View 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
);
}
}

View File

@@ -0,0 +1,7 @@
module gotcha_core {
header "gotcha_coreFFI.h"
export *
use "Darwin"
use "_Builtin_stdbool"
use "_Builtin_stdint"
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
#include "Generated/gotcha_coreFFI.h"

View File

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

View File

@@ -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 */;
}

View File

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

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

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

View File

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

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

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

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

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

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

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

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

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

View 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 cant 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)
}
}

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

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

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

View 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 Cant 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 cant 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
}
}

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

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

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

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

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

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

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

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

View 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 doesnt 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"
},
])
}
}

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