@@ -1,5 +1,11 @@
|
|||||||
# Repository instructions
|
# Repository instructions
|
||||||
|
|
||||||
|
## Gitea issue workflow
|
||||||
|
|
||||||
|
Use the `tea` command-line tool as the preferred way to list, inspect, create,
|
||||||
|
and update Gitea issues. Run it from the repository root so it can use the
|
||||||
|
configured remote, for example `tea issues` or `tea issues 1`.
|
||||||
|
|
||||||
## Required pre-commit gates
|
## Required pre-commit gates
|
||||||
|
|
||||||
Run every gate below from the repository root before committing. Every command
|
Run every gate below from the repository root before committing. Every command
|
||||||
|
|||||||
4874
Cargo.lock
generated
4874
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -14,4 +14,3 @@ serde_json = "1"
|
|||||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||||
rpassword = "7"
|
rpassword = "7"
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
slint = { version = "=1.17.1", default-features = false, features = ["std", "backend-winit", "renderer-skia", "compat-1-2"] }
|
|
||||||
|
|||||||
35
README.md
35
README.md
@@ -1,16 +1,18 @@
|
|||||||
# Gotcha
|
# Gotcha
|
||||||
|
|
||||||
Gotcha is a lightweight Gitea client built in Rust. Development starts with a
|
Gotcha is a lightweight Gitea client with a reusable Rust core, a CLI, and a
|
||||||
reusable API crate and a CLI test bed; the same crate will back the Slint iOS
|
native iOS application. The iOS interface is UIKit/Swift; UniFFI exposes the
|
||||||
application.
|
Rust application logic to Swift.
|
||||||
|
|
||||||
## Workspace
|
## Workspace
|
||||||
|
|
||||||
- `gotcha_gitea`: reusable asynchronous Gitea API client with the complete
|
- `gotcha_gitea`: reusable asynchronous Gitea API client with the complete
|
||||||
typed Gitea 1.25 API and model surface
|
typed Gitea 1.25 API and model surface
|
||||||
- `gotcha`: CLI for typed common operations and arbitrary API requests
|
- `gotcha`: CLI for typed common operations and arbitrary API requests
|
||||||
- `gotcha-app`: Slint iOS client for servers, owned repositories, favorites,
|
- `gotcha-app`: Rust application core and UniFFI API used by iOS
|
||||||
open issues, and issue details; bundle identifier `de.rfc1437.gotcha`
|
- `ios`: native UIKit application for activity, repositories, favorites,
|
||||||
|
issues, milestones, pull requests, commit history, changed files, and diffs; bundle
|
||||||
|
identifier `de.rfc1437.gotcha`
|
||||||
|
|
||||||
The generated API modules and models cover every Gitea 1.25 operation. The
|
The generated API modules and models cover every Gitea 1.25 operation. The
|
||||||
low-level request API remains available for newer instance-specific endpoints;
|
low-level request API remains available for newer instance-specific endpoints;
|
||||||
@@ -53,19 +55,23 @@ derives the `owner/repository` scope. Use `--server gitea.example.com` when sele
|
|||||||
ambiguous, or `--url`/`GITEA_URL` for an unconfigured server. Tokens are never
|
ambiguous, or `--url`/`GITEA_URL` for an unconfigured server. Tokens are never
|
||||||
accepted as command-line arguments or environment variables.
|
accepted as command-line arguments or environment variables.
|
||||||
|
|
||||||
## Direction
|
## Architecture
|
||||||
|
|
||||||
1. Exercise and type API areas in the CLI: repositories, issues and pull
|
The Rust core owns Gitea access, validation, presentation records, preferences,
|
||||||
requests, Actions, notifications, organizations, packages, administration.
|
favorites, and Keychain-backed credentials. UniFFI generates the Swift bridge
|
||||||
2. Add the Slint shell and move proven read workflows into touch-first screens.
|
in `ios/Generated`. UIKit owns navigation, lists, text input, menus, scrolling,
|
||||||
3. Add write workflows and iOS integrations such as sharing, notifications,
|
pull-to-refresh, appearance, and other platform behavior.
|
||||||
|
|
||||||
|
New API workflows should first be exercised and typed in the CLI, then exposed
|
||||||
|
through the Rust core and presented with native UIKit controls. Future work
|
||||||
|
includes write workflows and iOS integrations such as sharing, notifications,
|
||||||
and background refresh.
|
and background refresh.
|
||||||
|
|
||||||
## iOS app
|
## iOS app
|
||||||
|
|
||||||
Server tokens are kept in the Apple Keychain; the JSON preferences contain only
|
Server tokens are kept in the Apple Keychain; JSON preferences contain only
|
||||||
server metadata and favorites. The app requires Xcode, XcodeGen, an installed
|
server metadata, settings, and favorites. The app requires Xcode, XcodeGen, an
|
||||||
iOS Simulator runtime, and Rust's
|
installed iOS Simulator runtime, and Rust's
|
||||||
`aarch64-apple-ios` and `aarch64-apple-ios-sim` targets. Generate the project
|
`aarch64-apple-ios` and `aarch64-apple-ios-sim` targets. Generate the project
|
||||||
after installing those prerequisites:
|
after installing those prerequisites:
|
||||||
|
|
||||||
@@ -76,3 +82,6 @@ open Gotcha.xcodeproj
|
|||||||
```
|
```
|
||||||
|
|
||||||
For a fast host-side check, run `cargo test -p gotcha-app`.
|
For a fast host-side check, run `cargo test -p gotcha-app`.
|
||||||
|
|
||||||
|
See [TESTING.md](TESTING.md) for the complete build, simulator, gesture, and
|
||||||
|
release regression checklist.
|
||||||
|
|||||||
187
TESTING.md
Normal file
187
TESTING.md
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
# Testing Gotcha
|
||||||
|
|
||||||
|
Use this checklist for every iOS release. Add checks when a new screen or
|
||||||
|
interaction ships so the document remains the release regression suite.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- [ ] 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 credentials add and select the server; all data tabs load.
|
||||||
|
- [ ] 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- [ ] 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, 12-month heatmap,
|
||||||
|
and recent activity.
|
||||||
|
- [ ] Activity rows have the correct icon, repository, summary, and date.
|
||||||
|
- [ ] Tap repository, issue, pull-request, and commit activity. Each opens the
|
||||||
|
matching native tab and destination.
|
||||||
|
- [ ] Non-linkable server activity does not navigate or appear tappable.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- [ ] Open a repository; the issue 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 an issue and verify title, state/author metadata, Markdown body, and
|
||||||
|
comments. Links and selectable text use normal iOS interaction.
|
||||||
|
|
||||||
|
## Repositories and commits
|
||||||
|
|
||||||
|
- [ ] The repository list and favorite behavior match the Issues tab.
|
||||||
|
- [ ] 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 and merge connections align with their rows while
|
||||||
|
scrolling.
|
||||||
|
- [ ] Open a commit and verify Changed Files paths and statuses.
|
||||||
|
- [ ] Open a changed file and verify the diff title, old/new line numbers,
|
||||||
|
monospaced text, addition/removal/hunk colors, vertical scrolling, and
|
||||||
|
horizontal scrolling for long lines.
|
||||||
|
- [ ] Long-press diff text and verify normal selection and copying.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- [ ] Each row shows repository/number, title, author/update metadata, comment
|
||||||
|
count, and draft/merged state where applicable.
|
||||||
|
- [ ] Open a pull request and verify title, metadata, Markdown body, comments,
|
||||||
|
changed-file reference, file paths, and statuses.
|
||||||
|
- [ ] 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.
|
||||||
|
- [ ] Each milestone shows its title, description, state, due date, issue
|
||||||
|
counts, and a green/amber closed/open progress bar.
|
||||||
|
- [ ] Open a milestone and verify every assigned issue is listed; tapping an
|
||||||
|
issue opens its normal issue detail.
|
||||||
|
- [ ] 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.
|
||||||
|
- [ ] 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.
|
||||||
|
- [ ] Fresh-install and upgrade passes completed.
|
||||||
|
- [ ] Every supported iOS version and required device class completed.
|
||||||
|
- [ ] Failures are linked in the test record; release blockers are resolved.
|
||||||
@@ -6,18 +6,15 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
|
|
||||||
[[bin]]
|
[lib]
|
||||||
name = "gotcha-app"
|
name = "gotcha_core"
|
||||||
path = "src/main.rs"
|
crate-type = ["lib", "staticlib"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
gotcha_gitea = { path = "../gitea" }
|
gotcha_gitea = { path = "../gitea" }
|
||||||
pulldown-cmark = "0.13"
|
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
security-framework = "3"
|
security-framework = "3"
|
||||||
slint.workspace = true
|
thiserror = "2"
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
uniffi = { version = "0.32", features = ["tokio"] }
|
||||||
[build-dependencies]
|
|
||||||
slint-build = "=1.17.1"
|
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
fn main() {
|
|
||||||
slint_build::compile("ui/app.slint").unwrap();
|
|
||||||
}
|
|
||||||
@@ -7,8 +7,12 @@ use gotcha_gitea::{Client, apis, models};
|
|||||||
use tokio::task::JoinSet;
|
use tokio::task::JoinSet;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
domain::{HistoryCommit, HomeData, RepositoryData, Server},
|
diff,
|
||||||
view::compact_date,
|
domain::{
|
||||||
|
HistoryCommit, HomeData, IssueDetails, MilestoneDetails, PullDetails, RepositoryData,
|
||||||
|
Server,
|
||||||
|
},
|
||||||
|
presentation::compact_date,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn load_repositories(server: &Server) -> Result<Vec<RepositoryData>, String> {
|
pub async fn load_repositories(server: &Server) -> Result<Vec<RepositoryData>, String> {
|
||||||
@@ -81,6 +85,71 @@ pub async fn load_issues(
|
|||||||
.map_err(|error| error.to_string())
|
.map_err(|error| error.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn load_milestones(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
) -> Result<Vec<models::Milestone>, String> {
|
||||||
|
let client =
|
||||||
|
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
|
||||||
|
let configuration = client.configuration();
|
||||||
|
let mut milestones = Vec::new();
|
||||||
|
for page in 1.. {
|
||||||
|
let batch = apis::issue_api::issue_get_milestones_list(
|
||||||
|
&configuration,
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
Some("all"),
|
||||||
|
None,
|
||||||
|
Some(page),
|
||||||
|
Some(100),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
let done = batch.len() < 100;
|
||||||
|
milestones.extend(batch);
|
||||||
|
if done {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(milestones)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_milestone(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
id: i64,
|
||||||
|
) -> Result<MilestoneDetails, String> {
|
||||||
|
let client =
|
||||||
|
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
|
||||||
|
let configuration = client.configuration();
|
||||||
|
let milestone =
|
||||||
|
apis::issue_api::issue_get_milestone(&configuration, owner, repository, &id.to_string())
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
let issues = apis::issue_api::issue_list_issues(
|
||||||
|
&configuration,
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
Some("all"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some("issues"),
|
||||||
|
milestone.title.as_deref(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(1),
|
||||||
|
Some(100),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
Ok(MilestoneDetails { milestone, issues })
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn load_pulls(server: &Server, status: &str) -> Result<Vec<models::Issue>, String> {
|
pub async fn load_pulls(server: &Server, status: &str) -> Result<Vec<models::Issue>, String> {
|
||||||
let client =
|
let client =
|
||||||
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
|
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
|
||||||
@@ -233,6 +302,111 @@ pub async fn load_pull_files(
|
|||||||
Ok(files)
|
Ok(files)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn load_issue(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
number: i64,
|
||||||
|
) -> Result<IssueDetails, String> {
|
||||||
|
let client =
|
||||||
|
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
|
||||||
|
let configuration = client.configuration();
|
||||||
|
let (issue, comments) = tokio::join!(
|
||||||
|
apis::issue_api::issue_get_issue(&configuration, owner, repository, number),
|
||||||
|
apis::issue_api::issue_get_comments(&configuration, owner, repository, number, None, None,),
|
||||||
|
);
|
||||||
|
Ok(IssueDetails {
|
||||||
|
issue: issue.map_err(|error| error.to_string())?,
|
||||||
|
comments: comments.map_err(|error| error.to_string())?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_pull(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
number: i64,
|
||||||
|
) -> Result<PullDetails, String> {
|
||||||
|
let client =
|
||||||
|
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
|
||||||
|
let configuration = client.configuration();
|
||||||
|
let (pull, comments, files) = tokio::join!(
|
||||||
|
apis::repository_api::repo_get_pull_request(&configuration, owner, repository, number,),
|
||||||
|
apis::issue_api::issue_get_comments(&configuration, owner, repository, number, None, None,),
|
||||||
|
load_pull_files(&configuration, owner, repository, number),
|
||||||
|
);
|
||||||
|
Ok(PullDetails {
|
||||||
|
pull: pull.map_err(|error| error.to_string())?,
|
||||||
|
comments: comments.map_err(|error| error.to_string())?,
|
||||||
|
files: files.map_err(|error| error.to_string())?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_commit_files(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
sha: &str,
|
||||||
|
) -> Result<Vec<models::CommitAffectedFiles>, String> {
|
||||||
|
let client =
|
||||||
|
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
|
||||||
|
apis::repository_api::repo_get_single_commit(
|
||||||
|
&client.configuration(),
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
sha,
|
||||||
|
Some(true),
|
||||||
|
None,
|
||||||
|
Some(true),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|commit| commit.files.unwrap_or_default())
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_commit_diff(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
sha: &str,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<diff::Parsed, String> {
|
||||||
|
let client =
|
||||||
|
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
|
||||||
|
apis::repository_api::repo_download_commit_diff_or_patch(
|
||||||
|
&client.configuration(),
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
sha,
|
||||||
|
"diff",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|text| diff::parse_file(&text, path))
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_pull_diff(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
number: i64,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<diff::Parsed, String> {
|
||||||
|
let client =
|
||||||
|
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
|
||||||
|
apis::repository_api::repo_download_pull_diff_or_patch(
|
||||||
|
&client.configuration(),
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
number,
|
||||||
|
"diff",
|
||||||
|
Some(false),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|text| diff::parse_file(&text, path))
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
async fn load_pull_refs(
|
async fn load_pull_refs(
|
||||||
server: &Server,
|
server: &Server,
|
||||||
owner: &str,
|
owner: &str,
|
||||||
|
|||||||
@@ -3,35 +3,6 @@ use std::collections::BTreeSet;
|
|||||||
use gotcha_gitea::models;
|
use gotcha_gitea::models;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
|
||||||
#[serde(rename_all = "lowercase")]
|
|
||||||
pub enum IconStyle {
|
|
||||||
Duotone,
|
|
||||||
Bold,
|
|
||||||
#[default]
|
|
||||||
#[serde(other)]
|
|
||||||
Outline,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IconStyle {
|
|
||||||
pub fn index(self) -> i32 {
|
|
||||||
match self {
|
|
||||||
Self::Outline => 0,
|
|
||||||
Self::Duotone => 1,
|
|
||||||
Self::Bold => 2,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_index(index: i32) -> Option<Self> {
|
|
||||||
match index {
|
|
||||||
0 => Some(Self::Outline),
|
|
||||||
1 => Some(Self::Duotone),
|
|
||||||
2 => Some(Self::Bold),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum AppearanceMode {
|
pub enum AppearanceMode {
|
||||||
@@ -82,8 +53,6 @@ pub struct Preferences {
|
|||||||
#[serde(default = "open_status")]
|
#[serde(default = "open_status")]
|
||||||
pub pull_status: String,
|
pub pull_status: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub icon_style: IconStyle,
|
|
||||||
#[serde(default)]
|
|
||||||
pub appearance: AppearanceMode,
|
pub appearance: AppearanceMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +64,6 @@ impl Default for Preferences {
|
|||||||
last_server: None,
|
last_server: None,
|
||||||
issue_status: open_status(),
|
issue_status: open_status(),
|
||||||
pull_status: open_status(),
|
pull_status: open_status(),
|
||||||
icon_style: IconStyle::default(),
|
|
||||||
appearance: AppearanceMode::default(),
|
appearance: AppearanceMode::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,17 +94,7 @@ pub struct HistoryCommit {
|
|||||||
pub struct State {
|
pub struct State {
|
||||||
pub preferences: Preferences,
|
pub preferences: Preferences,
|
||||||
pub active_server: Option<usize>,
|
pub active_server: Option<usize>,
|
||||||
pub active_repository: Option<(String, String)>,
|
|
||||||
pub active_issue: Option<i64>,
|
|
||||||
pub active_pull: Option<(String, String, i64)>,
|
|
||||||
pub active_commit: Option<String>,
|
|
||||||
pub opened_from_home: bool,
|
|
||||||
pub repositories: Vec<RepositoryData>,
|
pub repositories: Vec<RepositoryData>,
|
||||||
pub issues: Vec<models::Issue>,
|
|
||||||
pub pulls: Vec<models::Issue>,
|
|
||||||
pub branches: Vec<String>,
|
|
||||||
pub active_branch: Option<String>,
|
|
||||||
pub commits: Vec<HistoryCommit>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct HomeData {
|
pub struct HomeData {
|
||||||
@@ -149,6 +107,11 @@ pub struct IssueDetails {
|
|||||||
pub comments: Vec<models::Comment>,
|
pub comments: Vec<models::Comment>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct MilestoneDetails {
|
||||||
|
pub milestone: models::Milestone,
|
||||||
|
pub issues: Vec<models::Issue>,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct PullDetails {
|
pub struct PullDetails {
|
||||||
pub pull: models::PullRequest,
|
pub pull: models::PullRequest,
|
||||||
pub comments: Vec<models::Comment>,
|
pub comments: Vec<models::Comment>,
|
||||||
|
|||||||
355
crates/app/src/lib.rs
Normal file
355
crates/app/src/lib.rs
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use gotcha_gitea::Client;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
mod activity;
|
||||||
|
mod api;
|
||||||
|
mod diff;
|
||||||
|
mod domain;
|
||||||
|
mod presentation;
|
||||||
|
mod storage;
|
||||||
|
|
||||||
|
use api::*;
|
||||||
|
use domain::*;
|
||||||
|
pub use presentation::*;
|
||||||
|
use storage::*;
|
||||||
|
|
||||||
|
uniffi::setup_scaffolding!();
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(uniffi::Object)]
|
||||||
|
pub struct GotchaCore {
|
||||||
|
state: Mutex<State>,
|
||||||
|
startup_error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::export(async_runtime = "tokio")]
|
||||||
|
impl GotchaCore {
|
||||||
|
#[uniffi::constructor]
|
||||||
|
pub fn new() -> Arc<Self> {
|
||||||
|
let (preferences, startup_error) = match load_preferences() {
|
||||||
|
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()
|
||||||
|
}),
|
||||||
|
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 {
|
||||||
|
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 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,
|
||||||
|
) -> Result<u32, GotchaError> {
|
||||||
|
let server = validate_server(&name, &url, &token)?;
|
||||||
|
Client::new(&server.url, Some(&server.token))
|
||||||
|
.map_err(|error| error.to_string())?
|
||||||
|
.current_user()
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
save_server_token(&server)?;
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
state.preferences.servers.push(server);
|
||||||
|
let index = state.preferences.servers.len() - 1;
|
||||||
|
state.preferences.last_server = Some(index);
|
||||||
|
state.active_server = Some(index);
|
||||||
|
save_preferences(&state.preferences)?;
|
||||||
|
Ok(index as u32)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 async fn home(&self) -> Result<HomePage, GotchaError> {
|
||||||
|
let server = self.server()?;
|
||||||
|
let name = server.name.clone();
|
||||||
|
Ok(home_page(name, load_home(&server).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn repositories(&self) -> Result<Vec<RepositoryRow>, GotchaError> {
|
||||||
|
let server = self.server()?;
|
||||||
|
let repositories = load_repositories(&server).await?;
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
state.repositories = repositories;
|
||||||
|
Ok(self.repository_rows(&state))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_favorite(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
) -> 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(&server.url, &owner, &repository);
|
||||||
|
if !state.preferences.favorites.remove(&key) {
|
||||||
|
state.preferences.favorites.insert(key);
|
||||||
|
}
|
||||||
|
save_preferences(&state.preferences)?;
|
||||||
|
Ok(self.repository_rows(&state))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn issues(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
) -> Result<Vec<IssueRow>, GotchaError> {
|
||||||
|
let server = self.server()?;
|
||||||
|
let status = self.state.lock().unwrap().preferences.issue_status.clone();
|
||||||
|
Ok(issue_rows(
|
||||||
|
&load_issues(&server, &owner, &repository, &status).await?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn issue(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
number: i64,
|
||||||
|
) -> Result<IssuePage, GotchaError> {
|
||||||
|
Ok(issue_page(
|
||||||
|
load_issue(&self.server()?, &owner, &repository, number).await?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn milestones(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
) -> Result<Vec<MilestoneRow>, GotchaError> {
|
||||||
|
Ok(milestone_rows(
|
||||||
|
&load_milestones(&self.server()?, &owner, &repository).await?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn milestone(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
id: i64,
|
||||||
|
) -> Result<MilestonePage, GotchaError> {
|
||||||
|
Ok(milestone_page(
|
||||||
|
load_milestone(&self.server()?, &owner, &repository, id).await?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn pulls(&self) -> Result<Vec<PullRow>, GotchaError> {
|
||||||
|
let server = self.server()?;
|
||||||
|
let status = self.state.lock().unwrap().preferences.pull_status.clone();
|
||||||
|
Ok(pull_rows(&load_pulls(&server, &status).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn pull(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
number: i64,
|
||||||
|
) -> Result<PullPage, GotchaError> {
|
||||||
|
Ok(pull_page(
|
||||||
|
load_pull(&self.server()?, &owner, &repository, number).await?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn commits(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
branch: Option<String>,
|
||||||
|
) -> 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 commits = match branch.as_deref() {
|
||||||
|
Some(branch) => load_branch_commits(&server, &owner, &repository, branch).await?,
|
||||||
|
None => load_all_commits(&server, &owner, &repository, &branches).await?,
|
||||||
|
};
|
||||||
|
Ok(commit_page(branches, &commits, branch.is_none()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn commit_files(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
sha: String,
|
||||||
|
) -> Result<Vec<FileRow>, GotchaError> {
|
||||||
|
Ok(commit_file_rows(
|
||||||
|
load_commit_files(&self.server()?, &owner, &repository, &sha).await?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
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?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 repository_rows(&self, state: &State) -> 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(
|
||||||
|
server_url,
|
||||||
|
&repository.owner,
|
||||||
|
&repository.name,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,312 +0,0 @@
|
|||||||
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
|
|
||||||
use slint::StyledText;
|
|
||||||
|
|
||||||
use crate::ui::MarkdownBlock;
|
|
||||||
|
|
||||||
struct Draft {
|
|
||||||
kind: &'static str,
|
|
||||||
source: String,
|
|
||||||
plain: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct Renderer {
|
|
||||||
blocks: Vec<MarkdownBlock>,
|
|
||||||
current: Option<Draft>,
|
|
||||||
lists: Vec<Option<u64>>,
|
|
||||||
links: Vec<(String, bool, bool)>,
|
|
||||||
quote_depth: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn render(source: &str) -> Vec<MarkdownBlock> {
|
|
||||||
let mut renderer = Renderer::default();
|
|
||||||
for event in Parser::new_ext(source, Options::all()) {
|
|
||||||
renderer.event(event);
|
|
||||||
}
|
|
||||||
renderer.finish();
|
|
||||||
renderer.blocks
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Renderer {
|
|
||||||
fn event(&mut self, event: Event<'_>) {
|
|
||||||
match event {
|
|
||||||
Event::Start(tag) => self.start(tag),
|
|
||||||
Event::End(tag) => self.end(tag),
|
|
||||||
Event::Text(text) => self.text(&text),
|
|
||||||
Event::Code(text) | Event::InlineMath(text) | Event::DisplayMath(text) => {
|
|
||||||
if self.is_plain() {
|
|
||||||
self.raw(&text);
|
|
||||||
} else {
|
|
||||||
self.raw(&code_span(&text));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Event::Html(html) | Event::InlineHtml(html) => self.text(&html),
|
|
||||||
Event::FootnoteReference(label) => self.text(&format!("[{label}]")),
|
|
||||||
Event::SoftBreak | Event::HardBreak => self.raw("\n"),
|
|
||||||
Event::Rule => {
|
|
||||||
self.begin("rule", true);
|
|
||||||
self.finish();
|
|
||||||
}
|
|
||||||
Event::TaskListMarker(checked) => self.raw(if checked { "☑ " } else { "☐ " }),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn start(&mut self, tag: Tag<'_>) {
|
|
||||||
match tag {
|
|
||||||
Tag::Paragraph => {
|
|
||||||
if self.current.is_none() {
|
|
||||||
self.begin(
|
|
||||||
if self.quote_depth > 0 {
|
|
||||||
"quote"
|
|
||||||
} else {
|
|
||||||
"paragraph"
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
} else if self
|
|
||||||
.current
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|draft| !draft.source.is_empty() && !draft.source.ends_with(' '))
|
|
||||||
{
|
|
||||||
self.raw("\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Tag::Heading { level, .. } => {
|
|
||||||
self.begin(heading_kind(level), false);
|
|
||||||
self.raw("**");
|
|
||||||
}
|
|
||||||
Tag::BlockQuote(_) => {
|
|
||||||
self.quote_depth += 1;
|
|
||||||
if self.current.is_none() {
|
|
||||||
self.begin("quote", false);
|
|
||||||
} else {
|
|
||||||
self.raw("› ");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Tag::CodeBlock(_) => self.begin("code", true),
|
|
||||||
Tag::HtmlBlock | Tag::MetadataBlock(_) => self.begin("code", true),
|
|
||||||
Tag::List(start) => self.lists.push(start),
|
|
||||||
Tag::Item => {
|
|
||||||
self.begin("list", false);
|
|
||||||
let indent = "\u{00a0}\u{00a0}".repeat(self.lists.len().saturating_sub(1));
|
|
||||||
self.raw(&indent);
|
|
||||||
let prefix = match self.lists.last_mut() {
|
|
||||||
Some(Some(number)) => {
|
|
||||||
let prefix = format!("{number}\\. ");
|
|
||||||
*number += 1;
|
|
||||||
prefix
|
|
||||||
}
|
|
||||||
_ => "• ".into(),
|
|
||||||
};
|
|
||||||
self.raw(&prefix);
|
|
||||||
}
|
|
||||||
Tag::FootnoteDefinition(label) => {
|
|
||||||
self.begin("footnote", false);
|
|
||||||
self.text(&format!("[{label}] "));
|
|
||||||
}
|
|
||||||
Tag::DefinitionList => {}
|
|
||||||
Tag::DefinitionListTitle => {
|
|
||||||
self.begin("paragraph", false);
|
|
||||||
self.raw("**");
|
|
||||||
}
|
|
||||||
Tag::DefinitionListDefinition => {
|
|
||||||
self.begin("paragraph", false);
|
|
||||||
self.raw("↳ ");
|
|
||||||
}
|
|
||||||
Tag::Table(_) => self.begin("table", true),
|
|
||||||
Tag::TableHead | Tag::TableRow | Tag::TableCell => {}
|
|
||||||
Tag::Emphasis => self.marker("*"),
|
|
||||||
Tag::Strong => self.marker("**"),
|
|
||||||
Tag::Strikethrough => self.marker("~~"),
|
|
||||||
Tag::Superscript => self.raw("\\^"),
|
|
||||||
Tag::Subscript => self.raw("\\~"),
|
|
||||||
Tag::Link { dest_url, .. } => self.start_link(dest_url.into_string(), false),
|
|
||||||
Tag::Image { dest_url, .. } => self.start_link(dest_url.into_string(), true),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn end(&mut self, tag: TagEnd) {
|
|
||||||
match tag {
|
|
||||||
TagEnd::Paragraph => {
|
|
||||||
if self.quote_depth == 0
|
|
||||||
&& !self
|
|
||||||
.current
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|draft| draft.kind == "list")
|
|
||||||
{
|
|
||||||
self.finish();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TagEnd::Heading(_) => {
|
|
||||||
self.raw("**");
|
|
||||||
self.finish();
|
|
||||||
}
|
|
||||||
TagEnd::BlockQuote(_) => {
|
|
||||||
self.quote_depth = self.quote_depth.saturating_sub(1);
|
|
||||||
if self.quote_depth == 0 {
|
|
||||||
self.finish();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TagEnd::CodeBlock | TagEnd::HtmlBlock | TagEnd::MetadataBlock(_) => self.finish(),
|
|
||||||
TagEnd::List(_) => {
|
|
||||||
self.lists.pop();
|
|
||||||
}
|
|
||||||
TagEnd::Item | TagEnd::FootnoteDefinition => self.finish(),
|
|
||||||
TagEnd::DefinitionList => {}
|
|
||||||
TagEnd::DefinitionListTitle => {
|
|
||||||
self.raw("**");
|
|
||||||
self.finish();
|
|
||||||
}
|
|
||||||
TagEnd::DefinitionListDefinition => self.finish(),
|
|
||||||
TagEnd::Table => self.finish(),
|
|
||||||
TagEnd::TableHead => self.raw("────────\n"),
|
|
||||||
TagEnd::TableRow => {
|
|
||||||
if let Some(draft) = self.current.as_mut()
|
|
||||||
&& draft.source.ends_with(" │ ")
|
|
||||||
{
|
|
||||||
draft.source.truncate(draft.source.len() - " │ ".len());
|
|
||||||
}
|
|
||||||
self.raw("\n");
|
|
||||||
}
|
|
||||||
TagEnd::TableCell => self.raw(" │ "),
|
|
||||||
TagEnd::Emphasis => self.marker("*"),
|
|
||||||
TagEnd::Strong => self.marker("**"),
|
|
||||||
TagEnd::Strikethrough => self.marker("~~"),
|
|
||||||
TagEnd::Superscript => self.raw("\\^"),
|
|
||||||
TagEnd::Subscript => self.raw("\\~"),
|
|
||||||
TagEnd::Link | TagEnd::Image => self.end_link(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn begin(&mut self, kind: &'static str, plain: bool) {
|
|
||||||
self.finish();
|
|
||||||
self.current = Some(Draft {
|
|
||||||
kind,
|
|
||||||
source: String::new(),
|
|
||||||
plain,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn finish(&mut self) {
|
|
||||||
let Some(draft) = self.current.take() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let source = draft.source.trim_matches('\n');
|
|
||||||
if source.is_empty() && draft.kind != "rule" {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let text = if draft.plain {
|
|
||||||
StyledText::from_plain_text(source)
|
|
||||||
} else {
|
|
||||||
StyledText::from_markdown(source)
|
|
||||||
.unwrap_or_else(|_| StyledText::from_plain_text(source))
|
|
||||||
};
|
|
||||||
self.blocks.push(MarkdownBlock {
|
|
||||||
kind: draft.kind.into(),
|
|
||||||
text,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn text(&mut self, text: &str) {
|
|
||||||
if self.current.is_none() {
|
|
||||||
self.begin(
|
|
||||||
if self.quote_depth > 0 {
|
|
||||||
"quote"
|
|
||||||
} else {
|
|
||||||
"paragraph"
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if self.is_plain() {
|
|
||||||
self.raw(text);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let mut escaped = String::with_capacity(text.len());
|
|
||||||
for character in text.chars() {
|
|
||||||
if character.is_ascii_punctuation() {
|
|
||||||
escaped.push('\\');
|
|
||||||
}
|
|
||||||
escaped.push(character);
|
|
||||||
}
|
|
||||||
self.raw(&escaped);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn raw(&mut self, text: &str) {
|
|
||||||
if self.current.is_none() {
|
|
||||||
self.begin("paragraph", false);
|
|
||||||
}
|
|
||||||
self.current.as_mut().unwrap().source.push_str(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn marker(&mut self, marker: &str) {
|
|
||||||
if !self.is_plain() {
|
|
||||||
self.raw(marker);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_plain(&self) -> bool {
|
|
||||||
self.current.as_ref().is_some_and(|draft| draft.plain)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn start_link(&mut self, url: String, image: bool) {
|
|
||||||
let styled = !self.is_plain();
|
|
||||||
if styled {
|
|
||||||
self.raw(if image { "[🖼 " } else { "[" });
|
|
||||||
} else if image {
|
|
||||||
self.raw("🖼 ");
|
|
||||||
}
|
|
||||||
self.links.push((url, image, styled));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn end_link(&mut self) {
|
|
||||||
let Some((url, _, styled)) = self.links.pop() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if styled {
|
|
||||||
self.raw(&format!("](<{}>)", url.replace('>', "%3E")));
|
|
||||||
} else {
|
|
||||||
self.raw(&format!(" ({url})"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn heading_kind(level: HeadingLevel) -> &'static str {
|
|
||||||
match level {
|
|
||||||
HeadingLevel::H1 => "heading1",
|
|
||||||
HeadingLevel::H2 => "heading2",
|
|
||||||
HeadingLevel::H3 => "heading3",
|
|
||||||
HeadingLevel::H4 => "heading4",
|
|
||||||
HeadingLevel::H5 => "heading5",
|
|
||||||
HeadingLevel::H6 => "heading6",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn code_span(text: &str) -> String {
|
|
||||||
let longest = text
|
|
||||||
.split(|character| character != '`')
|
|
||||||
.map(str::len)
|
|
||||||
.max()
|
|
||||||
.unwrap_or(0);
|
|
||||||
let fence = "`".repeat(longest + 1);
|
|
||||||
format!("{fence} {text} {fence}")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn renders_extended_markdown_as_safe_styled_blocks() {
|
|
||||||
let blocks = render(
|
|
||||||
"# Title\n\n- [x] **done** with [link](https://example.com)\n\n> quoted\n\n```rust\nlet x = 1;\n```\n\n| A | B |\n| - | - |\n| 1 | 2 |",
|
|
||||||
);
|
|
||||||
let kinds: Vec<_> = blocks.iter().map(|block| block.kind.as_str()).collect();
|
|
||||||
assert_eq!(kinds, ["heading1", "list", "quote", "code", "table"]);
|
|
||||||
assert_eq!(
|
|
||||||
blocks[0].text,
|
|
||||||
StyledText::from_markdown("**Title**").unwrap()
|
|
||||||
);
|
|
||||||
assert_eq!(blocks[3].text, StyledText::from_plain_text("let x = 1;"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
800
crates/app/src/presentation.rs
Normal file
800
crates/app/src/presentation.rs
Normal file
@@ -0,0 +1,800 @@
|
|||||||
|
use gotcha_gitea::models;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
activity,
|
||||||
|
domain::{
|
||||||
|
HistoryCommit, HomeData, IssueDetails, MilestoneDetails, PullDetails, RepositoryData,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct ServerRow {
|
||||||
|
pub name: String,
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct RepositoryRow {
|
||||||
|
pub name: String,
|
||||||
|
pub owner: String,
|
||||||
|
pub description: String,
|
||||||
|
pub meta: String,
|
||||||
|
pub favorite: 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 title: String,
|
||||||
|
pub summary: String,
|
||||||
|
pub meta: String,
|
||||||
|
pub milestone: String,
|
||||||
|
pub labels: Vec<LabelRow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct PullRow {
|
||||||
|
pub number: i64,
|
||||||
|
pub owner: String,
|
||||||
|
pub repository: String,
|
||||||
|
pub title: String,
|
||||||
|
pub summary: String,
|
||||||
|
pub meta: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct CommentRow {
|
||||||
|
pub author: String,
|
||||||
|
pub body: String,
|
||||||
|
pub meta: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct IssuePage {
|
||||||
|
pub title: String,
|
||||||
|
pub meta: String,
|
||||||
|
pub milestone: String,
|
||||||
|
pub body: String,
|
||||||
|
pub comments: Vec<CommentRow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct MilestoneRow {
|
||||||
|
pub id: i64,
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
pub meta: String,
|
||||||
|
pub open_issues: i64,
|
||||||
|
pub closed_issues: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct MilestonePage {
|
||||||
|
pub milestone: MilestoneRow,
|
||||||
|
pub issues: Vec<IssueRow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct PullPage {
|
||||||
|
pub title: String,
|
||||||
|
pub meta: String,
|
||||||
|
pub body: String,
|
||||||
|
pub files_ref: String,
|
||||||
|
pub files: Vec<FileRow>,
|
||||||
|
pub comments: Vec<CommentRow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct CommitRow {
|
||||||
|
pub sha: String,
|
||||||
|
pub title: String,
|
||||||
|
pub meta: String,
|
||||||
|
pub refs: String,
|
||||||
|
pub top_lanes: Vec<u32>,
|
||||||
|
pub bottom_lanes: Vec<u32>,
|
||||||
|
pub node_lane: Option<u32>,
|
||||||
|
pub connections: Vec<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct CommitPage {
|
||||||
|
pub branches: Vec<String>,
|
||||||
|
pub commits: Vec<CommitRow>,
|
||||||
|
pub lane_count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct FileRow {
|
||||||
|
pub path: String,
|
||||||
|
pub status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct DiffLine {
|
||||||
|
pub old_number: String,
|
||||||
|
pub new_number: String,
|
||||||
|
pub text: String,
|
||||||
|
pub kind: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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: String,
|
||||||
|
pub title: String,
|
||||||
|
pub detail: String,
|
||||||
|
pub meta: String,
|
||||||
|
pub target: String,
|
||||||
|
pub owner: String,
|
||||||
|
pub repository: String,
|
||||||
|
pub number: i64,
|
||||||
|
pub sha: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct HeatCell {
|
||||||
|
pub week: u32,
|
||||||
|
pub day: u32,
|
||||||
|
pub level: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 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,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn issue_rows(issues: &[models::Issue]) -> Vec<IssueRow> {
|
||||||
|
issues
|
||||||
|
.iter()
|
||||||
|
.map(|issue| IssueRow {
|
||||||
|
number: issue.number.unwrap_or_default(),
|
||||||
|
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 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()?,
|
||||||
|
title: pull
|
||||||
|
.title
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "Untitled pull request".into()),
|
||||||
|
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,
|
||||||
|
) -> CommitPage {
|
||||||
|
let lane_count = if show_graph {
|
||||||
|
commits
|
||||||
|
.iter()
|
||||||
|
.flat_map(|commit| {
|
||||||
|
commit
|
||||||
|
.top_lanes
|
||||||
|
.iter()
|
||||||
|
.chain(commit.bottom_lanes.iter())
|
||||||
|
.chain(commit.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());
|
||||||
|
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(),
|
||||||
|
meta: format!("{author} · {}", compact_date(date)),
|
||||||
|
refs: row.refs.join(" · "),
|
||||||
|
top_lanes: indices(&row.top_lanes),
|
||||||
|
bottom_lanes: indices(&row.bottom_lanes),
|
||||||
|
node_lane: row.node_lane.map(|lane| lane as u32),
|
||||||
|
connections: indices(&row.connections),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
CommitPage {
|
||||||
|
branches,
|
||||||
|
commits,
|
||||||
|
lane_count: lane_count as u32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn issue_page(details: IssueDetails) -> IssuePage {
|
||||||
|
IssuePage {
|
||||||
|
title: details
|
||||||
|
.issue
|
||||||
|
.title
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "Untitled issue".into()),
|
||||||
|
meta: issue_meta(&details.issue),
|
||||||
|
milestone: issue_milestone(&details.issue),
|
||||||
|
body: details
|
||||||
|
.issue
|
||||||
|
.body
|
||||||
|
.filter(|body| !body.is_empty())
|
||||||
|
.unwrap_or_else(|| "No description provided.".into()),
|
||||||
|
comments: details.comments.iter().map(comment_row).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = if pull.merged.unwrap_or(false) {
|
||||||
|
"merged"
|
||||||
|
} else if pull.draft.unwrap_or(false) {
|
||||||
|
"draft"
|
||||||
|
} else {
|
||||||
|
pull.state.as_deref().unwrap_or("unknown")
|
||||||
|
};
|
||||||
|
PullPage {
|
||||||
|
title: pull
|
||||||
|
.title
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "Untitled pull request".into()),
|
||||||
|
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_row).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 diff_page(path: &str, diff: crate::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: line.kind.into(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn home_page(server_name: String, home: HomeData) -> HomePage {
|
||||||
|
let (heat_cells, contribution_count) = heat_cells(&home.heatmap);
|
||||||
|
HomePage {
|
||||||
|
server_name,
|
||||||
|
activities: home.activities.iter().map(activity_row).collect(),
|
||||||
|
heat_cells,
|
||||||
|
contribution_count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() => (
|
||||||
|
"branch-create",
|
||||||
|
format!("Created branch {branch} in {repository}"),
|
||||||
|
),
|
||||||
|
Some(OpType::CommitRepo | OpType::MirrorSyncPush) => {
|
||||||
|
("push", format!("Pushed to {branch} in {repository}"))
|
||||||
|
}
|
||||||
|
Some(OpType::CreateRepo) => ("repo-create", format!("Created {repository}")),
|
||||||
|
Some(OpType::RenameRepo) => ("repo-rename", format!("Renamed {repository}")),
|
||||||
|
Some(OpType::StarRepo) => ("repo-star", format!("Starred {repository}")),
|
||||||
|
Some(OpType::WatchRepo) => ("repo-watch", format!("Started watching {repository}")),
|
||||||
|
Some(OpType::CreateIssue) => ("issue-open", format!("Opened an issue in {repository}")),
|
||||||
|
Some(OpType::CloseIssue) => ("issue-close", format!("Closed an issue in {repository}")),
|
||||||
|
Some(OpType::ReopenIssue) => ("issue-reopen", format!("Reopened an issue in {repository}")),
|
||||||
|
Some(OpType::CommentIssue) => (
|
||||||
|
"issue-comment",
|
||||||
|
format!("Commented on an issue in {repository}"),
|
||||||
|
),
|
||||||
|
Some(OpType::CreatePullRequest) => (
|
||||||
|
"pull-open",
|
||||||
|
format!("Opened a pull request in {repository}"),
|
||||||
|
),
|
||||||
|
Some(OpType::MergePullRequest | OpType::AutoMergePullRequest) => (
|
||||||
|
"pull-merge",
|
||||||
|
format!("Merged a pull request in {repository}"),
|
||||||
|
),
|
||||||
|
Some(OpType::ClosePullRequest) => (
|
||||||
|
"pull-close",
|
||||||
|
format!("Closed a pull request in {repository}"),
|
||||||
|
),
|
||||||
|
Some(OpType::ReopenPullRequest) => (
|
||||||
|
"pull-reopen",
|
||||||
|
format!("Reopened a pull request in {repository}"),
|
||||||
|
),
|
||||||
|
Some(OpType::CommentPull) => (
|
||||||
|
"pull-comment",
|
||||||
|
format!("Commented on a pull request in {repository}"),
|
||||||
|
),
|
||||||
|
Some(OpType::ApprovePullRequest) => (
|
||||||
|
"pull-approve",
|
||||||
|
format!("Approved a pull request in {repository}"),
|
||||||
|
),
|
||||||
|
Some(OpType::RejectPullRequest) => {
|
||||||
|
("pull-reject", format!("Requested changes in {repository}"))
|
||||||
|
}
|
||||||
|
Some(OpType::PushTag) => ("tag-push", format!("Pushed tag {branch} in {repository}")),
|
||||||
|
Some(OpType::DeleteTag) => (
|
||||||
|
"tag-delete",
|
||||||
|
format!("Deleted tag {branch} in {repository}"),
|
||||||
|
),
|
||||||
|
Some(OpType::DeleteBranch) => (
|
||||||
|
"branch-delete",
|
||||||
|
format!("Deleted branch {branch} in {repository}"),
|
||||||
|
),
|
||||||
|
Some(OpType::PublishRelease) => ("release", format!("Published a release in {repository}")),
|
||||||
|
Some(_) | None => ("update", format!("Updated {repository}")),
|
||||||
|
};
|
||||||
|
let target = activity::target(activity);
|
||||||
|
let (target, owner, repository, number, sha) = match target {
|
||||||
|
Some(activity::Target::Repository { owner, repository }) => {
|
||||||
|
("repository", owner, repository, 0, String::new())
|
||||||
|
}
|
||||||
|
Some(activity::Target::Issue {
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
number,
|
||||||
|
}) => ("issue", owner, repository, number, String::new()),
|
||||||
|
Some(activity::Target::Pull {
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
number,
|
||||||
|
}) => ("pull", owner, repository, number, String::new()),
|
||||||
|
Some(activity::Target::Commit {
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
sha,
|
||||||
|
}) => ("commit", owner, repository, 0, sha),
|
||||||
|
None => ("", String::new(), String::new(), 0, String::new()),
|
||||||
|
};
|
||||||
|
ActivityRow {
|
||||||
|
icon: icon.into(),
|
||||||
|
title,
|
||||||
|
detail: activity_detail(activity),
|
||||||
|
meta: compact_date(activity.created.as_deref()),
|
||||||
|
target: target.into(),
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
number,
|
||||||
|
sha,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn activity_detail(activity: &models::Activity) -> String {
|
||||||
|
let text = activity
|
||||||
|
.comment
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|comment| comment.body.as_deref())
|
||||||
|
.or(activity.content.as_deref())
|
||||||
|
.unwrap_or("");
|
||||||
|
if let Ok(payload) = serde_json::from_str::<serde_json::Value>(text) {
|
||||||
|
let commits = payload.get("Len").and_then(|value| value.as_i64());
|
||||||
|
let message = payload
|
||||||
|
.get("HeadCommit")
|
||||||
|
.and_then(|commit| commit.get("Message"))
|
||||||
|
.and_then(|message| message.as_str())
|
||||||
|
.map(summary)
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !message.is_empty() {
|
||||||
|
return match commits {
|
||||||
|
Some(1) => format!("1 commit · {message}"),
|
||||||
|
Some(count) => format!("{count} commits · {message}"),
|
||||||
|
None => message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let text = summary(text);
|
||||||
|
if text.is_empty() {
|
||||||
|
"Server activity".into()
|
||||||
|
} else {
|
||||||
|
text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn heat_cells(data: &[models::UserHeatmapData]) -> (Vec<HeatCell>, i64) {
|
||||||
|
const DAYS: i64 = 52 * 7;
|
||||||
|
let latest = data
|
||||||
|
.iter()
|
||||||
|
.filter_map(|entry| entry.timestamp)
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0)
|
||||||
|
/ 86_400;
|
||||||
|
let start = latest - DAYS + 1;
|
||||||
|
let mut counts = vec![0_i64; DAYS 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 {
|
||||||
|
week: (index / 7) as u32,
|
||||||
|
day: (index % 7) as u32,
|
||||||
|
level: if count == 0 || maximum == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
((count * 4 + maximum - 1) / maximum).clamp(1, 4) as u32
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
(cells, contribution_count)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn comment_row(comment: &models::Comment) -> CommentRow {
|
||||||
|
CommentRow {
|
||||||
|
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()),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_row(file: &models::ChangedFile) -> Option<FileRow> {
|
||||||
|
Some(FileRow {
|
||||||
|
path: file.filename.clone()?,
|
||||||
|
status: file.status.clone().unwrap_or_else(|| "modified".into()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pull_files_ref(pull: &models::PullRequest) -> String {
|
||||||
|
if pull.state.as_deref() == Some("open") {
|
||||||
|
let branch = pull
|
||||||
|
.head
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|head| head.r#ref.as_deref().or(head.label.as_deref()))
|
||||||
|
.unwrap_or("head branch");
|
||||||
|
format!("Files on {branch}")
|
||||||
|
} else if let Some(sha) = pull.merge_commit_sha.as_deref() {
|
||||||
|
format!("Files at merge commit {}", &sha[..sha.len().min(8)])
|
||||||
|
} else {
|
||||||
|
"Files from pull request".into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn issue_milestone(issue: &models::Issue) -> String {
|
||||||
|
issue
|
||||||
|
.milestone
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|milestone| milestone.title.as_deref())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn milestone_row(milestone: &models::Milestone) -> MilestoneRow {
|
||||||
|
let open = milestone.open_issues.unwrap_or_default();
|
||||||
|
let closed = milestone.closed_issues.unwrap_or_default();
|
||||||
|
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(),
|
||||||
|
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
|
||||||
|
),
|
||||||
|
open_issues: open,
|
||||||
|
closed_issues: closed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summary(body: &str) -> String {
|
||||||
|
body.split_whitespace()
|
||||||
|
.take(24)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maps_heat_levels_and_labels() {
|
||||||
|
let heatmap = vec![
|
||||||
|
models::UserHeatmapData {
|
||||||
|
timestamp: Some(100 * 86_400),
|
||||||
|
contributions: Some(1),
|
||||||
|
},
|
||||||
|
models::UserHeatmapData {
|
||||||
|
timestamp: Some(101 * 86_400),
|
||||||
|
contributions: Some(4),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let (cells, total) = heat_cells(&heatmap);
|
||||||
|
assert_eq!((cells.len(), total), (364, 5));
|
||||||
|
assert_eq!((cells[362].level, cells[363].level), (1, 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.open_issues, milestone_row.closed_issues),
|
||||||
|
(3, 2)
|
||||||
|
);
|
||||||
|
let issue = models::Issue {
|
||||||
|
milestone: Some(Box::new(milestone)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(issue_rows(&[issue])[0].milestone, "Version 1");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,31 +96,10 @@ mod tests {
|
|||||||
"https://gitea.example.com|octo/demo"
|
"https://gitea.example.com|octo/demo"
|
||||||
);
|
);
|
||||||
assert_eq!(Preferences::default().pull_status, "open");
|
assert_eq!(Preferences::default().pull_status, "open");
|
||||||
assert_eq!(
|
|
||||||
Preferences::default().icon_style,
|
|
||||||
crate::domain::IconStyle::Outline
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Preferences::default().appearance,
|
Preferences::default().appearance,
|
||||||
crate::domain::AppearanceMode::Auto
|
crate::domain::AppearanceMode::Auto
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
serde_json::from_str::<Preferences>(r#"{"icon_style":"duotone"}"#)
|
|
||||||
.unwrap()
|
|
||||||
.icon_style,
|
|
||||||
crate::domain::IconStyle::Duotone
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::from_str::<Preferences>(r#"{"icon_style":"future-style"}"#)
|
|
||||||
.unwrap()
|
|
||||||
.icon_style,
|
|
||||||
crate::domain::IconStyle::Outline
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
crate::domain::IconStyle::from_index(2),
|
|
||||||
Some(crate::domain::IconStyle::Bold)
|
|
||||||
);
|
|
||||||
assert_eq!(crate::domain::IconStyle::from_index(3), None);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
serde_json::from_str::<Preferences>(r#"{"appearance":"dark"}"#)
|
serde_json::from_str::<Preferences>(r#"{"appearance":"dark"}"#)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
slint::include_modules!();
|
|
||||||
@@ -1,627 +0,0 @@
|
|||||||
use gotcha_gitea::models;
|
|
||||||
use slint::Color;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
activity,
|
|
||||||
domain::{HistoryCommit, HomeData, IssueDetails, Preferences, PullDetails, State},
|
|
||||||
markdown, model,
|
|
||||||
storage::favorite_key,
|
|
||||||
ui::*,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn refresh_servers(ui: &AppWindow, preferences: &Preferences) {
|
|
||||||
ui.set_servers(model(
|
|
||||||
preferences
|
|
||||||
.servers
|
|
||||||
.iter()
|
|
||||||
.map(|server| ServerRow {
|
|
||||||
name: server.name.clone().into(),
|
|
||||||
url: server.url.clone().into(),
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn refresh_repositories(ui: &AppWindow, state: &State) {
|
|
||||||
let server_url = state
|
|
||||||
.active_server
|
|
||||||
.and_then(|i| state.preferences.servers.get(i))
|
|
||||||
.map(|server| server.url.as_str())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let mut repositories = state.repositories.clone();
|
|
||||||
repositories.sort_by_key(|repository| {
|
|
||||||
(
|
|
||||||
!state.preferences.favorites.contains(&favorite_key(
|
|
||||||
server_url,
|
|
||||||
&repository.owner,
|
|
||||||
&repository.name,
|
|
||||||
)),
|
|
||||||
repository.name.to_lowercase(),
|
|
||||||
)
|
|
||||||
});
|
|
||||||
ui.set_repositories(model(
|
|
||||||
repositories
|
|
||||||
.into_iter()
|
|
||||||
.map(|repository| {
|
|
||||||
let favorite = state.preferences.favorites.contains(&favorite_key(
|
|
||||||
server_url,
|
|
||||||
&repository.owner,
|
|
||||||
&repository.name,
|
|
||||||
));
|
|
||||||
RepositoryRow {
|
|
||||||
name: repository.name.into(),
|
|
||||||
owner: repository.owner.into(),
|
|
||||||
description: repository.description.into(),
|
|
||||||
meta: format!(
|
|
||||||
"{} · {} open · {}",
|
|
||||||
repository.language, repository.open_issues, repository.updated
|
|
||||||
)
|
|
||||||
.into(),
|
|
||||||
favorite,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
));
|
|
||||||
ui.set_repositories_scroll_request(ui.get_repositories_scroll_request().wrapping_add(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn refresh_issues(ui: &AppWindow, issues: &[models::Issue]) {
|
|
||||||
ui.set_issues(model(
|
|
||||||
issues
|
|
||||||
.iter()
|
|
||||||
.map(|issue| IssueRow {
|
|
||||||
number: issue.number.unwrap_or_default() as i32,
|
|
||||||
title: issue
|
|
||||||
.title
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| "Untitled issue".into())
|
|
||||||
.into(),
|
|
||||||
summary: issue
|
|
||||||
.body
|
|
||||||
.as_deref()
|
|
||||||
.map(summary)
|
|
||||||
.unwrap_or_else(|| "No description".into())
|
|
||||||
.into(),
|
|
||||||
meta: issue_meta(issue).into(),
|
|
||||||
labels: model(
|
|
||||||
issue
|
|
||||||
.labels
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or_default()
|
|
||||||
.iter()
|
|
||||||
.filter_map(label_row)
|
|
||||||
.collect(),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
));
|
|
||||||
ui.set_issues_scroll_request(ui.get_issues_scroll_request().wrapping_add(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn refresh_pulls(ui: &AppWindow, pulls: &[models::Issue]) {
|
|
||||||
ui.set_pulls(model(
|
|
||||||
pulls
|
|
||||||
.iter()
|
|
||||||
.filter_map(|pull| {
|
|
||||||
let repository = pull.repository.as_ref()?;
|
|
||||||
let owner = repository.owner.clone()?;
|
|
||||||
let name = repository.name.clone()?;
|
|
||||||
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() as i32,
|
|
||||||
owner: owner.into(),
|
|
||||||
repository: name.into(),
|
|
||||||
title: pull
|
|
||||||
.title
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| "Untitled pull request".into())
|
|
||||||
.into(),
|
|
||||||
summary: pull
|
|
||||||
.body
|
|
||||||
.as_deref()
|
|
||||||
.map(summary)
|
|
||||||
.filter(|body| !body.is_empty())
|
|
||||||
.unwrap_or_else(|| "No description".into())
|
|
||||||
.into(),
|
|
||||||
meta: format!(
|
|
||||||
"{kind} · {author} · {} · {} comments",
|
|
||||||
compact_date(pull.updated_at.as_deref()),
|
|
||||||
pull.comments.unwrap_or_default()
|
|
||||||
)
|
|
||||||
.into(),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn refresh_commits(ui: &AppWindow, commits: &[HistoryCommit], lane_count: usize) {
|
|
||||||
ui.set_commits(model(
|
|
||||||
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());
|
|
||||||
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(),
|
|
||||||
meta: format!("{author} · {}", compact_date(date)).into(),
|
|
||||||
refs: row.refs.join(" · ").into(),
|
|
||||||
lanes: model(
|
|
||||||
(0..lane_count)
|
|
||||||
.map(|lane| GraphLane {
|
|
||||||
color: Color::from_hsva(
|
|
||||||
(lane as f32 * 137.508) % 360.0,
|
|
||||||
0.78,
|
|
||||||
0.78,
|
|
||||||
1.0,
|
|
||||||
),
|
|
||||||
top: row.top_lanes.contains(&lane),
|
|
||||||
bottom: row.bottom_lanes.contains(&lane),
|
|
||||||
node: row.node_lane == Some(lane),
|
|
||||||
connect_left: row.connections.contains(&lane),
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn label_row(label: &models::Label) -> Option<LabelRow> {
|
|
||||||
let name = label.name.as_deref()?.trim();
|
|
||||||
let hex = label.color.as_deref()?.trim().trim_start_matches('#');
|
|
||||||
if name.is_empty() || hex.len() != 6 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let value = u32::from_str_radix(hex, 16).ok()?;
|
|
||||||
let (red, green, blue) = (
|
|
||||||
((value >> 16) & 0xff) as u8,
|
|
||||||
((value >> 8) & 0xff) as u8,
|
|
||||||
(value & 0xff) as u8,
|
|
||||||
);
|
|
||||||
let light = red as u32 * 299 + green as u32 * 587 + blue as u32 * 114 > 150_000;
|
|
||||||
Some(LabelRow {
|
|
||||||
name: name.into(),
|
|
||||||
background: Color::from_rgb_u8(red, green, blue),
|
|
||||||
foreground: if light {
|
|
||||||
Color::from_rgb_u8(28, 28, 30)
|
|
||||||
} else {
|
|
||||||
Color::from_rgb_u8(255, 255, 255)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn refresh_home(ui: &AppWindow, home: HomeData) {
|
|
||||||
let (cells, contributions) = heat_cells(&home.heatmap);
|
|
||||||
ui.set_heat_cells(model(cells));
|
|
||||||
ui.set_contribution_count(contributions.min(i32::MAX as i64) as i32);
|
|
||||||
ui.set_activities(model(home.activities.iter().map(activity_row).collect()));
|
|
||||||
ui.set_home_scroll_request(ui.get_home_scroll_request().wrapping_add(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
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() => (
|
|
||||||
"branch-create",
|
|
||||||
format!("Created branch {branch} in {repository}"),
|
|
||||||
),
|
|
||||||
Some(OpType::CommitRepo | OpType::MirrorSyncPush) => {
|
|
||||||
("push", format!("Pushed to {branch} in {repository}"))
|
|
||||||
}
|
|
||||||
Some(OpType::CreateRepo) => ("repo-create", format!("Created {repository}")),
|
|
||||||
Some(OpType::RenameRepo) => ("repo-rename", format!("Renamed {repository}")),
|
|
||||||
Some(OpType::StarRepo) => ("repo-star", format!("Starred {repository}")),
|
|
||||||
Some(OpType::WatchRepo) => ("repo-watch", format!("Started watching {repository}")),
|
|
||||||
Some(OpType::CreateIssue) => ("issue-open", format!("Opened an issue in {repository}")),
|
|
||||||
Some(OpType::CloseIssue) => ("issue-close", format!("Closed an issue in {repository}")),
|
|
||||||
Some(OpType::ReopenIssue) => ("issue-reopen", format!("Reopened an issue in {repository}")),
|
|
||||||
Some(OpType::CommentIssue) => (
|
|
||||||
"issue-comment",
|
|
||||||
format!("Commented on an issue in {repository}"),
|
|
||||||
),
|
|
||||||
Some(OpType::CreatePullRequest) => (
|
|
||||||
"pull-open",
|
|
||||||
format!("Opened a pull request in {repository}"),
|
|
||||||
),
|
|
||||||
Some(OpType::MergePullRequest | OpType::AutoMergePullRequest) => (
|
|
||||||
"pull-merge",
|
|
||||||
format!("Merged a pull request in {repository}"),
|
|
||||||
),
|
|
||||||
Some(OpType::ClosePullRequest) => (
|
|
||||||
"pull-close",
|
|
||||||
format!("Closed a pull request in {repository}"),
|
|
||||||
),
|
|
||||||
Some(OpType::ReopenPullRequest) => (
|
|
||||||
"pull-reopen",
|
|
||||||
format!("Reopened a pull request in {repository}"),
|
|
||||||
),
|
|
||||||
Some(OpType::CommentPull) => (
|
|
||||||
"pull-comment",
|
|
||||||
format!("Commented on a pull request in {repository}"),
|
|
||||||
),
|
|
||||||
Some(OpType::ApprovePullRequest) => (
|
|
||||||
"pull-approve",
|
|
||||||
format!("Approved a pull request in {repository}"),
|
|
||||||
),
|
|
||||||
Some(OpType::RejectPullRequest) => {
|
|
||||||
("pull-reject", format!("Requested changes in {repository}"))
|
|
||||||
}
|
|
||||||
Some(OpType::PushTag) => ("tag-push", format!("Pushed tag {branch} in {repository}")),
|
|
||||||
Some(OpType::DeleteTag) => (
|
|
||||||
"tag-delete",
|
|
||||||
format!("Deleted tag {branch} in {repository}"),
|
|
||||||
),
|
|
||||||
Some(OpType::DeleteBranch) => (
|
|
||||||
"branch-delete",
|
|
||||||
format!("Deleted branch {branch} in {repository}"),
|
|
||||||
),
|
|
||||||
Some(OpType::PublishRelease) => ("release", format!("Published a release in {repository}")),
|
|
||||||
Some(_) | None => ("update", format!("Updated {repository}")),
|
|
||||||
};
|
|
||||||
let target = activity::target(activity);
|
|
||||||
let (target, owner, repository, number, sha) = match target {
|
|
||||||
Some(activity::Target::Repository { owner, repository }) => {
|
|
||||||
("repository", owner, repository, 0, String::new())
|
|
||||||
}
|
|
||||||
Some(activity::Target::Issue {
|
|
||||||
owner,
|
|
||||||
repository,
|
|
||||||
number,
|
|
||||||
}) => ("issue", owner, repository, number, String::new()),
|
|
||||||
Some(activity::Target::Pull {
|
|
||||||
owner,
|
|
||||||
repository,
|
|
||||||
number,
|
|
||||||
}) => ("pull", owner, repository, number, String::new()),
|
|
||||||
Some(activity::Target::Commit {
|
|
||||||
owner,
|
|
||||||
repository,
|
|
||||||
sha,
|
|
||||||
}) => ("commit", owner, repository, 0, sha),
|
|
||||||
None => ("", String::new(), String::new(), 0, String::new()),
|
|
||||||
};
|
|
||||||
ActivityRow {
|
|
||||||
icon: icon.into(),
|
|
||||||
title: title.into(),
|
|
||||||
detail: activity_detail(activity).into(),
|
|
||||||
meta: compact_date(activity.created.as_deref()).into(),
|
|
||||||
target: target.into(),
|
|
||||||
owner: owner.into(),
|
|
||||||
repository: repository.into(),
|
|
||||||
number: number.min(i32::MAX as i64) as i32,
|
|
||||||
sha: sha.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn activity_detail(activity: &models::Activity) -> String {
|
|
||||||
let text = activity
|
|
||||||
.comment
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|comment| comment.body.as_deref())
|
|
||||||
.or(activity.content.as_deref())
|
|
||||||
.unwrap_or("");
|
|
||||||
if let Ok(payload) = serde_json::from_str::<serde_json::Value>(text) {
|
|
||||||
let commits = payload.get("Len").and_then(|value| value.as_i64());
|
|
||||||
let message = payload
|
|
||||||
.get("HeadCommit")
|
|
||||||
.and_then(|commit| commit.get("Message"))
|
|
||||||
.and_then(|message| message.as_str())
|
|
||||||
.map(summary)
|
|
||||||
.unwrap_or_default();
|
|
||||||
if !message.is_empty() {
|
|
||||||
return match commits {
|
|
||||||
Some(1) => format!("1 commit · {message}"),
|
|
||||||
Some(count) => format!("{count} commits · {message}"),
|
|
||||||
None => message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let text = summary(text);
|
|
||||||
if text.is_empty() {
|
|
||||||
"Server activity".into()
|
|
||||||
} else {
|
|
||||||
text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn heat_cells(data: &[models::UserHeatmapData]) -> (Vec<HeatCell>, i64) {
|
|
||||||
const DAYS: i64 = 52 * 7;
|
|
||||||
let latest = data
|
|
||||||
.iter()
|
|
||||||
.filter_map(|entry| entry.timestamp)
|
|
||||||
.max()
|
|
||||||
.unwrap_or(0)
|
|
||||||
/ 86_400;
|
|
||||||
let start = latest - DAYS + 1;
|
|
||||||
let mut counts = vec![0_i64; DAYS 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 contributions = counts.iter().sum();
|
|
||||||
let cells = counts
|
|
||||||
.into_iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(index, count)| HeatCell {
|
|
||||||
week: (index / 7) as i32,
|
|
||||||
day: (index % 7) as i32,
|
|
||||||
level: if count == 0 || maximum == 0 {
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
((count * 4 + maximum - 1) / maximum).clamp(1, 4) as i32
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
(cells, contributions)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn show_issue(ui: &AppWindow, details: &IssueDetails) {
|
|
||||||
let issue = &details.issue;
|
|
||||||
ui.set_issue_title(
|
|
||||||
issue
|
|
||||||
.title
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| "Untitled issue".into())
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
ui.set_issue_meta(issue_meta(issue).into());
|
|
||||||
ui.set_issue_body(model(markdown::render(
|
|
||||||
issue
|
|
||||||
.body
|
|
||||||
.as_deref()
|
|
||||||
.filter(|body| !body.is_empty())
|
|
||||||
.unwrap_or("No description provided."),
|
|
||||||
)));
|
|
||||||
ui.set_comments(model(details.comments.iter().map(comment_row).collect()));
|
|
||||||
ui.set_issue_scroll_request(ui.get_issue_scroll_request().wrapping_add(1));
|
|
||||||
ui.set_page("issue".into());
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn show_pull(ui: &AppWindow, details: &PullDetails) {
|
|
||||||
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 = if pull.merged.unwrap_or(false) {
|
|
||||||
"merged"
|
|
||||||
} else if pull.draft.unwrap_or(false) {
|
|
||||||
"draft"
|
|
||||||
} else {
|
|
||||||
pull.state.as_deref().unwrap_or("unknown")
|
|
||||||
};
|
|
||||||
ui.set_detail_title(
|
|
||||||
pull.title
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| "Untitled pull request".into())
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
ui.set_detail_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(),
|
|
||||||
)
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
ui.set_detail_body(model(markdown::render(
|
|
||||||
pull.body
|
|
||||||
.as_deref()
|
|
||||||
.filter(|body| !body.is_empty())
|
|
||||||
.unwrap_or("No description provided."),
|
|
||||||
)));
|
|
||||||
ui.set_comments(model(details.comments.iter().map(comment_row).collect()));
|
|
||||||
ui.set_files(model(
|
|
||||||
details
|
|
||||||
.files
|
|
||||||
.iter()
|
|
||||||
.filter_map(|file| {
|
|
||||||
Some(FileRow {
|
|
||||||
path: file.filename.clone()?.into(),
|
|
||||||
status: file
|
|
||||||
.status
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| "modified".into())
|
|
||||||
.into(),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
));
|
|
||||||
ui.set_detail_files_ref(pull_files_ref(pull).into());
|
|
||||||
ui.set_page("pull".into());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pull_files_ref(pull: &models::PullRequest) -> String {
|
|
||||||
if pull.state.as_deref() == Some("open") {
|
|
||||||
let branch = pull
|
|
||||||
.head
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|head| head.r#ref.as_deref().or(head.label.as_deref()))
|
|
||||||
.unwrap_or("head branch");
|
|
||||||
format!("Files on {branch}")
|
|
||||||
} else if let Some(sha) = pull.merge_commit_sha.as_deref() {
|
|
||||||
format!("Files at merge commit {}", &sha[..sha.len().min(8)])
|
|
||||||
} else {
|
|
||||||
"Files from pull request".into()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn comment_row(comment: &models::Comment) -> CommentRow {
|
|
||||||
CommentRow {
|
|
||||||
author: comment
|
|
||||||
.user
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|user| user.login.as_deref())
|
|
||||||
.or(comment.original_author.as_deref())
|
|
||||||
.unwrap_or("unknown")
|
|
||||||
.into(),
|
|
||||||
body: model(markdown::render(
|
|
||||||
comment
|
|
||||||
.body
|
|
||||||
.as_deref()
|
|
||||||
.filter(|body| !body.is_empty())
|
|
||||||
.unwrap_or("No comment text."),
|
|
||||||
)),
|
|
||||||
meta: compact_date(
|
|
||||||
comment
|
|
||||||
.updated_at
|
|
||||||
.as_deref()
|
|
||||||
.or(comment.created_at.as_deref()),
|
|
||||||
)
|
|
||||||
.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn summary(body: &str) -> String {
|
|
||||||
body.split_whitespace()
|
|
||||||
.take(24)
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(" ")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn compact_date(date: Option<&str>) -> String {
|
|
||||||
date.and_then(|date| date.get(..10))
|
|
||||||
.unwrap_or("unknown")
|
|
||||||
.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn maps_heat_levels_and_labels() {
|
|
||||||
let heatmap = vec![
|
|
||||||
models::UserHeatmapData {
|
|
||||||
timestamp: Some(100 * 86_400),
|
|
||||||
contributions: Some(1),
|
|
||||||
},
|
|
||||||
models::UserHeatmapData {
|
|
||||||
timestamp: Some(101 * 86_400),
|
|
||||||
contributions: Some(4),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
let (cells, total) = heat_cells(&heatmap);
|
|
||||||
assert_eq!((cells.len(), total), (364, 5));
|
|
||||||
assert_eq!((cells[362].level, cells[363].level), (1, 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());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pull_files_use_the_live_branch_or_merge_commit() {
|
|
||||||
let mut pull = models::PullRequest {
|
|
||||||
state: Some("open".into()),
|
|
||||||
head: Some(Box::new(models::PrBranchInfo {
|
|
||||||
r#ref: Some("feature/files".into()),
|
|
||||||
..Default::default()
|
|
||||||
})),
|
|
||||||
merge_commit_sha: Some("0123456789abcdef".into()),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
assert_eq!(pull_files_ref(&pull), "Files on feature/files");
|
|
||||||
|
|
||||||
pull.state = Some("closed".into());
|
|
||||||
assert_eq!(pull_files_ref(&pull), "Files at merge commit 01234567");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn activity_rows_use_semantic_icons() {
|
|
||||||
let activity = models::Activity {
|
|
||||||
op_type: Some(models::activity::OpType::CreatePullRequest),
|
|
||||||
content: Some("42|feature".into()),
|
|
||||||
repo: Some(Box::new(models::Repository {
|
|
||||||
full_name: Some("octo/demo".into()),
|
|
||||||
..Default::default()
|
|
||||||
})),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(activity_row(&activity).icon, "pull-open");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,614 +0,0 @@
|
|||||||
import { Button, ComboBox, LineEdit, ListView, ScrollView, Spinner } from "std-widgets.slint";
|
|
||||||
export {
|
|
||||||
ActivityRow, CommentRow, CommitRow, DiffLine, FileRow, GraphLane, HeatCell, IssueRow,
|
|
||||||
LabelRow, MarkdownBlock, PullRow, RepositoryRow, ServerRow,
|
|
||||||
} from "types.slint";
|
|
||||||
import {
|
|
||||||
ActivityRow, CommentRow, CommitRow, DiffLine, FileRow, GraphLane, HeatCell, IssueRow,
|
|
||||||
MarkdownBlock, PullRow, RepositoryRow, ServerRow,
|
|
||||||
} from "types.slint";
|
|
||||||
export { AppTheme, IconTheme } from "components.slint";
|
|
||||||
import {
|
|
||||||
AppearanceChoice, AppIcon, AppTheme, CommentCard, EdgeBack, EmptyState, FileCard, Header,
|
|
||||||
IconStyleChoice, MarkdownContent, PullToRefreshGesture, TabButton,
|
|
||||||
} from "components.slint";
|
|
||||||
|
|
||||||
export component AppWindow inherits Window {
|
|
||||||
title: "Gotcha";
|
|
||||||
preferred-width: 390px;
|
|
||||||
preferred-height: 844px;
|
|
||||||
background: AppTheme.page;
|
|
||||||
private property <length> toolbar-height: 68px;
|
|
||||||
|
|
||||||
in-out property <string> tab: "home";
|
|
||||||
in-out property <string> page: "servers";
|
|
||||||
in-out property <[ServerRow]> servers;
|
|
||||||
in-out property <[RepositoryRow]> repositories;
|
|
||||||
in-out property <[IssueRow]> issues;
|
|
||||||
in-out property <[PullRow]> pulls;
|
|
||||||
in-out property <[CommentRow]> comments;
|
|
||||||
in-out property <[CommitRow]> commits;
|
|
||||||
in-out property <[FileRow]> files;
|
|
||||||
in-out property <[ActivityRow]> activities;
|
|
||||||
in-out property <[HeatCell]> heat_cells;
|
|
||||||
in-out property <int> contribution_count;
|
|
||||||
in-out property <bool> has_active_server;
|
|
||||||
in-out property <string> home_server;
|
|
||||||
in-out property <string> server_title;
|
|
||||||
in-out property <string> repository_title;
|
|
||||||
in-out property <string> issue_title;
|
|
||||||
in-out property <string> issue_meta;
|
|
||||||
in-out property <[MarkdownBlock]> issue_body;
|
|
||||||
in-out property <string> detail_title;
|
|
||||||
in-out property <string> detail_meta;
|
|
||||||
in-out property <string> detail_files_ref;
|
|
||||||
in-out property <[MarkdownBlock]> detail_body;
|
|
||||||
in-out property <[DiffLine]> diff_lines;
|
|
||||||
in-out property <int> diff_columns;
|
|
||||||
in-out property <string> issue_filter: "open";
|
|
||||||
in-out property <string> pull_filter: "open";
|
|
||||||
in-out property <[string]> branch_choices;
|
|
||||||
in-out property <int> branch_index;
|
|
||||||
in-out property <bool> filter_open;
|
|
||||||
in-out property <bool> loading;
|
|
||||||
in-out property <bool> home_loading;
|
|
||||||
in-out property <bool> refreshing;
|
|
||||||
in-out property <string> error;
|
|
||||||
in-out property <int> home_scroll_request;
|
|
||||||
in-out property <int> servers_scroll_request;
|
|
||||||
in-out property <int> repositories_scroll_request;
|
|
||||||
in-out property <int> issues_scroll_request;
|
|
||||||
in-out property <int> issue_scroll_request;
|
|
||||||
private property <bool> home_at_top: true;
|
|
||||||
private property <bool> repositories_at_top: true;
|
|
||||||
private property <bool> issues_at_top: true;
|
|
||||||
private property <bool> issue_at_top: true;
|
|
||||||
|
|
||||||
callback select_tab(string);
|
|
||||||
callback refresh_home();
|
|
||||||
callback refresh_repositories();
|
|
||||||
callback refresh_issues();
|
|
||||||
callback refresh_issue();
|
|
||||||
callback refresh_pulls();
|
|
||||||
callback refresh_pull();
|
|
||||||
callback refresh_commits();
|
|
||||||
callback show_add_server();
|
|
||||||
callback save_server(string, string, string);
|
|
||||||
callback select_server(int);
|
|
||||||
callback select_repository(string, string);
|
|
||||||
callback toggle_favorite(string, string);
|
|
||||||
callback select_issue(int);
|
|
||||||
callback select_pull(string, string, int);
|
|
||||||
callback select_commit(string);
|
|
||||||
callback select_branch(string);
|
|
||||||
callback select_file(string);
|
|
||||||
callback select_pull_file(string);
|
|
||||||
callback select_activity(string, string, string, int, string);
|
|
||||||
callback choose_filter(string);
|
|
||||||
callback choose_icon_style(int);
|
|
||||||
callback choose_appearance(int);
|
|
||||||
callback back();
|
|
||||||
|
|
||||||
if tab == "home": Rectangle {
|
|
||||||
x: root.safe-area-insets.left; y: root.safe-area-insets.top;
|
|
||||||
width: root.width - root.safe-area-insets.left - root.safe-area-insets.right;
|
|
||||||
height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - root.toolbar-height;
|
|
||||||
background: AppTheme.page;
|
|
||||||
Header { x: 0; y: 0; title: root.has_active_server ? root.home_server : "Home"; can_back: false; }
|
|
||||||
|
|
||||||
if !root.has_active_server: EmptyState {
|
|
||||||
x: 32px; width: parent.width - 64px; y: 210px; height: 190px;
|
|
||||||
title: "No server selected";
|
|
||||||
detail: "Open Issues to select a server or add your first one.";
|
|
||||||
}
|
|
||||||
|
|
||||||
if root.has_active_server: Rectangle {
|
|
||||||
x: 16px; y: 72px; width: parent.width - 32px; height: 118px;
|
|
||||||
background: AppTheme.surface; border-radius: 12px;
|
|
||||||
Text { x: 13px; y: 10px; text: "Activity · last 12 months"; font-size: 13px; font-weight: 600; color: AppTheme.text; }
|
|
||||||
Text { x: 13px; y: 86px; text: root.contribution_count + " contributions"; font-size: 12px; color: AppTheme.text-secondary; }
|
|
||||||
for cell in root.heat_cells: Rectangle {
|
|
||||||
x: 13px + cell.week * 6.7px; y: 39px + cell.day * 6.2px;
|
|
||||||
width: 5.2px; height: 5.2px; border-radius: 1px;
|
|
||||||
background: cell.level == 0 ? (AppTheme.dark ? #3a3a3c : #e5e5e9) : cell.level == 1 ? #b8d9f4 : cell.level == 2 ? #72b5e8 : cell.level == 3 ? #278bd4 : #0969b7;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if root.has_active_server && !root.home_loading && root.activities.length == 0: EmptyState {
|
|
||||||
x: 32px; width: parent.width - 64px; y: 250px; height: 150px;
|
|
||||||
title: "No recent activity";
|
|
||||||
detail: "Your recent server actions will appear here.";
|
|
||||||
}
|
|
||||||
|
|
||||||
if root.has_active_server: PullToRefreshGesture {
|
|
||||||
x: 16px; y: 202px; width: parent.width - 32px; height: parent.height - 214px;
|
|
||||||
at_top: root.home_at_top; refreshing: root.home_loading;
|
|
||||||
refresh => { root.refresh_home(); }
|
|
||||||
home_list := ListView {
|
|
||||||
property <int> scroll_request: root.home_scroll_request;
|
|
||||||
width: 100%; height: 100%; mouse-drag-pan-enabled: true;
|
|
||||||
changed scroll_request => { self.viewport-y = 0px; root.home_at_top = true; }
|
|
||||||
scrolled => { root.home_at_top = self.viewport-y >= 0px; }
|
|
||||||
for activity in root.activities: Rectangle {
|
|
||||||
height: 94px;
|
|
||||||
background: AppTheme.surface; border-radius: 12px;
|
|
||||||
TouchArea {
|
|
||||||
enabled: activity.target != "";
|
|
||||||
clicked => { root.select_activity(activity.target, activity.owner, activity.repository, activity.number, activity.sha); }
|
|
||||||
}
|
|
||||||
Rectangle {
|
|
||||||
x: 12px; y: 14px; width: 34px; height: 34px; border-radius: 17px;
|
|
||||||
background: AppTheme.accent-soft;
|
|
||||||
AppIcon { x: 5px; y: 5px; width: 24px; height: 24px; kind: activity.icon; }
|
|
||||||
}
|
|
||||||
Text { x: 57px; y: 11px; width: parent.width - 69px; height: 24px; text: activity.title; font-size: 15px; font-weight: 600; color: AppTheme.text; overflow: elide; }
|
|
||||||
Text { x: 57px; y: 36px; width: parent.width - 69px; height: 35px; text: activity.detail; font-size: 13px; color: AppTheme.text-secondary; wrap: word-wrap; overflow: elide; }
|
|
||||||
Text { x: 57px; y: 72px; width: parent.width - 69px; text: activity.meta; font-size: 11px; color: AppTheme.text-tertiary; overflow: elide; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if root.home_loading: Spinner { width: 34px; height: 34px; x: (parent.width - self.width) / 2; y: 222px; }
|
|
||||||
}
|
|
||||||
|
|
||||||
if tab == "settings": Rectangle {
|
|
||||||
x: root.safe-area-insets.left; y: root.safe-area-insets.top;
|
|
||||||
width: root.width - root.safe-area-insets.left - root.safe-area-insets.right;
|
|
||||||
height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - root.toolbar-height;
|
|
||||||
background: AppTheme.page;
|
|
||||||
Header {
|
|
||||||
x: 0; y: 0; can_back: false;
|
|
||||||
title: "Settings";
|
|
||||||
}
|
|
||||||
Rectangle {
|
|
||||||
x: 16px; y: 76px; width: parent.width - 32px; height: 184px;
|
|
||||||
background: AppTheme.surface; border-radius: 12px;
|
|
||||||
Text { x: 15px; y: 13px; text: "Icon style"; font-size: 18px; font-weight: 650; color: AppTheme.text; }
|
|
||||||
Text { x: 15px; y: 43px; width: parent.width - 30px; text: "Choose how navigation and activity icons look throughout Gotcha."; font-size: 13px; color: AppTheme.text-secondary; wrap: word-wrap; }
|
|
||||||
IconStyleChoice { x: 12px; y: 91px; width: (parent.width - 36px) / 3; height: 78px; value: 0; label: "Outline"; clicked => { root.choose_icon_style(0); } }
|
|
||||||
IconStyleChoice { x: 18px + (parent.width - 36px) / 3; y: 91px; width: (parent.width - 36px) / 3; height: 78px; value: 1; label: "Duotone"; clicked => { root.choose_icon_style(1); } }
|
|
||||||
IconStyleChoice { x: 24px + 2 * (parent.width - 36px) / 3; y: 91px; width: (parent.width - 36px) / 3; height: 78px; value: 2; label: "Bold"; clicked => { root.choose_icon_style(2); } }
|
|
||||||
}
|
|
||||||
Rectangle {
|
|
||||||
x: 16px; y: 276px; width: parent.width - 32px; height: 136px;
|
|
||||||
background: AppTheme.surface; border-radius: 12px;
|
|
||||||
Text { x: 15px; y: 13px; text: "Appearance"; font-size: 18px; font-weight: 650; color: AppTheme.text; }
|
|
||||||
Text { x: 15px; y: 43px; width: parent.width - 30px; text: "Follow iOS automatically or choose a fixed light or dark appearance."; font-size: 13px; color: AppTheme.text-secondary; wrap: word-wrap; }
|
|
||||||
AppearanceChoice { x: 12px; y: 81px; width: (parent.width - 36px) / 3; height: 42px; value: 0; label: "Auto"; clicked => { root.choose_appearance(0); } }
|
|
||||||
AppearanceChoice { x: 18px + (parent.width - 36px) / 3; y: 81px; width: (parent.width - 36px) / 3; height: 42px; value: 1; label: "Light"; clicked => { root.choose_appearance(1); } }
|
|
||||||
AppearanceChoice { x: 24px + 2 * (parent.width - 36px) / 3; y: 81px; width: (parent.width - 36px) / 3; height: 42px; value: 2; label: "Dark"; clicked => { root.choose_appearance(2); } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tab == "issues" || tab == "repositories") && page == "servers": Rectangle {
|
|
||||||
x: root.safe-area-insets.left; y: root.safe-area-insets.top;
|
|
||||||
width: root.width - root.safe-area-insets.left - root.safe-area-insets.right;
|
|
||||||
height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - root.toolbar-height;
|
|
||||||
background: AppTheme.page;
|
|
||||||
Header { x: 0; y: 0; title: "Servers"; can_back: false; }
|
|
||||||
if root.servers.length > 0: servers_list := ListView {
|
|
||||||
property <int> scroll_request: root.servers_scroll_request;
|
|
||||||
x: 16px; y: 76px; width: parent.width - 32px; height: parent.height - 154px;
|
|
||||||
mouse-drag-pan-enabled: true;
|
|
||||||
changed scroll_request => { self.viewport-y = 0px; }
|
|
||||||
for server[index] in root.servers: Rectangle {
|
|
||||||
height: 76px;
|
|
||||||
background: AppTheme.surface;
|
|
||||||
border-color: AppTheme.border;
|
|
||||||
border-width: 1px;
|
|
||||||
border-radius: 12px;
|
|
||||||
TouchArea { clicked => { root.select_server(index); } }
|
|
||||||
Text { x: 16px; y: 13px; width: parent.width - 54px; text: server.name; font-size: 17px; font-weight: 600; color: AppTheme.text; overflow: elide; }
|
|
||||||
Text { x: 16px; y: 42px; width: parent.width - 54px; text: server.url; font-size: 13px; color: AppTheme.text-secondary; overflow: elide; }
|
|
||||||
AppIcon { x: parent.width - 29px; y: 26px; width: 20px; height: 20px; kind: "disclosure"; icon-color: AppTheme.text-tertiary; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Button { x: 16px; y: parent.height - 66px; width: parent.width - 32px; height: 48px; text: "Add Server"; clicked => { root.show_add_server(); } }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tab == "issues" || tab == "repositories") && page == "add": Rectangle {
|
|
||||||
x: root.safe-area-insets.left; y: root.safe-area-insets.top;
|
|
||||||
width: root.width - root.safe-area-insets.left - root.safe-area-insets.right;
|
|
||||||
height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - root.toolbar-height;
|
|
||||||
background: AppTheme.page;
|
|
||||||
Header { x: 0; y: 0; title: "Add Server"; can_back: true; back => { root.back(); } }
|
|
||||||
VerticalLayout {
|
|
||||||
x: 20px; y: 92px; width: parent.width - 40px; height: 260px; spacing: 10px;
|
|
||||||
Text { text: "Name"; font-size: 13px; color: AppTheme.text-secondary; }
|
|
||||||
server_name := LineEdit { placeholder-text: "Work"; }
|
|
||||||
Text { text: "Server URL"; font-size: 13px; color: AppTheme.text-secondary; }
|
|
||||||
server_url := LineEdit { placeholder-text: "https://gitea.example.com"; }
|
|
||||||
Text { text: "Access token"; font-size: 13px; color: AppTheme.text-secondary; }
|
|
||||||
server_token := LineEdit { placeholder-text: "Token"; input-type: InputType.password; }
|
|
||||||
}
|
|
||||||
Button {
|
|
||||||
x: 20px; y: 380px; width: parent.width - 40px; height: 48px;
|
|
||||||
text: root.loading ? "Connecting…" : "Add Server";
|
|
||||||
enabled: !root.loading;
|
|
||||||
clicked => { root.save_server(server_name.text, server_url.text, server_token.text); }
|
|
||||||
}
|
|
||||||
EdgeBack { x: 0; y: 0; back => { root.back(); } }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tab == "issues" || tab == "repositories") && page == "repositories": Rectangle {
|
|
||||||
x: root.safe-area-insets.left; y: root.safe-area-insets.top;
|
|
||||||
width: root.width - root.safe-area-insets.left - root.safe-area-insets.right;
|
|
||||||
height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - root.toolbar-height;
|
|
||||||
background: AppTheme.page;
|
|
||||||
Header { x: 0; y: 0; title: root.server_title; can_back: true; back => { root.back(); } }
|
|
||||||
if root.refreshing: Spinner { x: parent.width - 42px; y: 17px; width: 24px; height: 24px; }
|
|
||||||
if !root.loading && root.repositories.length == 0: EmptyState {
|
|
||||||
x: 34px; width: parent.width - 68px; y: 220px; height: 160px;
|
|
||||||
title: "No repositories";
|
|
||||||
detail: "This account does not own any repositories on this server.";
|
|
||||||
}
|
|
||||||
PullToRefreshGesture {
|
|
||||||
x: 20px; y: 70px; width: parent.width - 40px; height: parent.height - 82px;
|
|
||||||
at_top: root.repositories_at_top; refreshing: root.refreshing;
|
|
||||||
refresh => { root.refresh_repositories(); }
|
|
||||||
repositories_list := ListView {
|
|
||||||
property <int> scroll_request: root.repositories_scroll_request;
|
|
||||||
width: 100%; height: 100%; mouse-drag-pan-enabled: true;
|
|
||||||
changed scroll_request => { self.viewport-y = 0px; root.repositories_at_top = true; }
|
|
||||||
scrolled => { root.repositories_at_top = self.viewport-y >= 0px; }
|
|
||||||
for repo in root.repositories: Rectangle {
|
|
||||||
height: 104px;
|
|
||||||
background: AppTheme.surface;
|
|
||||||
border-color: AppTheme.border;
|
|
||||||
border-width: 1px;
|
|
||||||
border-radius: 12px;
|
|
||||||
TouchArea { clicked => { root.select_repository(repo.owner, repo.name); } }
|
|
||||||
Text { x: 15px; y: 11px; width: parent.width - 62px; text: repo.name; font-size: 17px; font-weight: 600; color: AppTheme.text; overflow: elide; }
|
|
||||||
Text { x: 15px; y: 39px; width: parent.width - 30px; text: repo.description; font-size: 14px; color: AppTheme.text-secondary; overflow: elide; }
|
|
||||||
Text { x: 15px; y: 71px; width: parent.width - 30px; text: repo.meta; font-size: 12px; color: AppTheme.text-tertiary; overflow: elide; }
|
|
||||||
TouchArea {
|
|
||||||
x: parent.width - 52px; y: 2px; width: 48px; height: 48px;
|
|
||||||
clicked => { root.toggle_favorite(repo.owner, repo.name); }
|
|
||||||
AppIcon { x: 10px; y: 10px; width: 28px; height: 28px; kind: repo.favorite ? "favorite-filled" : "favorite"; icon-color: repo.favorite ? #f2a900 : AppTheme.text-tertiary; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EdgeBack { x: 0; y: 0; back => { root.back(); } }
|
|
||||||
}
|
|
||||||
|
|
||||||
if tab == "issues" && page == "issues": Rectangle {
|
|
||||||
x: root.safe-area-insets.left; y: root.safe-area-insets.top;
|
|
||||||
width: root.width - root.safe-area-insets.left - root.safe-area-insets.right;
|
|
||||||
height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - root.toolbar-height;
|
|
||||||
background: AppTheme.page;
|
|
||||||
Header {
|
|
||||||
x: 0; y: 0; title: root.repository_title; can_back: true; can_filter: true;
|
|
||||||
back => { root.back(); }
|
|
||||||
filter => { root.filter_open = true; }
|
|
||||||
}
|
|
||||||
if root.refreshing: Spinner { x: parent.width - 42px; y: 17px; width: 24px; height: 24px; }
|
|
||||||
if !root.loading && root.issues.length == 0: EmptyState {
|
|
||||||
x: 34px; width: parent.width - 68px; y: 220px; height: 160px;
|
|
||||||
title: "No " + root.issue_filter + " issues";
|
|
||||||
detail: "No issues match the selected status.";
|
|
||||||
}
|
|
||||||
PullToRefreshGesture {
|
|
||||||
x: 20px; y: 70px; width: parent.width - 40px; height: parent.height - 82px;
|
|
||||||
at_top: root.issues_at_top; refreshing: root.refreshing;
|
|
||||||
refresh => { root.refresh_issues(); }
|
|
||||||
issues_list := ListView {
|
|
||||||
property <int> scroll_request: root.issues_scroll_request;
|
|
||||||
width: 100%; height: 100%; mouse-drag-pan-enabled: true;
|
|
||||||
changed scroll_request => { self.viewport-y = 0px; root.issues_at_top = true; }
|
|
||||||
scrolled => { root.issues_at_top = self.viewport-y >= 0px; }
|
|
||||||
for issue in root.issues: Rectangle {
|
|
||||||
height: issue.labels.length > 0 ? 138px : 112px;
|
|
||||||
background: AppTheme.surface;
|
|
||||||
border-color: AppTheme.border;
|
|
||||||
border-width: 1px;
|
|
||||||
border-radius: 12px;
|
|
||||||
TouchArea { clicked => { root.select_issue(issue.number); } }
|
|
||||||
Text { x: 15px; y: 11px; width: parent.width - 44px; text: issue.title; font-size: 16px; font-weight: 600; color: AppTheme.text; overflow: elide; }
|
|
||||||
Text { x: 15px; y: 39px; width: parent.width - 30px; height: 38px; text: issue.summary; font-size: 13px; color: AppTheme.text-secondary; wrap: word-wrap; overflow: elide; }
|
|
||||||
if issue.labels.length > 0: HorizontalLayout {
|
|
||||||
x: 15px; y: 82px; width: parent.width - 30px; height: 22px; spacing: 5px;
|
|
||||||
for label in issue.labels: Rectangle {
|
|
||||||
width: min(104px, max(48px, (parent.width - (issue.labels.length - 1) * 5px) / issue.labels.length));
|
|
||||||
height: 22px; background: label.background; border-radius: 11px;
|
|
||||||
Text { x: 7px; width: parent.width - 14px; text: label.name; color: label.foreground; font-size: 11px; font-weight: 600; horizontal-alignment: center; vertical-alignment: center; overflow: elide; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text { x: 15px; y: issue.labels.length > 0 ? 112px : 86px; width: parent.width - 30px; text: issue.meta; font-size: 12px; color: AppTheme.text-tertiary; overflow: elide; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EdgeBack { x: 0; y: 0; back => { root.back(); } }
|
|
||||||
}
|
|
||||||
|
|
||||||
if tab == "issues" && page == "issue": Rectangle {
|
|
||||||
x: root.safe-area-insets.left; y: root.safe-area-insets.top;
|
|
||||||
width: root.width - root.safe-area-insets.left - root.safe-area-insets.right;
|
|
||||||
height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - root.toolbar-height;
|
|
||||||
background: AppTheme.page;
|
|
||||||
Header { x: 0; y: 0; title: "Issue"; can_back: true; back => { root.back(); } }
|
|
||||||
if root.refreshing: Spinner { x: parent.width - 42px; y: 17px; width: 24px; height: 24px; }
|
|
||||||
PullToRefreshGesture {
|
|
||||||
x: 18px; y: 78px; width: parent.width - 36px; height: parent.height - 96px;
|
|
||||||
at_top: root.issue_at_top; refreshing: root.refreshing;
|
|
||||||
refresh => { root.refresh_issue(); }
|
|
||||||
issue_scroll := ScrollView {
|
|
||||||
property <int> scroll_request: root.issue_scroll_request;
|
|
||||||
width: 100%; height: 100%; mouse-drag-pan-enabled: true;
|
|
||||||
changed scroll_request => { self.viewport-y = 0px; root.issue_at_top = true; }
|
|
||||||
scrolled => { root.issue_at_top = self.viewport-y >= 0px; }
|
|
||||||
VerticalLayout {
|
|
||||||
width: parent.width;
|
|
||||||
spacing: 12px;
|
|
||||||
alignment: start;
|
|
||||||
Text { text: root.issue_title; font-size: 23px; font-weight: 650; color: AppTheme.text; wrap: word-wrap; }
|
|
||||||
Text { text: root.issue_meta; font-size: 13px; color: AppTheme.text-secondary; wrap: word-wrap; }
|
|
||||||
Rectangle { height: 1px; background: AppTheme.separator; }
|
|
||||||
MarkdownContent { width: parent.width; blocks: root.issue_body; }
|
|
||||||
if root.comments.length > 0: Text { text: "Comments"; font-size: 17px; font-weight: 600; color: AppTheme.text; }
|
|
||||||
for comment in root.comments: CommentCard { width: parent.width; comment: comment; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EdgeBack { x: 0; y: 0; back => { root.back(); } }
|
|
||||||
}
|
|
||||||
|
|
||||||
if tab == "pulls" && !root.has_active_server: Rectangle {
|
|
||||||
x: root.safe-area-insets.left; y: root.safe-area-insets.top;
|
|
||||||
width: root.width - root.safe-area-insets.left - root.safe-area-insets.right;
|
|
||||||
height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - root.toolbar-height;
|
|
||||||
background: AppTheme.page;
|
|
||||||
Header { x: 0; y: 0; title: "Home"; can_back: false; }
|
|
||||||
EmptyState {
|
|
||||||
x: 32px; width: parent.width - 64px; y: 210px; height: 190px;
|
|
||||||
title: "No server selected";
|
|
||||||
detail: "Open Issues or Repositories to select a server or add your first one.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if tab == "pulls" && root.has_active_server && page == "pulls": Rectangle {
|
|
||||||
x: root.safe-area-insets.left; y: root.safe-area-insets.top;
|
|
||||||
width: root.width - root.safe-area-insets.left - root.safe-area-insets.right;
|
|
||||||
height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - root.toolbar-height;
|
|
||||||
background: AppTheme.page;
|
|
||||||
Header {
|
|
||||||
x: 0; y: 0; title: "Pull Requests"; can_back: false; can_filter: true;
|
|
||||||
filter => { root.filter_open = true; }
|
|
||||||
}
|
|
||||||
if root.refreshing: Spinner { x: parent.width - 92px; y: 17px; width: 24px; height: 24px; }
|
|
||||||
if !root.loading && root.pulls.length == 0: EmptyState {
|
|
||||||
x: 34px; width: parent.width - 68px; y: 220px; height: 160px;
|
|
||||||
title: "No " + root.pull_filter + " pull requests";
|
|
||||||
detail: "No pull requests match the selected status.";
|
|
||||||
}
|
|
||||||
PullToRefreshGesture {
|
|
||||||
x: 16px; y: 70px; width: parent.width - 32px; height: parent.height - 82px;
|
|
||||||
at_top: root.issues_at_top; refreshing: root.refreshing;
|
|
||||||
refresh => { root.refresh_pulls(); }
|
|
||||||
ListView {
|
|
||||||
width: 100%; height: 100%; mouse-drag-pan-enabled: true;
|
|
||||||
scrolled => { root.issues_at_top = self.viewport-y >= 0px; }
|
|
||||||
for pull in root.pulls: Rectangle {
|
|
||||||
height: 112px;
|
|
||||||
background: AppTheme.surface; border-color: AppTheme.border; border-width: 1px; border-radius: 12px;
|
|
||||||
TouchArea { clicked => { root.select_pull(pull.owner, pull.repository, pull.number); } }
|
|
||||||
Text { x: 14px; y: 10px; width: parent.width - 28px; text: pull.repository + " #" + pull.number; font-size: 12px; color: AppTheme.text-secondary; overflow: elide; }
|
|
||||||
Text { x: 14px; y: 31px; width: parent.width - 28px; text: pull.title; font-size: 16px; font-weight: 600; color: AppTheme.text; overflow: elide; }
|
|
||||||
Text { x: 14px; y: 57px; width: parent.width - 28px; text: pull.summary; font-size: 13px; color: AppTheme.text-secondary; overflow: elide; }
|
|
||||||
Text { x: 14px; y: 85px; width: parent.width - 28px; text: pull.meta; font-size: 12px; color: AppTheme.text-tertiary; overflow: elide; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if tab == "pulls" && page == "pull": Rectangle {
|
|
||||||
x: root.safe-area-insets.left; y: root.safe-area-insets.top;
|
|
||||||
width: root.width - root.safe-area-insets.left - root.safe-area-insets.right;
|
|
||||||
height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - root.toolbar-height;
|
|
||||||
background: AppTheme.page;
|
|
||||||
Header { x: 0; y: 0; title: "Pull Request"; can_back: true; back => { root.back(); } }
|
|
||||||
if root.refreshing: Spinner { x: parent.width - 42px; y: 17px; width: 24px; height: 24px; }
|
|
||||||
PullToRefreshGesture {
|
|
||||||
x: 18px; y: 78px; width: parent.width - 36px; height: parent.height - 96px;
|
|
||||||
at_top: root.issue_at_top; refreshing: root.refreshing;
|
|
||||||
refresh => { root.refresh_pull(); }
|
|
||||||
ScrollView {
|
|
||||||
width: 100%; height: 100%; mouse-drag-pan-enabled: true;
|
|
||||||
scrolled => { root.issue_at_top = self.viewport-y >= 0px; }
|
|
||||||
VerticalLayout {
|
|
||||||
width: parent.width; spacing: 12px; alignment: start;
|
|
||||||
Text { text: root.detail_title; font-size: 23px; font-weight: 650; color: AppTheme.text; wrap: word-wrap; }
|
|
||||||
Text { text: root.detail_meta; font-size: 13px; color: AppTheme.text-secondary; wrap: word-wrap; }
|
|
||||||
Rectangle { height: 1px; background: AppTheme.separator; }
|
|
||||||
MarkdownContent { width: parent.width; blocks: root.detail_body; }
|
|
||||||
if root.files.length > 0: Text { text: root.detail_files_ref; font-size: 17px; font-weight: 600; color: AppTheme.text; }
|
|
||||||
for file in root.files: FileCard { width: parent.width; file: file; clicked => { root.select_pull_file(file.path); } }
|
|
||||||
if root.comments.length > 0: Text { text: "Comments"; font-size: 17px; font-weight: 600; color: AppTheme.text; }
|
|
||||||
for comment in root.comments: CommentCard { width: parent.width; comment: comment; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EdgeBack { x: 0; y: 0; back => { root.back(); } }
|
|
||||||
}
|
|
||||||
|
|
||||||
if tab == "repositories" && page == "commits": Rectangle {
|
|
||||||
x: root.safe-area-insets.left; y: root.safe-area-insets.top;
|
|
||||||
width: root.width - root.safe-area-insets.left - root.safe-area-insets.right;
|
|
||||||
height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - root.toolbar-height;
|
|
||||||
background: AppTheme.page;
|
|
||||||
Header { x: 0; y: 0; title: root.repository_title; can_back: true; back => { root.back(); } }
|
|
||||||
ComboBox {
|
|
||||||
x: 16px; y: 66px; width: parent.width - 32px; height: 42px;
|
|
||||||
model: root.branch_choices; current-index: root.branch_index;
|
|
||||||
selected(value) => { root.select_branch(value); }
|
|
||||||
}
|
|
||||||
if root.refreshing: Spinner { x: parent.width - 42px; y: 17px; width: 24px; height: 24px; }
|
|
||||||
if !root.loading && root.commits.length == 0: EmptyState {
|
|
||||||
x: 34px; width: parent.width - 68px; y: 220px; height: 160px;
|
|
||||||
title: "No commits";
|
|
||||||
detail: "This repository has no commit history.";
|
|
||||||
}
|
|
||||||
PullToRefreshGesture {
|
|
||||||
x: 16px; y: 116px; width: parent.width - 32px; height: parent.height - 128px;
|
|
||||||
at_top: root.repositories_at_top; refreshing: root.refreshing;
|
|
||||||
refresh => { root.refresh_commits(); }
|
|
||||||
ListView {
|
|
||||||
width: 100%; height: 100%; mouse-drag-pan-enabled: true;
|
|
||||||
scrolled => { root.repositories_at_top = self.viewport-y >= 0px; }
|
|
||||||
for commit in root.commits: commit_row := Rectangle {
|
|
||||||
property <length> graph-width: commit.lanes.length > 0 ? min(72px, commit.lanes.length * 10px + 8px) : 0px;
|
|
||||||
property <length> lane-spacing: (self.graph-width - 8px) / max(1, commit.lanes.length);
|
|
||||||
height: 86px; background: transparent;
|
|
||||||
Rectangle { width: 100%; height: 100%; background: AppTheme.surface; border-color: AppTheme.border; border-width: 1px; border-radius: 12px; }
|
|
||||||
TouchArea { clicked => { root.select_commit(commit.sha); } }
|
|
||||||
for lane[index] in commit.lanes: Rectangle {
|
|
||||||
x: 4px + index * commit_row.lane-spacing;
|
|
||||||
width: 3px; height: 100%; background: transparent;
|
|
||||||
if lane.top: Rectangle {
|
|
||||||
y: -1px; width: 3px;
|
|
||||||
height: lane.connect_left && !lane.bottom ? 37px : 42px;
|
|
||||||
background: lane.color;
|
|
||||||
}
|
|
||||||
if lane.bottom: Rectangle {
|
|
||||||
y: lane.connect_left && !lane.top ? 45px : 40px;
|
|
||||||
width: 3px;
|
|
||||||
height: parent.height - (lane.connect_left && !lane.top ? 44px : 39px);
|
|
||||||
background: lane.color;
|
|
||||||
}
|
|
||||||
if lane.connect_left && lane.top == lane.bottom: Rectangle {
|
|
||||||
x: -commit_row.lane-spacing; y: 39px;
|
|
||||||
width: commit_row.lane-spacing + 3px; height: 3px;
|
|
||||||
background: lane.color;
|
|
||||||
}
|
|
||||||
if lane.connect_left && lane.bottom && !lane.top: Path {
|
|
||||||
x: 1.5px - commit_row.lane-spacing; y: 35px;
|
|
||||||
width: commit_row.lane-spacing; height: 11px;
|
|
||||||
viewbox-width: 10; viewbox-height: 11;
|
|
||||||
commands: "M 0 5.5 L 5 5.5 Q 10 5.5 10 10.5";
|
|
||||||
fill: transparent; stroke: lane.color; stroke-width: 3px; stroke-line-cap: round;
|
|
||||||
}
|
|
||||||
if lane.connect_left && lane.top && !lane.bottom: Path {
|
|
||||||
x: 1.5px - commit_row.lane-spacing; y: 35px;
|
|
||||||
width: commit_row.lane-spacing; height: 11px;
|
|
||||||
viewbox-width: 10; viewbox-height: 11;
|
|
||||||
commands: "M 10 0.5 Q 10 5.5 5 5.5 L 0 5.5";
|
|
||||||
fill: transparent; stroke: lane.color; stroke-width: 3px; stroke-line-cap: round;
|
|
||||||
}
|
|
||||||
if lane.node: Rectangle {
|
|
||||||
x: -3px; y: 36px; width: 9px; height: 9px; border-radius: 5px;
|
|
||||||
background: lane.color; border-color: AppTheme.surface; border-width: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text { x: parent.graph-width + 14px; y: 8px; width: parent.width - self.x - 14px; text: commit.title; font-size: 15px; font-weight: 600; color: AppTheme.text; overflow: elide; }
|
|
||||||
Text { x: parent.graph-width + 14px; y: 33px; width: parent.width - self.x - 14px; text: commit.refs != "" ? commit.refs : commit.meta; font-size: 12px; color: commit.refs != "" ? AppTheme.accent : AppTheme.text-secondary; overflow: elide; }
|
|
||||||
Text { x: parent.graph-width + 14px; y: 53px; width: parent.width - self.x - 14px; text: commit.refs != "" ? commit.meta + " · " + commit.sha : commit.sha; font-size: 11px; color: AppTheme.text-tertiary; overflow: elide; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EdgeBack { x: 0; y: 0; back => { root.back(); } }
|
|
||||||
}
|
|
||||||
|
|
||||||
if tab == "repositories" && page == "files": Rectangle {
|
|
||||||
x: root.safe-area-insets.left; y: root.safe-area-insets.top;
|
|
||||||
width: root.width - root.safe-area-insets.left - root.safe-area-insets.right;
|
|
||||||
height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - root.toolbar-height;
|
|
||||||
background: AppTheme.page;
|
|
||||||
Header { x: 0; y: 0; title: "Changed Files"; can_back: true; back => { root.back(); } }
|
|
||||||
if !root.loading && root.files.length == 0: EmptyState {
|
|
||||||
x: 34px; width: parent.width - 68px; y: 220px; height: 160px;
|
|
||||||
title: "No changed files";
|
|
||||||
detail: "This commit does not contain file changes.";
|
|
||||||
}
|
|
||||||
ListView {
|
|
||||||
x: 16px; y: 70px; width: parent.width - 32px; height: parent.height - 82px; mouse-drag-pan-enabled: true;
|
|
||||||
for file in root.files: FileCard { file: file; clicked => { root.select_file(file.path); } }
|
|
||||||
}
|
|
||||||
EdgeBack { x: 0; y: 0; back => { root.back(); } }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tab == "repositories" || tab == "pulls") && page == "diff": Rectangle {
|
|
||||||
x: root.safe-area-insets.left; y: root.safe-area-insets.top;
|
|
||||||
width: root.width - root.safe-area-insets.left - root.safe-area-insets.right;
|
|
||||||
height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - root.toolbar-height;
|
|
||||||
background: AppTheme.page;
|
|
||||||
Header { x: 0; y: 0; title: root.detail_title; can_back: true; back => { root.back(); } }
|
|
||||||
ScrollView {
|
|
||||||
x: 12px; y: 70px; width: parent.width - 24px; height: parent.height - 82px; mouse-drag-pan-enabled: true;
|
|
||||||
viewport-width: max(self.visible-width, root.diff_columns * 7px + 100px);
|
|
||||||
viewport-height: diff_rows.preferred-height;
|
|
||||||
diff_rows := VerticalLayout {
|
|
||||||
width: parent.viewport-width;
|
|
||||||
for line in root.diff_lines: Rectangle {
|
|
||||||
height: 20px;
|
|
||||||
background: line.kind == "addition" ? (AppTheme.dark ? #173d25 : #ddf5e3) : line.kind == "removal" ? (AppTheme.dark ? #4a2020 : #fde2e1) : line.kind == "hunk" ? (AppTheme.dark ? #17324d : #dcecff) : line.kind == "header" ? AppTheme.surface-muted : AppTheme.surface;
|
|
||||||
Text { x: 0; width: 38px; text: line.old_number; font-family: "monospace"; font-size: 11px; color: AppTheme.text-tertiary; horizontal-alignment: right; vertical-alignment: center; }
|
|
||||||
Text { x: 42px; width: 38px; text: line.new_number; font-family: "monospace"; font-size: 11px; color: AppTheme.text-tertiary; horizontal-alignment: right; vertical-alignment: center; }
|
|
||||||
Rectangle { x: 84px; width: 1px; height: 100%; background: AppTheme.separator; }
|
|
||||||
Text {
|
|
||||||
x: 90px; width: parent.width - 94px; text: line.text; font-family: "monospace"; font-size: 11px;
|
|
||||||
color: line.kind == "addition" ? (AppTheme.dark ? #7fe787 : #176b2c) : line.kind == "removal" ? (AppTheme.dark ? #ff7b72 : #a12720) : line.kind == "hunk" ? (AppTheme.dark ? #79c0ff : #175b9e) : AppTheme.text;
|
|
||||||
wrap: no-wrap; overflow: clip; vertical-alignment: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EdgeBack { x: 0; y: 0; back => { root.back(); } }
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
x: root.safe-area-insets.left;
|
|
||||||
y: root.height - root.safe-area-insets.bottom - root.toolbar-height;
|
|
||||||
width: root.width - root.safe-area-insets.left - root.safe-area-insets.right;
|
|
||||||
height: root.toolbar-height;
|
|
||||||
background: AppTheme.toolbar;
|
|
||||||
TabButton {
|
|
||||||
x: 0; width: parent.width / 5; height: parent.height; kind: "home"; label: "Home"; active: root.tab == "home";
|
|
||||||
clicked => {
|
|
||||||
if root.tab == "home" { root.home_scroll_request += 1; }
|
|
||||||
root.select_tab("home");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TabButton {
|
|
||||||
x: parent.width / 5; width: parent.width / 5; height: parent.height; kind: "issues"; label: "Issues"; active: root.tab == "issues";
|
|
||||||
clicked => {
|
|
||||||
if root.tab == "issues" {
|
|
||||||
if root.page == "servers" { root.servers_scroll_request += 1; }
|
|
||||||
if root.page == "repositories" { root.repositories_scroll_request += 1; }
|
|
||||||
if root.page == "issues" { root.issues_scroll_request += 1; }
|
|
||||||
if root.page == "issue" { root.issue_scroll_request += 1; }
|
|
||||||
}
|
|
||||||
root.select_tab("issues");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TabButton { x: parent.width * 2 / 5; width: parent.width / 5; height: parent.height; kind: "repositories"; label: "Repos"; active: root.tab == "repositories"; clicked => { root.select_tab("repositories"); } }
|
|
||||||
TabButton { x: parent.width * 3 / 5; width: parent.width / 5; height: parent.height; kind: "pulls"; label: "Pulls"; active: root.tab == "pulls"; clicked => { root.select_tab("pulls"); } }
|
|
||||||
TabButton { x: parent.width * 4 / 5; width: parent.width / 5; height: parent.height; kind: "settings"; label: "Settings"; active: root.tab == "settings"; clicked => { root.select_tab("settings"); } }
|
|
||||||
}
|
|
||||||
|
|
||||||
if root.loading && (root.tab == "issues" || root.tab == "repositories" || root.tab == "pulls"): Rectangle {
|
|
||||||
width: 100%; height: 100%;
|
|
||||||
background: AppTheme.loading-overlay;
|
|
||||||
TouchArea { }
|
|
||||||
Spinner { width: 38px; height: 38px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; }
|
|
||||||
}
|
|
||||||
if root.filter_open: Rectangle {
|
|
||||||
width: 100%; height: 100%; background: #00000066;
|
|
||||||
TouchArea { clicked => { root.filter_open = false; } }
|
|
||||||
Rectangle {
|
|
||||||
x: 28px; y: (parent.height - 190px) / 2; width: parent.width - 56px; height: 190px;
|
|
||||||
background: AppTheme.surface; border-radius: 14px;
|
|
||||||
Text { x: 18px; y: 16px; width: parent.width - 36px; text: "Status"; font-size: 19px; font-weight: 650; color: AppTheme.text; }
|
|
||||||
VerticalLayout {
|
|
||||||
x: 18px; y: 54px; width: parent.width - 36px; height: 118px; spacing: 8px;
|
|
||||||
Button { text: "Open"; clicked => { root.choose_filter("open"); root.filter_open = false; } }
|
|
||||||
Button { text: "Closed"; clicked => { root.choose_filter("closed"); root.filter_open = false; } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if root.error != "": Rectangle {
|
|
||||||
x: 14px; y: parent.height - root.safe-area-insets.bottom - 132px; width: parent.width - 28px; height: 66px;
|
|
||||||
background: #d93d35; border-radius: 12px;
|
|
||||||
Text { x: 14px; width: parent.width - 28px; text: root.error; color: white; font-size: 13px; wrap: word-wrap; vertical-alignment: center; }
|
|
||||||
TouchArea { clicked => { root.error = ""; } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,318 +0,0 @@
|
|||||||
import { Palette } from "std-widgets.slint";
|
|
||||||
import { CommentRow, FileRow, MarkdownBlock } from "types.slint";
|
|
||||||
|
|
||||||
export global IconTheme {
|
|
||||||
in-out property <int> style: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export global AppTheme {
|
|
||||||
in-out property <int> mode: 0;
|
|
||||||
private property <color> system-background: Palette.background;
|
|
||||||
out property <bool> dark: mode == 2 || (mode == 0 && system-background.to-oklch().lightness < 0.5);
|
|
||||||
out property <color> accent: dark ? #0a84ff : #0879e1;
|
|
||||||
out property <color> accent-soft: dark ? #17324d : #e5f2fd;
|
|
||||||
out property <color> page: dark ? #1c1c1e : #f2f2f7;
|
|
||||||
out property <color> surface: dark ? #2c2c2e : #ffffff;
|
|
||||||
out property <color> surface-muted: dark ? #3a3a3c : #f5f5f8;
|
|
||||||
out property <color> header: dark ? #242426 : #f8f8fa;
|
|
||||||
out property <color> toolbar: dark ? #242426 : #fbfbfc;
|
|
||||||
out property <color> border: dark ? #48484a : #dedee3;
|
|
||||||
out property <color> separator: dark ? #3a3a3c : #d8d8dd;
|
|
||||||
out property <color> text: dark ? #f2f2f7 : #17171a;
|
|
||||||
out property <color> text-secondary: dark ? #c7c7cc : #5f5f66;
|
|
||||||
out property <color> text-tertiary: dark ? #8e8e93 : #85858c;
|
|
||||||
out property <color> code-background: dark ? #242426 : #e9e9ed;
|
|
||||||
out property <color> quote-background: dark ? #242426 : #f6f6f8;
|
|
||||||
out property <color> loading-overlay: dark ? #00000088 : #ffffff88;
|
|
||||||
changed mode => {
|
|
||||||
Palette.color-scheme = mode == 1 ? ColorScheme.light : mode == 2 ? ColorScheme.dark : ColorScheme.unknown;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export component AppIcon inherits Rectangle {
|
|
||||||
in property <string> kind;
|
|
||||||
in property <int> style: IconTheme.style;
|
|
||||||
in property <color> icon-color: AppTheme.accent;
|
|
||||||
background: transparent;
|
|
||||||
|
|
||||||
private property <bool> duotone: root.style == 1;
|
|
||||||
private property <bool> bold: root.style == 2;
|
|
||||||
private property <bool> issue-kind: root.kind == "issues" || root.kind == "issue-open" || root.kind == "issue-close" || root.kind == "issue-reopen" || root.kind == "issue-comment";
|
|
||||||
private property <bool> repository-kind: root.kind == "repositories" || root.kind == "repo-create" || root.kind == "repo-rename" || root.kind == "repo-star" || root.kind == "repo-watch" || root.kind == "update";
|
|
||||||
private property <bool> pull-kind: root.kind == "pulls" || root.kind == "pull-open" || root.kind == "pull-merge" || root.kind == "pull-close" || root.kind == "pull-reopen" || root.kind == "pull-comment" || root.kind == "pull-approve" || root.kind == "pull-reject";
|
|
||||||
private property <bool> tag-kind: root.kind == "tag-push" || root.kind == "tag-delete";
|
|
||||||
private property <bool> branch-kind: root.kind == "branch-create" || root.kind == "branch-delete";
|
|
||||||
private property <bool> closed-shape: root.kind == "home" || root.issue-kind || root.repository-kind || root.kind == "favorite" || root.kind == "favorite-filled" || root.tag-kind || root.kind == "release";
|
|
||||||
private property <bool> has-badge: root.kind == "branch-create" || root.kind == "branch-delete" || root.kind == "push" || root.kind == "repo-create" || root.kind == "repo-rename" || root.kind == "repo-star" || root.kind == "repo-watch" || root.kind == "update" || root.kind == "issue-open" || root.kind == "issue-close" || root.kind == "issue-reopen" || root.kind == "issue-comment" || root.kind == "pull-open" || root.kind == "pull-merge" || root.kind == "pull-close" || root.kind == "pull-reopen" || root.kind == "pull-comment" || root.kind == "pull-approve" || root.kind == "pull-reject" || root.kind == "tag-push" || root.kind == "tag-delete";
|
|
||||||
private property <bool> positive: root.kind == "issue-close" || root.kind == "pull-merge" || root.kind == "pull-approve";
|
|
||||||
private property <bool> destructive: root.kind == "branch-delete" || root.kind == "pull-close" || root.kind == "pull-reject" || root.kind == "tag-delete";
|
|
||||||
private property <color> badge-color: root.positive ? #20a866 : root.destructive ? #e45b4d : root.duotone ? #26bfd1 : root.icon-color;
|
|
||||||
private property <string> shape: root.kind == "home"
|
|
||||||
? "M3 10.5 L12 3 L21 10.5 V21 H15 V14 H9 V21 H3 Z"
|
|
||||||
: root.issue-kind
|
|
||||||
? "M4 5 H20 V9 C18 9 18 15 20 15 V19 H4 V15 C6 15 6 9 4 9 Z"
|
|
||||||
: root.repository-kind
|
|
||||||
? "M6 3 H18 C19.1 3 20 3.9 20 5 V21 H6 C4.9 21 4 20.1 4 19 V5 C4 3.9 4.9 3 6 3 Z"
|
|
||||||
: root.pull-kind
|
|
||||||
? "M7 4 V20 M17 4 V11 C17 15 14 17 10 17 M7 4 H7 M17 4 H17"
|
|
||||||
: root.kind == "settings"
|
|
||||||
? "M12 3 V6 M12 18 V21 M3 12 H6 M18 12 H21 M5.6 5.6 L7.8 7.8 M16.2 16.2 L18.4 18.4 M18.4 5.6 L16.2 7.8 M7.8 16.2 L5.6 18.4"
|
|
||||||
: root.kind == "back"
|
|
||||||
? (root.bold ? "M16 4 L7 12 L16 20 Z" : "M16 4 L7 12 L16 20")
|
|
||||||
: root.kind == "filter"
|
|
||||||
? (root.bold ? "M3 5 H21 L14 13 V20 L10 22 V13 Z" : "M4 6 H20 M4 12 H20 M4 18 H20")
|
|
||||||
: root.kind == "favorite" || root.kind == "favorite-filled"
|
|
||||||
? "M12 3 L14.8 8.7 L21 9.6 L16.5 14 L17.6 20.3 L12 17.3 L6.4 20.3 L7.5 14 L3 9.6 L9.2 8.7 Z"
|
|
||||||
: root.kind == "disclosure"
|
|
||||||
? (root.bold ? "M8 4 L17 12 L8 20 Z" : "M8 4 L17 12 L8 20")
|
|
||||||
: root.branch-kind
|
|
||||||
? "M7 4 V20 M7 11 C11 11 14 9 14 5"
|
|
||||||
: root.kind == "push"
|
|
||||||
? "M4 8 H13 M4 12 H13 M4 16 H13"
|
|
||||||
: root.tag-kind
|
|
||||||
? "M3 5 V13 L11 21 L21 11 L13 3 H5 C3.9 3 3 3.9 3 5 Z"
|
|
||||||
: root.kind == "release"
|
|
||||||
? "M4 8 L12 4 L20 8 V18 L12 22 L4 18 Z"
|
|
||||||
: "M12 4 V20 M4 12 H20";
|
|
||||||
private property <string> detail: root.kind == "home"
|
|
||||||
? "M9 21 V14 H15 V21"
|
|
||||||
: root.issue-kind
|
|
||||||
? "M12 8 V13 M12 16.5 V17"
|
|
||||||
: root.repository-kind
|
|
||||||
? "M7 3 V21 M11 9 L8.5 12 L11 15 M15 9 L17.5 12 L15 15"
|
|
||||||
: root.pull-kind
|
|
||||||
? "M7 4 A1.8 1.8 0 1 1 6.99 4 M7 20 A1.8 1.8 0 1 1 6.99 20 M17 4 A1.8 1.8 0 1 1 16.99 4"
|
|
||||||
: root.kind == "settings"
|
|
||||||
? "M12 8 A4 4 0 1 1 11.99 8"
|
|
||||||
: root.kind == "filter" && !root.bold
|
|
||||||
? "M9 4 V8 M15 10 V14 M8 16 V20"
|
|
||||||
: root.tag-kind
|
|
||||||
? "M8 8 A1.5 1.5 0 1 1 7.99 8"
|
|
||||||
: root.kind == "release"
|
|
||||||
? "M12 18 V8 M9 11 L12 8 L15 11 M7 6 V3 M17 6 V3"
|
|
||||||
: "";
|
|
||||||
private property <string> badge-detail: root.kind == "branch-create" || root.kind == "repo-create" || root.kind == "issue-open" || root.kind == "pull-open"
|
|
||||||
? "M18 15.5 V20.5 M15.5 18 H20.5"
|
|
||||||
: root.kind == "issue-close" || root.kind == "pull-merge" || root.kind == "pull-approve"
|
|
||||||
? "M15.4 18 L17.2 19.8 L20.7 16.2"
|
|
||||||
: root.kind == "branch-delete" || root.kind == "tag-delete"
|
|
||||||
? "M15.3 18 H20.7"
|
|
||||||
: root.kind == "pull-close" || root.kind == "pull-reject"
|
|
||||||
? "M16 16 L20 20 M20 16 L16 20"
|
|
||||||
: root.kind == "push" || root.kind == "tag-push"
|
|
||||||
? "M18 20.5 V15.5 M15.7 17.8 L18 15.5 L20.3 17.8"
|
|
||||||
: root.kind == "repo-rename"
|
|
||||||
? "M15.3 20.6 L16 18 L19.7 14.3 L21.7 16.3 L18 20 Z"
|
|
||||||
: root.kind == "repo-star"
|
|
||||||
? "M18 14.5 L19 16.7 L21.4 17 L19.6 18.7 L20.1 21 L18 19.8 L15.9 21 L16.4 18.7 L14.6 17 L17 16.7 Z"
|
|
||||||
: root.kind == "repo-watch"
|
|
||||||
? "M14.5 18 C16.3 15.8 19.7 15.8 21.5 18 C19.7 20.2 16.3 20.2 14.5 18 M18 17 A1 1 0 1 1 17.99 17"
|
|
||||||
: root.kind == "issue-reopen" || root.kind == "pull-reopen"
|
|
||||||
? "M20.8 17 A3 3 0 1 0 20.2 20.3 M20.8 17 H18.5 M20.8 17 V19.3"
|
|
||||||
: root.kind == "issue-comment" || root.kind == "pull-comment"
|
|
||||||
? "M15.5 17 H20.5 V20 H18 L16 21 V20 H15.5 Z M17 18.5 H17.1 M19 18.5 H19.1"
|
|
||||||
: root.kind == "update"
|
|
||||||
? "M18 14.8 L18.8 17.2 L21.2 18 L18.8 18.8 L18 21.2 L17.2 18.8 L14.8 18 L17.2 17.2 Z"
|
|
||||||
: "";
|
|
||||||
|
|
||||||
Path {
|
|
||||||
width: 100%; height: 100%; viewbox-width: 24; viewbox-height: 24;
|
|
||||||
commands: root.shape;
|
|
||||||
fill: root.closed-shape ? (root.bold || root.kind == "favorite-filled" ? root.icon-color : root.duotone ? #26bfd133 : transparent) : transparent;
|
|
||||||
stroke: root.closed-shape && (root.bold || root.kind == "favorite-filled") ? transparent : root.icon-color;
|
|
||||||
stroke-width: root.bold ? 3px : root.duotone ? 2.2px : 1.8px;
|
|
||||||
stroke-line-cap: round; stroke-line-join: round;
|
|
||||||
}
|
|
||||||
if root.detail != "": Path {
|
|
||||||
width: 100%; height: 100%; viewbox-width: 24; viewbox-height: 24;
|
|
||||||
commands: root.detail; fill: transparent;
|
|
||||||
stroke: root.bold && root.closed-shape ? #ffffff : root.icon-color;
|
|
||||||
stroke-width: root.bold ? 2.4px : 1.8px;
|
|
||||||
stroke-line-cap: round; stroke-line-join: round;
|
|
||||||
}
|
|
||||||
if root.has-badge: Path {
|
|
||||||
width: 100%; height: 100%; viewbox-width: 24; viewbox-height: 24;
|
|
||||||
commands: "M18 13.5 A4.5 4.5 0 1 1 17.99 13.5 Z";
|
|
||||||
fill: root.style == 0 ? AppTheme.surface : root.badge-color;
|
|
||||||
stroke: root.style == 0 ? root.badge-color : #ffffff;
|
|
||||||
stroke-width: root.style == 2 ? 1.5px : 1.2px;
|
|
||||||
}
|
|
||||||
if root.has-badge: Path {
|
|
||||||
width: 100%; height: 100%; viewbox-width: 24; viewbox-height: 24;
|
|
||||||
commands: root.badge-detail; fill: root.style == 0 ? transparent : #ffffff;
|
|
||||||
stroke: root.style == 0 ? root.badge-color : #ffffff;
|
|
||||||
stroke-width: root.bold ? 2px : 1.6px;
|
|
||||||
stroke-line-cap: round; stroke-line-join: round;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export component IconStyleChoice inherits Rectangle {
|
|
||||||
in property <int> value;
|
|
||||||
in property <string> label;
|
|
||||||
callback clicked();
|
|
||||||
background: IconTheme.style == root.value ? AppTheme.accent-soft : AppTheme.surface-muted;
|
|
||||||
border-color: IconTheme.style == root.value ? AppTheme.accent : AppTheme.border;
|
|
||||||
border-width: IconTheme.style == root.value ? 2px : 1px;
|
|
||||||
border-radius: 11px;
|
|
||||||
TouchArea { clicked => { root.clicked(); } }
|
|
||||||
AppIcon { x: (parent.width - 30px) / 2; y: 10px; width: 30px; height: 30px; kind: "repositories"; style: root.value; }
|
|
||||||
Text { y: 48px; width: 100%; text: root.label; font-size: 12px; font-weight: IconTheme.style == root.value ? 650 : 500; color: AppTheme.text; horizontal-alignment: center; }
|
|
||||||
}
|
|
||||||
|
|
||||||
export component AppearanceChoice inherits Rectangle {
|
|
||||||
in property <int> value;
|
|
||||||
in property <string> label;
|
|
||||||
callback clicked();
|
|
||||||
background: AppTheme.mode == root.value ? AppTheme.accent-soft : AppTheme.surface-muted;
|
|
||||||
border-color: AppTheme.mode == root.value ? AppTheme.accent : AppTheme.border;
|
|
||||||
border-width: AppTheme.mode == root.value ? 2px : 1px;
|
|
||||||
border-radius: 11px;
|
|
||||||
TouchArea { clicked => { root.clicked(); } }
|
|
||||||
Text {
|
|
||||||
width: 100%; height: 100%; text: root.label;
|
|
||||||
font-size: 14px; font-weight: AppTheme.mode == root.value ? 650 : 500;
|
|
||||||
color: AppTheme.mode == root.value ? AppTheme.accent : AppTheme.text;
|
|
||||||
horizontal-alignment: center; vertical-alignment: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export component Header inherits Rectangle {
|
|
||||||
in property <string> title;
|
|
||||||
in property <bool> can_back;
|
|
||||||
in property <bool> can_filter: false;
|
|
||||||
callback back();
|
|
||||||
callback filter();
|
|
||||||
height: 58px;
|
|
||||||
width: 100%;
|
|
||||||
background: AppTheme.header;
|
|
||||||
border-color: AppTheme.border;
|
|
||||||
border-width: 0px;
|
|
||||||
|
|
||||||
if can_back: TouchArea {
|
|
||||||
x: 8px; width: 54px;
|
|
||||||
clicked => { root.back(); }
|
|
||||||
AppIcon { x: 7px; y: 17px; width: 24px; height: 24px; kind: "back"; }
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
x: root.can_back || root.can_filter ? 62px : 12px;
|
|
||||||
width: parent.width - 2 * self.x;
|
|
||||||
text: root.title;
|
|
||||||
horizontal-alignment: center;
|
|
||||||
vertical-alignment: center;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: AppTheme.text;
|
|
||||||
overflow: elide;
|
|
||||||
}
|
|
||||||
if can_filter: TouchArea {
|
|
||||||
x: parent.width - 58px; width: 58px;
|
|
||||||
clicked => { root.filter(); }
|
|
||||||
AppIcon { x: 17px; y: 17px; width: 24px; height: 24px; kind: "filter"; }
|
|
||||||
}
|
|
||||||
Rectangle { y: parent.height - 1px; height: 1px; background: AppTheme.border; }
|
|
||||||
}
|
|
||||||
|
|
||||||
export component EdgeBack inherits SwipeGestureHandler {
|
|
||||||
callback back();
|
|
||||||
width: 24px;
|
|
||||||
height: 100%;
|
|
||||||
handle-swipe-right: true;
|
|
||||||
swiped => { root.back(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
export component EmptyState inherits VerticalLayout {
|
|
||||||
in property <string> title;
|
|
||||||
in property <string> detail;
|
|
||||||
alignment: center;
|
|
||||||
spacing: 8px;
|
|
||||||
Text { text: root.title; font-size: 21px; font-weight: 600; horizontal-alignment: center; color: AppTheme.text; }
|
|
||||||
Text { text: root.detail; font-size: 15px; horizontal-alignment: center; wrap: word-wrap; color: AppTheme.text-secondary; }
|
|
||||||
}
|
|
||||||
|
|
||||||
export component MarkdownContent inherits VerticalLayout {
|
|
||||||
in property <[MarkdownBlock]> blocks;
|
|
||||||
spacing: 8px;
|
|
||||||
|
|
||||||
for block in root.blocks: Rectangle {
|
|
||||||
height: block.kind == "rule" ? 9px : rendered.preferred-height + (block.kind == "code" || block.kind == "table" ? 16px : 0px);
|
|
||||||
background: block.kind == "code" || block.kind == "table" ? AppTheme.code-background : block.kind == "quote" ? AppTheme.quote-background : transparent;
|
|
||||||
border-radius: block.kind == "code" || block.kind == "table" ? 7px : 0px;
|
|
||||||
if block.kind == "quote": Rectangle { width: 3px; height: 100%; background: AppTheme.text-tertiary; border-radius: 2px; }
|
|
||||||
if block.kind == "rule": Rectangle { y: 4px; width: 100%; height: 1px; background: AppTheme.separator; }
|
|
||||||
rendered := StyledText {
|
|
||||||
x: block.kind == "code" || block.kind == "table" ? 8px : block.kind == "quote" ? 11px : 0px;
|
|
||||||
y: block.kind == "code" || block.kind == "table" ? 8px : 0px;
|
|
||||||
width: parent.width - self.x - (block.kind == "code" || block.kind == "table" ? 8px : 0px);
|
|
||||||
text: block.text;
|
|
||||||
default-color: AppTheme.text;
|
|
||||||
link-color: AppTheme.accent;
|
|
||||||
default-font-family: block.kind == "code" || block.kind == "table" ? "monospace" : "";
|
|
||||||
default-font-size: block.kind == "heading1" ? 24px : block.kind == "heading2" ? 21px : block.kind == "heading3" ? 19px : block.kind == "heading4" ? 17px : 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export component CommentCard inherits Rectangle {
|
|
||||||
in property <CommentRow> comment;
|
|
||||||
height: content.preferred-height + 24px;
|
|
||||||
background: AppTheme.surface;
|
|
||||||
border-radius: 10px;
|
|
||||||
content := VerticalLayout {
|
|
||||||
x: 12px; y: 10px; width: parent.width - 24px;
|
|
||||||
spacing: 5px;
|
|
||||||
Text { text: root.comment.author; font-size: 13px; font-weight: 600; color: AppTheme.text; }
|
|
||||||
MarkdownContent { width: parent.width; blocks: root.comment.body; }
|
|
||||||
Text { text: root.comment.meta; font-size: 11px; color: AppTheme.text-tertiary; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export component FileCard inherits Rectangle {
|
|
||||||
in property <FileRow> file;
|
|
||||||
callback clicked();
|
|
||||||
height: 66px;
|
|
||||||
background: AppTheme.surface;
|
|
||||||
border-color: AppTheme.border;
|
|
||||||
border-width: 1px;
|
|
||||||
border-radius: 10px;
|
|
||||||
TouchArea { clicked => { root.clicked(); } }
|
|
||||||
Text { x: 13px; y: 10px; width: parent.width - 26px; text: root.file.path; font-size: 14px; font-weight: 600; color: AppTheme.text; overflow: elide; }
|
|
||||||
Text { x: 13px; y: 38px; width: parent.width - 26px; text: root.file.status; font-size: 12px; color: AppTheme.text-secondary; }
|
|
||||||
}
|
|
||||||
|
|
||||||
export component TabButton inherits Rectangle {
|
|
||||||
in property <string> kind;
|
|
||||||
in property <string> label;
|
|
||||||
in property <bool> active;
|
|
||||||
private property <length> icon-area-height: root.height * 3 / 4;
|
|
||||||
private property <length> icon-size: min(42px, self.icon-area-height - 6px);
|
|
||||||
callback clicked();
|
|
||||||
background: transparent;
|
|
||||||
TouchArea { clicked => { root.clicked(); } }
|
|
||||||
AppIcon {
|
|
||||||
x: (parent.width - root.icon-size) / 2;
|
|
||||||
y: (root.icon-area-height - root.icon-size) / 2;
|
|
||||||
width: root.icon-size; height: root.icon-size;
|
|
||||||
kind: root.kind; icon-color: root.active ? AppTheme.accent : AppTheme.text-tertiary;
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
y: root.icon-area-height; height: parent.height - self.y; width: 100%; text: root.label;
|
|
||||||
color: root.active ? AppTheme.accent : AppTheme.text-tertiary; font-size: 9px;
|
|
||||||
font-weight: root.active ? 600 : 400;
|
|
||||||
horizontal-alignment: center; vertical-alignment: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export component PullToRefreshGesture inherits SwipeGestureHandler {
|
|
||||||
in property <bool> at_top;
|
|
||||||
in property <bool> refreshing;
|
|
||||||
callback refresh();
|
|
||||||
enabled: root.at_top && !root.refreshing;
|
|
||||||
handle-swipe-down: true;
|
|
||||||
swiped => { root.refresh(); }
|
|
||||||
@children
|
|
||||||
if self.swiping: Rectangle {
|
|
||||||
x: (parent.width - 142px) / 2; y: 8px; width: 142px; height: 32px;
|
|
||||||
background: AppTheme.surface-muted; border-radius: 16px;
|
|
||||||
Text { text: "Release to refresh"; font-size: 12px; color: AppTheme.text-secondary; horizontal-alignment: center; vertical-alignment: center; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
export struct ServerRow { name: string, url: string }
|
|
||||||
export struct RepositoryRow {
|
|
||||||
name: string, owner: string, description: string, meta: string, favorite: bool,
|
|
||||||
}
|
|
||||||
export struct LabelRow { name: string, background: color, foreground: color }
|
|
||||||
export struct IssueRow {
|
|
||||||
number: int, title: string, summary: string, meta: string, labels: [LabelRow],
|
|
||||||
}
|
|
||||||
export struct PullRow {
|
|
||||||
number: int, owner: string, repository: string, title: string, summary: string, meta: string,
|
|
||||||
}
|
|
||||||
export struct MarkdownBlock { text: styled-text, kind: string }
|
|
||||||
export struct CommentRow { author: string, body: [MarkdownBlock], meta: string }
|
|
||||||
export struct GraphLane { color: color, top: bool, bottom: bool, node: bool, connect_left: bool }
|
|
||||||
export struct CommitRow {
|
|
||||||
sha: string, title: string, meta: string, refs: string, lanes: [GraphLane],
|
|
||||||
}
|
|
||||||
export struct FileRow { path: string, status: string }
|
|
||||||
export struct ActivityRow {
|
|
||||||
icon: string, title: string, detail: string, meta: string,
|
|
||||||
target: string, owner: string, repository: string, number: int, sha: string,
|
|
||||||
}
|
|
||||||
export struct DiffLine { old_number: string, new_number: string, text: string, kind: string }
|
|
||||||
export struct HeatCell { week: int, day: int, level: int }
|
|
||||||
7
ios/Generated/gotcha_core.modulemap
Normal file
7
ios/Generated/gotcha_core.modulemap
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
module gotcha_core {
|
||||||
|
header "gotcha_coreFFI.h"
|
||||||
|
export *
|
||||||
|
use "Darwin"
|
||||||
|
use "_Builtin_stdbool"
|
||||||
|
use "_Builtin_stdint"
|
||||||
|
}
|
||||||
2858
ios/Generated/gotcha_core.swift
Normal file
2858
ios/Generated/gotcha_core.swift
Normal file
File diff suppressed because it is too large
Load Diff
786
ios/Generated/gotcha_coreFFI.h
Normal file
786
ios/Generated/gotcha_coreFFI.h
Normal file
@@ -0,0 +1,786 @@
|
|||||||
|
// This file was autogenerated by some hot garbage in the `uniffi` crate.
|
||||||
|
// Trust me, you don't want to mess with it!
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
// The following structs are used to implement the lowest level
|
||||||
|
// of the FFI, and thus useful to multiple uniffied crates.
|
||||||
|
// We ensure they are declared exactly once, with a header guard, UNIFFI_SHARED_H.
|
||||||
|
#ifdef UNIFFI_SHARED_H
|
||||||
|
// We also try to prevent mixing versions of shared uniffi header structs.
|
||||||
|
// If you add anything to the #else block, you must increment the version suffix in UNIFFI_SHARED_HEADER_V4
|
||||||
|
#ifndef UNIFFI_SHARED_HEADER_V4
|
||||||
|
#error Combining helper code from multiple versions of uniffi is not supported
|
||||||
|
#endif // ndef UNIFFI_SHARED_HEADER_V4
|
||||||
|
#else
|
||||||
|
#define UNIFFI_SHARED_H
|
||||||
|
#define UNIFFI_SHARED_HEADER_V4
|
||||||
|
// ⚠️ Attention: If you change this #else block (ending in `#endif // def UNIFFI_SHARED_H`) you *must* ⚠️
|
||||||
|
// ⚠️ increment the version suffix in all instances of UNIFFI_SHARED_HEADER_V4 in this file. ⚠️
|
||||||
|
|
||||||
|
typedef struct RustBuffer
|
||||||
|
{
|
||||||
|
uint64_t capacity;
|
||||||
|
uint64_t len;
|
||||||
|
uint8_t *_Nullable data;
|
||||||
|
} RustBuffer;
|
||||||
|
|
||||||
|
typedef struct ForeignBytes
|
||||||
|
{
|
||||||
|
int32_t len;
|
||||||
|
const uint8_t *_Nullable data;
|
||||||
|
} ForeignBytes;
|
||||||
|
|
||||||
|
// Error definitions
|
||||||
|
typedef struct RustCallStatus {
|
||||||
|
int8_t code;
|
||||||
|
RustBuffer errorBuf;
|
||||||
|
} RustCallStatus;
|
||||||
|
|
||||||
|
// ⚠️ Attention: If you change this #else block (ending in `#endif // def UNIFFI_SHARED_H`) you *must* ⚠️
|
||||||
|
// ⚠️ increment the version suffix in all instances of UNIFFI_SHARED_HEADER_V4 in this file. ⚠️
|
||||||
|
#endif // def UNIFFI_SHARED_H
|
||||||
|
#ifndef UNIFFI_FFIDEF_RUST_FUTURE_CONTINUATION_CALLBACK
|
||||||
|
#define UNIFFI_FFIDEF_RUST_FUTURE_CONTINUATION_CALLBACK
|
||||||
|
typedef void (*UniffiRustFutureContinuationCallback)(uint64_t, int8_t
|
||||||
|
);
|
||||||
|
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_DROPPED_CALLBACK
|
||||||
|
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_DROPPED_CALLBACK
|
||||||
|
typedef void (*UniffiForeignFutureDroppedCallback)(uint64_t
|
||||||
|
);
|
||||||
|
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_FREE
|
||||||
|
#define UNIFFI_FFIDEF_CALLBACK_INTERFACE_FREE
|
||||||
|
typedef void (*UniffiCallbackInterfaceFree)(uint64_t
|
||||||
|
);
|
||||||
|
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_CLONE
|
||||||
|
#define UNIFFI_FFIDEF_CALLBACK_INTERFACE_CLONE
|
||||||
|
typedef uint64_t (*UniffiCallbackInterfaceClone)(uint64_t
|
||||||
|
);
|
||||||
|
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_DROPPED_CALLBACK_STRUCT
|
||||||
|
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_DROPPED_CALLBACK_STRUCT
|
||||||
|
typedef struct UniffiForeignFutureDroppedCallbackStruct {
|
||||||
|
uint64_t handle;
|
||||||
|
UniffiForeignFutureDroppedCallback _Nonnull free;
|
||||||
|
} UniffiForeignFutureDroppedCallbackStruct;
|
||||||
|
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U8
|
||||||
|
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U8
|
||||||
|
typedef struct UniffiForeignFutureResultU8 {
|
||||||
|
uint8_t returnValue;
|
||||||
|
RustCallStatus callStatus;
|
||||||
|
} UniffiForeignFutureResultU8;
|
||||||
|
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U8
|
||||||
|
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U8
|
||||||
|
typedef void (*UniffiForeignFutureCompleteU8)(uint64_t, UniffiForeignFutureResultU8
|
||||||
|
);
|
||||||
|
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I8
|
||||||
|
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I8
|
||||||
|
typedef struct UniffiForeignFutureResultI8 {
|
||||||
|
int8_t returnValue;
|
||||||
|
RustCallStatus callStatus;
|
||||||
|
} UniffiForeignFutureResultI8;
|
||||||
|
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I8
|
||||||
|
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I8
|
||||||
|
typedef void (*UniffiForeignFutureCompleteI8)(uint64_t, UniffiForeignFutureResultI8
|
||||||
|
);
|
||||||
|
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U16
|
||||||
|
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U16
|
||||||
|
typedef struct UniffiForeignFutureResultU16 {
|
||||||
|
uint16_t returnValue;
|
||||||
|
RustCallStatus callStatus;
|
||||||
|
} UniffiForeignFutureResultU16;
|
||||||
|
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U16
|
||||||
|
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U16
|
||||||
|
typedef void (*UniffiForeignFutureCompleteU16)(uint64_t, UniffiForeignFutureResultU16
|
||||||
|
);
|
||||||
|
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I16
|
||||||
|
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I16
|
||||||
|
typedef struct UniffiForeignFutureResultI16 {
|
||||||
|
int16_t returnValue;
|
||||||
|
RustCallStatus callStatus;
|
||||||
|
} UniffiForeignFutureResultI16;
|
||||||
|
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I16
|
||||||
|
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I16
|
||||||
|
typedef void (*UniffiForeignFutureCompleteI16)(uint64_t, UniffiForeignFutureResultI16
|
||||||
|
);
|
||||||
|
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U32
|
||||||
|
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U32
|
||||||
|
typedef struct UniffiForeignFutureResultU32 {
|
||||||
|
uint32_t returnValue;
|
||||||
|
RustCallStatus callStatus;
|
||||||
|
} UniffiForeignFutureResultU32;
|
||||||
|
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U32
|
||||||
|
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U32
|
||||||
|
typedef void (*UniffiForeignFutureCompleteU32)(uint64_t, UniffiForeignFutureResultU32
|
||||||
|
);
|
||||||
|
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I32
|
||||||
|
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I32
|
||||||
|
typedef struct UniffiForeignFutureResultI32 {
|
||||||
|
int32_t returnValue;
|
||||||
|
RustCallStatus callStatus;
|
||||||
|
} UniffiForeignFutureResultI32;
|
||||||
|
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I32
|
||||||
|
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I32
|
||||||
|
typedef void (*UniffiForeignFutureCompleteI32)(uint64_t, UniffiForeignFutureResultI32
|
||||||
|
);
|
||||||
|
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U64
|
||||||
|
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U64
|
||||||
|
typedef struct UniffiForeignFutureResultU64 {
|
||||||
|
uint64_t returnValue;
|
||||||
|
RustCallStatus callStatus;
|
||||||
|
} UniffiForeignFutureResultU64;
|
||||||
|
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U64
|
||||||
|
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U64
|
||||||
|
typedef void (*UniffiForeignFutureCompleteU64)(uint64_t, UniffiForeignFutureResultU64
|
||||||
|
);
|
||||||
|
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I64
|
||||||
|
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I64
|
||||||
|
typedef struct UniffiForeignFutureResultI64 {
|
||||||
|
int64_t returnValue;
|
||||||
|
RustCallStatus callStatus;
|
||||||
|
} UniffiForeignFutureResultI64;
|
||||||
|
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I64
|
||||||
|
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I64
|
||||||
|
typedef void (*UniffiForeignFutureCompleteI64)(uint64_t, UniffiForeignFutureResultI64
|
||||||
|
);
|
||||||
|
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_F32
|
||||||
|
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_F32
|
||||||
|
typedef struct UniffiForeignFutureResultF32 {
|
||||||
|
float returnValue;
|
||||||
|
RustCallStatus callStatus;
|
||||||
|
} UniffiForeignFutureResultF32;
|
||||||
|
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F32
|
||||||
|
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F32
|
||||||
|
typedef void (*UniffiForeignFutureCompleteF32)(uint64_t, UniffiForeignFutureResultF32
|
||||||
|
);
|
||||||
|
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_F64
|
||||||
|
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_F64
|
||||||
|
typedef struct UniffiForeignFutureResultF64 {
|
||||||
|
double returnValue;
|
||||||
|
RustCallStatus callStatus;
|
||||||
|
} UniffiForeignFutureResultF64;
|
||||||
|
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F64
|
||||||
|
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F64
|
||||||
|
typedef void (*UniffiForeignFutureCompleteF64)(uint64_t, UniffiForeignFutureResultF64
|
||||||
|
);
|
||||||
|
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_RUST_BUFFER
|
||||||
|
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_RUST_BUFFER
|
||||||
|
typedef struct UniffiForeignFutureResultRustBuffer {
|
||||||
|
RustBuffer returnValue;
|
||||||
|
RustCallStatus callStatus;
|
||||||
|
} UniffiForeignFutureResultRustBuffer;
|
||||||
|
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_RUST_BUFFER
|
||||||
|
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_RUST_BUFFER
|
||||||
|
typedef void (*UniffiForeignFutureCompleteRustBuffer)(uint64_t, UniffiForeignFutureResultRustBuffer
|
||||||
|
);
|
||||||
|
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_VOID
|
||||||
|
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_VOID
|
||||||
|
typedef struct UniffiForeignFutureResultVoid {
|
||||||
|
RustCallStatus callStatus;
|
||||||
|
} UniffiForeignFutureResultVoid;
|
||||||
|
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_VOID
|
||||||
|
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_VOID
|
||||||
|
typedef void (*UniffiForeignFutureCompleteVoid)(uint64_t, UniffiForeignFutureResultVoid
|
||||||
|
);
|
||||||
|
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_CLONE_GOTCHACORE
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_CLONE_GOTCHACORE
|
||||||
|
uint64_t uniffi_gotcha_core_fn_clone_gotchacore(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_FREE_GOTCHACORE
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_FREE_GOTCHACORE
|
||||||
|
void uniffi_gotcha_core_fn_free_gotchacore(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_CONSTRUCTOR_GOTCHACORE_NEW
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_CONSTRUCTOR_GOTCHACORE_NEW
|
||||||
|
uint64_t uniffi_gotcha_core_fn_constructor_gotchacore_new(RustCallStatus *_Nonnull out_status
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTIVE_SERVER_INDEX
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTIVE_SERVER_INDEX
|
||||||
|
RustBuffer uniffi_gotcha_core_fn_method_gotchacore_active_server_index(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTIVE_SERVER_NAME
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTIVE_SERVER_NAME
|
||||||
|
RustBuffer uniffi_gotcha_core_fn_method_gotchacore_active_server_name(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ADD_SERVER
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ADD_SERVER
|
||||||
|
uint64_t uniffi_gotcha_core_fn_method_gotchacore_add_server(uint64_t ptr, RustBuffer name, RustBuffer url, RustBuffer token
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_COMMIT_DIFF
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_COMMIT_DIFF
|
||||||
|
uint64_t uniffi_gotcha_core_fn_method_gotchacore_commit_diff(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer sha, RustBuffer path
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_COMMIT_FILES
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_COMMIT_FILES
|
||||||
|
uint64_t uniffi_gotcha_core_fn_method_gotchacore_commit_files(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer sha
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_COMMITS
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_COMMITS
|
||||||
|
uint64_t uniffi_gotcha_core_fn_method_gotchacore_commits(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer branch
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_HOME
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_HOME
|
||||||
|
uint64_t uniffi_gotcha_core_fn_method_gotchacore_home(uint64_t ptr
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUE
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUE
|
||||||
|
uint64_t uniffi_gotcha_core_fn_method_gotchacore_issue(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUES
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUES
|
||||||
|
uint64_t uniffi_gotcha_core_fn_method_gotchacore_issues(uint64_t ptr, RustBuffer owner, RustBuffer repository
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_MILESTONE
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_MILESTONE
|
||||||
|
uint64_t uniffi_gotcha_core_fn_method_gotchacore_milestone(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t id
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_MILESTONES
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_MILESTONES
|
||||||
|
uint64_t uniffi_gotcha_core_fn_method_gotchacore_milestones(uint64_t ptr, RustBuffer owner, RustBuffer repository
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_PULL
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_PULL
|
||||||
|
uint64_t uniffi_gotcha_core_fn_method_gotchacore_pull(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_PULL_DIFF
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_PULL_DIFF
|
||||||
|
uint64_t uniffi_gotcha_core_fn_method_gotchacore_pull_diff(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number, RustBuffer path
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_PULLS
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_PULLS
|
||||||
|
uint64_t uniffi_gotcha_core_fn_method_gotchacore_pulls(uint64_t ptr
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_REPOSITORIES
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_REPOSITORIES
|
||||||
|
uint64_t uniffi_gotcha_core_fn_method_gotchacore_repositories(uint64_t ptr
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SELECT_SERVER
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SELECT_SERVER
|
||||||
|
void uniffi_gotcha_core_fn_method_gotchacore_select_server(uint64_t ptr, uint32_t index, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SERVERS
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SERVERS
|
||||||
|
RustBuffer uniffi_gotcha_core_fn_method_gotchacore_servers(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_APPEARANCE
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_APPEARANCE
|
||||||
|
void uniffi_gotcha_core_fn_method_gotchacore_set_appearance(uint64_t ptr, uint32_t index, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_ISSUE_STATUS
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_ISSUE_STATUS
|
||||||
|
void uniffi_gotcha_core_fn_method_gotchacore_set_issue_status(uint64_t ptr, RustBuffer status, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_PULL_STATUS
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_PULL_STATUS
|
||||||
|
void uniffi_gotcha_core_fn_method_gotchacore_set_pull_status(uint64_t ptr, RustBuffer status, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SETTINGS
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SETTINGS
|
||||||
|
RustBuffer uniffi_gotcha_core_fn_method_gotchacore_settings(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_STARTUP_ERROR
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_STARTUP_ERROR
|
||||||
|
RustBuffer uniffi_gotcha_core_fn_method_gotchacore_startup_error(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_TOGGLE_FAVORITE
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_TOGGLE_FAVORITE
|
||||||
|
RustBuffer uniffi_gotcha_core_fn_method_gotchacore_toggle_favorite(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUSTBUFFER_ALLOC
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUSTBUFFER_ALLOC
|
||||||
|
RustBuffer ffi_gotcha_core_rustbuffer_alloc(uint64_t size, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUSTBUFFER_FROM_BYTES
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUSTBUFFER_FROM_BYTES
|
||||||
|
RustBuffer ffi_gotcha_core_rustbuffer_from_bytes(ForeignBytes bytes, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUSTBUFFER_FREE
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUSTBUFFER_FREE
|
||||||
|
void ffi_gotcha_core_rustbuffer_free(RustBuffer buf, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUSTBUFFER_RESERVE
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUSTBUFFER_RESERVE
|
||||||
|
RustBuffer ffi_gotcha_core_rustbuffer_reserve(RustBuffer buf, uint64_t additional, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_POLL_U8
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_POLL_U8
|
||||||
|
void ffi_gotcha_core_rust_future_poll_u8(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_CANCEL_U8
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_CANCEL_U8
|
||||||
|
void ffi_gotcha_core_rust_future_cancel_u8(uint64_t handle
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_FREE_U8
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_FREE_U8
|
||||||
|
void ffi_gotcha_core_rust_future_free_u8(uint64_t handle
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_COMPLETE_U8
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_COMPLETE_U8
|
||||||
|
uint8_t ffi_gotcha_core_rust_future_complete_u8(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_POLL_I8
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_POLL_I8
|
||||||
|
void ffi_gotcha_core_rust_future_poll_i8(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_CANCEL_I8
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_CANCEL_I8
|
||||||
|
void ffi_gotcha_core_rust_future_cancel_i8(uint64_t handle
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_FREE_I8
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_FREE_I8
|
||||||
|
void ffi_gotcha_core_rust_future_free_i8(uint64_t handle
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_COMPLETE_I8
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_COMPLETE_I8
|
||||||
|
int8_t ffi_gotcha_core_rust_future_complete_i8(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_POLL_U16
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_POLL_U16
|
||||||
|
void ffi_gotcha_core_rust_future_poll_u16(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_CANCEL_U16
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_CANCEL_U16
|
||||||
|
void ffi_gotcha_core_rust_future_cancel_u16(uint64_t handle
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_FREE_U16
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_FREE_U16
|
||||||
|
void ffi_gotcha_core_rust_future_free_u16(uint64_t handle
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_COMPLETE_U16
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_COMPLETE_U16
|
||||||
|
uint16_t ffi_gotcha_core_rust_future_complete_u16(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_POLL_I16
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_POLL_I16
|
||||||
|
void ffi_gotcha_core_rust_future_poll_i16(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_CANCEL_I16
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_CANCEL_I16
|
||||||
|
void ffi_gotcha_core_rust_future_cancel_i16(uint64_t handle
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_FREE_I16
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_FREE_I16
|
||||||
|
void ffi_gotcha_core_rust_future_free_i16(uint64_t handle
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_COMPLETE_I16
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_COMPLETE_I16
|
||||||
|
int16_t ffi_gotcha_core_rust_future_complete_i16(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_POLL_U32
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_POLL_U32
|
||||||
|
void ffi_gotcha_core_rust_future_poll_u32(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_CANCEL_U32
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_CANCEL_U32
|
||||||
|
void ffi_gotcha_core_rust_future_cancel_u32(uint64_t handle
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_FREE_U32
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_FREE_U32
|
||||||
|
void ffi_gotcha_core_rust_future_free_u32(uint64_t handle
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_COMPLETE_U32
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_COMPLETE_U32
|
||||||
|
uint32_t ffi_gotcha_core_rust_future_complete_u32(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_POLL_I32
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_POLL_I32
|
||||||
|
void ffi_gotcha_core_rust_future_poll_i32(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_CANCEL_I32
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_CANCEL_I32
|
||||||
|
void ffi_gotcha_core_rust_future_cancel_i32(uint64_t handle
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_FREE_I32
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_FREE_I32
|
||||||
|
void ffi_gotcha_core_rust_future_free_i32(uint64_t handle
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_COMPLETE_I32
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_COMPLETE_I32
|
||||||
|
int32_t ffi_gotcha_core_rust_future_complete_i32(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_POLL_U64
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_POLL_U64
|
||||||
|
void ffi_gotcha_core_rust_future_poll_u64(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_CANCEL_U64
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_CANCEL_U64
|
||||||
|
void ffi_gotcha_core_rust_future_cancel_u64(uint64_t handle
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_FREE_U64
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_FREE_U64
|
||||||
|
void ffi_gotcha_core_rust_future_free_u64(uint64_t handle
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_COMPLETE_U64
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_COMPLETE_U64
|
||||||
|
uint64_t ffi_gotcha_core_rust_future_complete_u64(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_POLL_I64
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_POLL_I64
|
||||||
|
void ffi_gotcha_core_rust_future_poll_i64(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_CANCEL_I64
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_CANCEL_I64
|
||||||
|
void ffi_gotcha_core_rust_future_cancel_i64(uint64_t handle
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_FREE_I64
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_FREE_I64
|
||||||
|
void ffi_gotcha_core_rust_future_free_i64(uint64_t handle
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_COMPLETE_I64
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_COMPLETE_I64
|
||||||
|
int64_t ffi_gotcha_core_rust_future_complete_i64(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_POLL_F32
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_POLL_F32
|
||||||
|
void ffi_gotcha_core_rust_future_poll_f32(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_CANCEL_F32
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_CANCEL_F32
|
||||||
|
void ffi_gotcha_core_rust_future_cancel_f32(uint64_t handle
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_FREE_F32
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_FREE_F32
|
||||||
|
void ffi_gotcha_core_rust_future_free_f32(uint64_t handle
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_COMPLETE_F32
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_COMPLETE_F32
|
||||||
|
float ffi_gotcha_core_rust_future_complete_f32(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_POLL_F64
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_POLL_F64
|
||||||
|
void ffi_gotcha_core_rust_future_poll_f64(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_CANCEL_F64
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_CANCEL_F64
|
||||||
|
void ffi_gotcha_core_rust_future_cancel_f64(uint64_t handle
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_FREE_F64
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_FREE_F64
|
||||||
|
void ffi_gotcha_core_rust_future_free_f64(uint64_t handle
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_COMPLETE_F64
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_COMPLETE_F64
|
||||||
|
double ffi_gotcha_core_rust_future_complete_f64(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_POLL_RUST_BUFFER
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_POLL_RUST_BUFFER
|
||||||
|
void ffi_gotcha_core_rust_future_poll_rust_buffer(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_CANCEL_RUST_BUFFER
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_CANCEL_RUST_BUFFER
|
||||||
|
void ffi_gotcha_core_rust_future_cancel_rust_buffer(uint64_t handle
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_FREE_RUST_BUFFER
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_FREE_RUST_BUFFER
|
||||||
|
void ffi_gotcha_core_rust_future_free_rust_buffer(uint64_t handle
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_COMPLETE_RUST_BUFFER
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_COMPLETE_RUST_BUFFER
|
||||||
|
RustBuffer ffi_gotcha_core_rust_future_complete_rust_buffer(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_POLL_VOID
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_POLL_VOID
|
||||||
|
void ffi_gotcha_core_rust_future_poll_void(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_CANCEL_VOID
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_CANCEL_VOID
|
||||||
|
void ffi_gotcha_core_rust_future_cancel_void(uint64_t handle
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_FREE_VOID
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_FREE_VOID
|
||||||
|
void ffi_gotcha_core_rust_future_free_void(uint64_t handle
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_COMPLETE_VOID
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_COMPLETE_VOID
|
||||||
|
void ffi_gotcha_core_rust_future_complete_void(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTIVE_SERVER_INDEX
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTIVE_SERVER_INDEX
|
||||||
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_active_server_index(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTIVE_SERVER_NAME
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTIVE_SERVER_NAME
|
||||||
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_active_server_name(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ADD_SERVER
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ADD_SERVER
|
||||||
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_add_server(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_COMMIT_DIFF
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_COMMIT_DIFF
|
||||||
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_commit_diff(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_COMMIT_FILES
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_COMMIT_FILES
|
||||||
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_commit_files(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_COMMITS
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_COMMITS
|
||||||
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_commits(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_HOME
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_HOME
|
||||||
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_home(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ISSUE
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ISSUE
|
||||||
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_issue(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ISSUES
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ISSUES
|
||||||
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_issues(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_MILESTONE
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_MILESTONE
|
||||||
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_milestone(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_MILESTONES
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_MILESTONES
|
||||||
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_milestones(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_PULL
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_PULL
|
||||||
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_pull(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_PULL_DIFF
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_PULL_DIFF
|
||||||
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_pull_diff(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_PULLS
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_PULLS
|
||||||
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_pulls(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_REPOSITORIES
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_REPOSITORIES
|
||||||
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_repositories(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SELECT_SERVER
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SELECT_SERVER
|
||||||
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_select_server(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SERVERS
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SERVERS
|
||||||
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_servers(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_APPEARANCE
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_APPEARANCE
|
||||||
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_appearance(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_ISSUE_STATUS
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_ISSUE_STATUS
|
||||||
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_issue_status(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_PULL_STATUS
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_PULL_STATUS
|
||||||
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_pull_status(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SETTINGS
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SETTINGS
|
||||||
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_settings(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_STARTUP_ERROR
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_STARTUP_ERROR
|
||||||
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_startup_error(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_TOGGLE_FAVORITE
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_TOGGLE_FAVORITE
|
||||||
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_toggle_favorite(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_CONSTRUCTOR_GOTCHACORE_NEW
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_CONSTRUCTOR_GOTCHACORE_NEW
|
||||||
|
uint16_t uniffi_gotcha_core_checksum_constructor_gotchacore_new(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_UNIFFI_CONTRACT_VERSION
|
||||||
|
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_UNIFFI_CONTRACT_VERSION
|
||||||
|
uint32_t ffi_gotcha_core_uniffi_contract_version(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
1
ios/Gotcha-Bridging-Header.h
Normal file
1
ios/Gotcha-Bridging-Header.h
Normal file
@@ -0,0 +1 @@
|
|||||||
|
#include "Generated/gotcha_coreFFI.h"
|
||||||
@@ -7,19 +7,59 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
|
1AED04075E363FBC0FF55773 /* ContentScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE5E10787062D1175125BB4C /* ContentScreens.swift */; };
|
||||||
|
25BDBDED14B3B886E19F511C /* DetailScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EFB48F40AEB3016D3CF643 /* DetailScreens.swift */; };
|
||||||
|
374320EB00185A0C8E40D985 /* ListScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC828923A1E11A58AE348A74 /* ListScreens.swift */; };
|
||||||
|
950C584D58E80106DF350A22 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C0921C76676A14A024BA417 /* AppDelegate.swift */; };
|
||||||
|
96C4AFC206A1DBAE3D3E00CE /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */; };
|
||||||
|
D59D3ED36ABCC1D8690A9088 /* gotcha_core.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE12480725C13293FAFDDA52 /* gotcha_core.swift */; };
|
||||||
E7AC0B140F5CFC5EF226D174 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DDAABE6B13ADC6D08D9438AF /* Assets.xcassets */; };
|
E7AC0B140F5CFC5EF226D174 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DDAABE6B13ADC6D08D9438AF /* Assets.xcassets */; };
|
||||||
|
EC7F8B4703DDE37A0B10CD9B /* AppContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = F61515849F6AACD721FE915C /* AppContext.swift */; };
|
||||||
|
F55A89489B2758D694F3B27D /* Support.swift in Sources */ = {isa = PBXBuildFile; fileRef = F75B3E4FFB9C9992517C4D69 /* Support.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
13EFB48F40AEB3016D3CF643 /* DetailScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailScreens.swift; sourceTree = "<group>"; };
|
||||||
5FB3250A93766966A60A685E /* Gotcha.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Gotcha.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
5FB3250A93766966A60A685E /* Gotcha.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Gotcha.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = "<group>"; };
|
||||||
|
9C0921C76676A14A024BA417 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
AC828923A1E11A58AE348A74 /* ListScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListScreens.swift; sourceTree = "<group>"; };
|
||||||
|
CE5E10787062D1175125BB4C /* ContentScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentScreens.swift; sourceTree = "<group>"; };
|
||||||
DDAABE6B13ADC6D08D9438AF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
DDAABE6B13ADC6D08D9438AF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
|
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>"; };
|
||||||
|
FE12480725C13293FAFDDA52 /* gotcha_core.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = gotcha_core.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
|
710A50F51478401FC642E6E3 /* Sources */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
F61515849F6AACD721FE915C /* AppContext.swift */,
|
||||||
|
9C0921C76676A14A024BA417 /* AppDelegate.swift */,
|
||||||
|
CE5E10787062D1175125BB4C /* ContentScreens.swift */,
|
||||||
|
13EFB48F40AEB3016D3CF643 /* DetailScreens.swift */,
|
||||||
|
AC828923A1E11A58AE348A74 /* ListScreens.swift */,
|
||||||
|
8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */,
|
||||||
|
F75B3E4FFB9C9992517C4D69 /* Support.swift */,
|
||||||
|
);
|
||||||
|
path = Sources;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
94721140EFE7F8E7CD0F5C0B /* Generated */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
FE12480725C13293FAFDDA52 /* gotcha_core.swift */,
|
||||||
|
);
|
||||||
|
path = Generated;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
A8558BC8DD12191B80F52573 = {
|
A8558BC8DD12191B80F52573 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
DDAABE6B13ADC6D08D9438AF /* Assets.xcassets */,
|
DDAABE6B13ADC6D08D9438AF /* Assets.xcassets */,
|
||||||
|
94721140EFE7F8E7CD0F5C0B /* Generated */,
|
||||||
|
710A50F51478401FC642E6E3 /* Sources */,
|
||||||
F059299C038F3CAFCE470831 /* Products */,
|
F059299C038F3CAFCE470831 /* Products */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -39,8 +79,8 @@
|
|||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 7E8DF3DDA64C8C998FF3BB1D /* Build configuration list for PBXNativeTarget "Gotcha" */;
|
buildConfigurationList = 7E8DF3DDA64C8C998FF3BB1D /* Build configuration list for PBXNativeTarget "Gotcha" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
|
E01FE2CE8A64C51D25060BE9 /* Build Rust core */,
|
||||||
409DBF67E9C2743801B8F8A4 /* Sources */,
|
409DBF67E9C2743801B8F8A4 /* Sources */,
|
||||||
6334AD54CF86DAD2EC201EBC /* Build Rust app */,
|
|
||||||
9EDD4380917C29EE67EE344B /* Resources */,
|
9EDD4380917C29EE67EE344B /* Resources */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
@@ -63,6 +103,10 @@
|
|||||||
BuildIndependentTargetsInParallel = YES;
|
BuildIndependentTargetsInParallel = YES;
|
||||||
LastUpgradeCheck = 1430;
|
LastUpgradeCheck = 1430;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
|
60C9DF1AB4A7858833302BA5 = {
|
||||||
|
DevelopmentTeam = MU22FMRGK8;
|
||||||
|
ProvisioningStyle = Automatic;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = 807BC8918EC1B0F31384F2B7 /* Build configuration list for PBXProject "Gotcha" */;
|
buildConfigurationList = 807BC8918EC1B0F31384F2B7 /* Build configuration list for PBXProject "Gotcha" */;
|
||||||
@@ -96,7 +140,7 @@
|
|||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXShellScriptBuildPhase section */
|
/* Begin PBXShellScriptBuildPhase section */
|
||||||
6334AD54CF86DAD2EC201EBC /* Build Rust app */ = {
|
E01FE2CE8A64C51D25060BE9 /* Build Rust core */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
alwaysOutOfDate = 1;
|
alwaysOutOfDate = 1;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -106,15 +150,15 @@
|
|||||||
);
|
);
|
||||||
inputPaths = (
|
inputPaths = (
|
||||||
);
|
);
|
||||||
name = "Build Rust app";
|
name = "Build Rust core";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
);
|
);
|
||||||
outputPaths = (
|
outputPaths = (
|
||||||
"$(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH)",
|
"$(DERIVED_FILE_DIR)/rust/libgotcha_core.a",
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "./build_for_ios_with_cargo.bash gotcha-app\n";
|
shellScript = "./build_rust_core.bash\n";
|
||||||
};
|
};
|
||||||
/* End PBXShellScriptBuildPhase section */
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
@@ -123,6 +167,14 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
EC7F8B4703DDE37A0B10CD9B /* AppContext.swift in Sources */,
|
||||||
|
950C584D58E80106DF350A22 /* AppDelegate.swift in Sources */,
|
||||||
|
1AED04075E363FBC0FF55773 /* ContentScreens.swift in Sources */,
|
||||||
|
25BDBDED14B3B886E19F511C /* DetailScreens.swift in Sources */,
|
||||||
|
374320EB00185A0C8E40D985 /* ListScreens.swift in Sources */,
|
||||||
|
96C4AFC206A1DBAE3D3E00CE /* SettingsViewController.swift in Sources */,
|
||||||
|
F55A89489B2758D694F3B27D /* Support.swift in Sources */,
|
||||||
|
D59D3ED36ABCC1D8690A9088 /* gotcha_core.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -135,15 +187,21 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_ENTITLEMENTS = Gotcha.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Gotcha.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
DEVELOPMENT_TEAM = MU22FMRGK8;
|
||||||
INFOPLIST_FILE = Info.plist;
|
INFOPLIST_FILE = Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
|
LIBRARY_SEARCH_PATHS = "$(inherited) $(DERIVED_FILE_DIR)/rust";
|
||||||
|
OTHER_LDFLAGS = "$(inherited) -lgotcha_core";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.rfc1437.gotcha;
|
PRODUCT_BUNDLE_IDENTIFIER = de.rfc1437.gotcha;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
SWIFT_OBJC_BRIDGING_HEADER = "Gotcha-Bridging-Header.h";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
@@ -153,15 +211,21 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_ENTITLEMENTS = Gotcha.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Gotcha.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
DEVELOPMENT_TEAM = MU22FMRGK8;
|
||||||
INFOPLIST_FILE = Info.plist;
|
INFOPLIST_FILE = Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
|
LIBRARY_SEARCH_PATHS = "$(inherited) $(DERIVED_FILE_DIR)/rust";
|
||||||
|
OTHER_LDFLAGS = "$(inherited) -lgotcha_core";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.rfc1437.gotcha;
|
PRODUCT_BUNDLE_IDENTIFIER = de.rfc1437.gotcha;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
SWIFT_OBJC_BRIDGING_HEADER = "Gotcha-Bridging-Header.h";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
|
|||||||
134
ios/Sources/AppContext.swift
Normal file
134
ios/Sources/AppContext.swift
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AppContext {
|
||||||
|
let core = GotchaCore()
|
||||||
|
private let window: UIWindow
|
||||||
|
private(set) var tabs = UITabBarController()
|
||||||
|
private(set) var navigationControllers: [UINavigationController] = []
|
||||||
|
|
||||||
|
init(window: UIWindow) {
|
||||||
|
self.window = window
|
||||||
|
applyAppearance()
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeRootController() -> UIViewController {
|
||||||
|
let roots: [UIViewController] = [
|
||||||
|
HomeViewController(context: self),
|
||||||
|
repositoryRoot(mode: .issues),
|
||||||
|
repositoryRoot(mode: .commits),
|
||||||
|
PullsViewController(context: self),
|
||||||
|
repositoryRoot(mode: .milestones),
|
||||||
|
]
|
||||||
|
let items = [
|
||||||
|
("Home", "house", "house.fill"),
|
||||||
|
("Issues", "exclamationmark.circle", "exclamationmark.circle.fill"),
|
||||||
|
("Repos", "books.vertical", "books.vertical.fill"),
|
||||||
|
("PRs", "arrow.triangle.pull", "arrow.triangle.pull"),
|
||||||
|
("Milestones", "flag", "flag.fill"),
|
||||||
|
]
|
||||||
|
navigationControllers = zip(roots, items).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
|
||||||
|
}
|
||||||
|
tabs.viewControllers = navigationControllers
|
||||||
|
return tabs
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
let replacements: [(Int, UIViewController)] = [
|
||||||
|
(0, HomeViewController(context: self)),
|
||||||
|
(1, RepositoriesViewController(context: self, mode: .issues)),
|
||||||
|
(2, RepositoriesViewController(context: self, mode: .commits)),
|
||||||
|
(3, PullsViewController(context: self)),
|
||||||
|
(4, RepositoriesViewController(context: self, mode: .milestones)),
|
||||||
|
]
|
||||||
|
for (index, root) in replacements {
|
||||||
|
navigationControllers[index].setViewControllers([root], animated: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
switch activity.target {
|
||||||
|
case "repository":
|
||||||
|
tabs.selectedIndex = 1
|
||||||
|
navigationControllers[1].pushViewController(
|
||||||
|
IssuesViewController(
|
||||||
|
context: self,
|
||||||
|
owner: activity.owner,
|
||||||
|
repository: activity.repository
|
||||||
|
),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
case "issue":
|
||||||
|
tabs.selectedIndex = 1
|
||||||
|
navigationControllers[1].pushViewController(
|
||||||
|
IssueViewController(
|
||||||
|
context: self,
|
||||||
|
owner: activity.owner,
|
||||||
|
repository: activity.repository,
|
||||||
|
number: activity.number
|
||||||
|
),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
case "pull":
|
||||||
|
tabs.selectedIndex = 3
|
||||||
|
navigationControllers[3].pushViewController(
|
||||||
|
PullViewController(
|
||||||
|
context: self,
|
||||||
|
owner: activity.owner,
|
||||||
|
repository: activity.repository,
|
||||||
|
number: activity.number
|
||||||
|
),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
case "commit":
|
||||||
|
tabs.selectedIndex = 2
|
||||||
|
navigationControllers[2].pushViewController(
|
||||||
|
FilesViewController(
|
||||||
|
context: self,
|
||||||
|
owner: activity.owner,
|
||||||
|
repository: activity.repository,
|
||||||
|
sha: activity.sha
|
||||||
|
),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func symbol(_ name: String) -> UIImage? {
|
||||||
|
UIImage(systemName: name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func repositoryRoot(mode: RepositoryMode) -> UIViewController {
|
||||||
|
if core.activeServerIndex() == nil {
|
||||||
|
return ServersViewController(context: self, destination: mode)
|
||||||
|
}
|
||||||
|
return RepositoriesViewController(context: self, mode: mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
19
ios/Sources/AppDelegate.swift
Normal file
19
ios/Sources/AppDelegate.swift
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
@main
|
||||||
|
final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
|
var window: UIWindow?
|
||||||
|
|
||||||
|
func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||||
|
) -> Bool {
|
||||||
|
let window = UIWindow(frame: UIScreen.main.bounds)
|
||||||
|
let context = AppContext(window: window)
|
||||||
|
window.rootViewController = context.makeRootController()
|
||||||
|
window.makeKeyAndVisible()
|
||||||
|
self.window = window
|
||||||
|
context.showStartupErrorIfNeeded()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
654
ios/Sources/ContentScreens.swift
Normal file
654
ios/Sources/ContentScreens.swift
Normal file
@@ -0,0 +1,654 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class IssuesViewController: RefreshingTableViewController {
|
||||||
|
private let context: AppContext
|
||||||
|
private let owner: String
|
||||||
|
private let repository: String
|
||||||
|
private var rows: [IssueRow] = []
|
||||||
|
|
||||||
|
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(IssueCell.self, forCellReuseIdentifier: "issue")
|
||||||
|
updateFilterMenu()
|
||||||
|
loadContent(refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadContent(refreshing: Bool) {
|
||||||
|
beginLoading(refreshing: refreshing)
|
||||||
|
loadingTask?.cancel()
|
||||||
|
loadingTask = Task {
|
||||||
|
do {
|
||||||
|
rows = try await context.core.issues(owner: owner, repository: repository)
|
||||||
|
tableView.reloadData()
|
||||||
|
let status = context.core.settings().issueStatus
|
||||||
|
tableView.backgroundView = rows.isEmpty
|
||||||
|
? EmptyBackgroundView(
|
||||||
|
title: "No \(status) issues",
|
||||||
|
detail: "No issues match the selected status."
|
||||||
|
)
|
||||||
|
: nil
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled { show(error: error) }
|
||||||
|
}
|
||||||
|
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: "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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateFilterMenu() {
|
||||||
|
let current = context.core.settings().issueStatus
|
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
|
image: context.symbol("line.3.horizontal.decrease.circle"),
|
||||||
|
menu: UIMenu(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.loadContent(refreshing: false)
|
||||||
|
} catch {
|
||||||
|
self.show(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class IssueCell: UITableViewCell {
|
||||||
|
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
|
||||||
|
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: [titleLabel, 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) {
|
||||||
|
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) {
|
||||||
|
let view = UILabel()
|
||||||
|
view.text = " \(label.name) "
|
||||||
|
view.font = .preferredFont(forTextStyle: .caption2)
|
||||||
|
view.textColor = label.light ? .black : .white
|
||||||
|
view.backgroundColor = UIColor(hex: label.color)
|
||||||
|
view.layer.cornerRadius = 9
|
||||||
|
view.clipsToBounds = true
|
||||||
|
labels.addArrangedSubview(view)
|
||||||
|
}
|
||||||
|
labels.addArrangedSubview(UIView())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class MilestonesViewController: RefreshingTableViewController {
|
||||||
|
private let context: AppContext
|
||||||
|
private let owner: String
|
||||||
|
private let repository: String
|
||||||
|
private var rows: [MilestoneRow] = []
|
||||||
|
|
||||||
|
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(MilestoneCell.self, forCellReuseIdentifier: "milestone")
|
||||||
|
tableView.rowHeight = UITableView.automaticDimension
|
||||||
|
tableView.estimatedRowHeight = 118
|
||||||
|
loadContent(refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadContent(refreshing: Bool) {
|
||||||
|
beginLoading(refreshing: refreshing)
|
||||||
|
loadingTask?.cancel()
|
||||||
|
loadingTask = Task {
|
||||||
|
do {
|
||||||
|
rows = try await context.core.milestones(owner: owner, repository: repository)
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class MilestoneViewController: RefreshingTableViewController {
|
||||||
|
private let context: AppContext
|
||||||
|
private let owner: String
|
||||||
|
private let repository: String
|
||||||
|
private let id: Int64
|
||||||
|
private var page: MilestonePage?
|
||||||
|
|
||||||
|
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") }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
tableView.register(MilestoneCell.self, forCellReuseIdentifier: "milestone")
|
||||||
|
tableView.register(IssueCell.self, forCellReuseIdentifier: "issue")
|
||||||
|
tableView.rowHeight = UITableView.automaticDimension
|
||||||
|
tableView.estimatedRowHeight = 118
|
||||||
|
loadContent(refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadContent(refreshing: Bool) {
|
||||||
|
beginLoading(refreshing: refreshing)
|
||||||
|
loadingTask?.cancel()
|
||||||
|
loadingTask = Task {
|
||||||
|
do {
|
||||||
|
page = try await context.core.milestone(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
id: id
|
||||||
|
)
|
||||||
|
title = page?.milestone.title
|
||||||
|
tableView.reloadData()
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled { show(error: error) }
|
||||||
|
}
|
||||||
|
endLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func numberOfSections(in tableView: UITableView) -> Int { page == nil ? 0 : 2 }
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
section == 0 ? 1 : page?.issues.count ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||||
|
section == 1 ? "Issues" : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
|
||||||
|
section == 1 && page?.issues.isEmpty == true ? "No issues are assigned to this milestone." : 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
|
||||||
|
}
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: "issue", for: indexPath) as! IssueCell
|
||||||
|
cell.configure(page.issues[indexPath.row])
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
guard indexPath.section == 1, let issue = page?.issues[indexPath.row] else { return }
|
||||||
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
|
navigationController?.pushViewController(
|
||||||
|
IssueViewController(
|
||||||
|
context: context,
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
number: issue.number
|
||||||
|
),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class MilestoneCell: UITableViewCell {
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let descriptionLabel = UILabel()
|
||||||
|
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
|
||||||
|
descriptionLabel.font = .preferredFont(forTextStyle: .subheadline)
|
||||||
|
descriptionLabel.textColor = .secondaryLabel
|
||||||
|
descriptionLabel.numberOfLines = 2
|
||||||
|
metaLabel.font = .preferredFont(forTextStyle: .caption1)
|
||||||
|
metaLabel.textColor = .tertiaryLabel
|
||||||
|
metaLabel.numberOfLines = 2
|
||||||
|
progress.progressTintColor = .systemGreen
|
||||||
|
let stack = UIStackView(arrangedSubviews: [titleLabel, descriptionLabel, progress, 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
|
||||||
|
titleLabel.text = row.title
|
||||||
|
descriptionLabel.text = row.description
|
||||||
|
metaLabel.text = row.meta
|
||||||
|
let total = row.openIssues + row.closedIssues
|
||||||
|
progress.progress = total == 0 ? 0 : Float(row.closedIssues) / Float(total)
|
||||||
|
progress.trackTintColor = total == 0 ? .systemGray5 : .systemOrange
|
||||||
|
progress.accessibilityLabel = "Milestone progress"
|
||||||
|
progress.accessibilityValue = "\(row.closedIssues) closed, \(row.openIssues) open"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class PullsViewController: RefreshingTableViewController {
|
||||||
|
private let context: AppContext
|
||||||
|
private var rows: [PullRow] = []
|
||||||
|
|
||||||
|
init(context: AppContext) {
|
||||||
|
self.context = context
|
||||||
|
super.init()
|
||||||
|
title = "Pull Requests"
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
updateFilterMenu()
|
||||||
|
loadContent(refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
beginLoading(refreshing: refreshing)
|
||||||
|
loadingTask?.cancel()
|
||||||
|
loadingTask = Task {
|
||||||
|
do {
|
||||||
|
rows = try await context.core.pulls()
|
||||||
|
tableView.reloadData()
|
||||||
|
let status = context.core.settings().pullStatus
|
||||||
|
tableView.backgroundView = rows.isEmpty
|
||||||
|
? EmptyBackgroundView(
|
||||||
|
title: "No \(status) pull requests",
|
||||||
|
detail: "No pull requests match the selected status."
|
||||||
|
)
|
||||||
|
: nil
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled { show(error: error) }
|
||||||
|
}
|
||||||
|
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: "pull")
|
||||||
|
?? UITableViewCell(style: .subtitle, reuseIdentifier: "pull")
|
||||||
|
let row = rows[indexPath.row]
|
||||||
|
configureTextCell(
|
||||||
|
cell,
|
||||||
|
title: "\(row.repository) #\(row.number)\n\(row.title)",
|
||||||
|
detail: "\(row.summary)\n\(row.meta)"
|
||||||
|
)
|
||||||
|
cell.accessoryType = .disclosureIndicator
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||||
|
116
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
|
image: context.symbol("line.3.horizontal.decrease.circle"),
|
||||||
|
menu: UIMenu(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.loadContent(refreshing: false)
|
||||||
|
} catch {
|
||||||
|
self.show(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class CommitsViewController: RefreshingTableViewController {
|
||||||
|
private let context: AppContext
|
||||||
|
private let owner: String
|
||||||
|
private let repository: String
|
||||||
|
private var page: CommitPage?
|
||||||
|
private var branch: String?
|
||||||
|
|
||||||
|
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")
|
||||||
|
updateBranchMenu()
|
||||||
|
loadContent(refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadContent(refreshing: Bool) {
|
||||||
|
beginLoading(refreshing: refreshing)
|
||||||
|
loadingTask?.cancel()
|
||||||
|
loadingTask = Task {
|
||||||
|
do {
|
||||||
|
page = try await context.core.commits(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
branch: branch
|
||||||
|
)
|
||||||
|
updateBranchMenu()
|
||||||
|
tableView.reloadData()
|
||||||
|
tableView.backgroundView = page?.commits.isEmpty == true
|
||||||
|
? EmptyBackgroundView(title: "No commits", detail: "This repository has no commit history.")
|
||||||
|
: nil
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled { show(error: error) }
|
||||||
|
}
|
||||||
|
endLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 row = page?.commits[indexPath.row] else { return }
|
||||||
|
navigationController?.pushViewController(
|
||||||
|
FilesViewController(
|
||||||
|
context: context,
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
sha: row.sha
|
||||||
|
),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 : min(72, CGFloat(laneCount) * 10 + 8)
|
||||||
|
content.text = row.title
|
||||||
|
content.secondaryText = row.refs.isEmpty
|
||||||
|
? "\(row.meta)\n\(row.sha.prefix(8))"
|
||||||
|
: "\(row.refs)\n\(row.meta) · \(row.sha.prefix(8))"
|
||||||
|
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 spacing: CGFloat = 10
|
||||||
|
let centerY = bounds.midY
|
||||||
|
for lane in 0..<laneCount {
|
||||||
|
let x = 10 + CGFloat(lane) * spacing
|
||||||
|
let color = laneColor(lane)
|
||||||
|
context.setStrokeColor(color.cgColor)
|
||||||
|
context.setLineWidth(3)
|
||||||
|
if row.topLanes.contains(lane) {
|
||||||
|
context.move(to: CGPoint(x: x, y: 0))
|
||||||
|
context.addLine(to: CGPoint(x: x, y: centerY))
|
||||||
|
context.strokePath()
|
||||||
|
}
|
||||||
|
if row.bottomLanes.contains(lane) {
|
||||||
|
context.move(to: CGPoint(x: x, y: centerY))
|
||||||
|
context.addLine(to: CGPoint(x: x, y: bounds.height))
|
||||||
|
context.strokePath()
|
||||||
|
}
|
||||||
|
if row.connections.contains(lane), lane > 0 {
|
||||||
|
context.move(to: CGPoint(x: x - spacing, y: centerY))
|
||||||
|
context.addLine(to: CGPoint(x: x, y: centerY))
|
||||||
|
context.strokePath()
|
||||||
|
}
|
||||||
|
if row.nodeLane == lane {
|
||||||
|
context.setFillColor(color.cgColor)
|
||||||
|
context.fillEllipse(in: CGRect(x: x - 5, y: centerY - 5, width: 10, height: 10))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
453
ios/Sources/DetailScreens.swift
Normal file
453
ios/Sources/DetailScreens.swift
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class MarkdownPageViewController: UIViewController {
|
||||||
|
let context: AppContext
|
||||||
|
let scrollView = UIScrollView()
|
||||||
|
let stack = UIStackView()
|
||||||
|
private let spinner = UIActivityIndicatorView(style: .medium)
|
||||||
|
var loadingTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
init(context: AppContext) {
|
||||||
|
self.context = context
|
||||||
|
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 = .systemGroupedBackground
|
||||||
|
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
scrollView.alwaysBounceVertical = true
|
||||||
|
scrollView.refreshControl = UIRefreshControl()
|
||||||
|
scrollView.refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged)
|
||||||
|
view.addSubview(scrollView)
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = 12
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
scrollView.addSubview(stack)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
scrollView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 18),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -18),
|
||||||
|
stack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 18),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -18),
|
||||||
|
stack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -36),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit { loadingTask?.cancel() }
|
||||||
|
|
||||||
|
func loadContent(refreshing: Bool) {}
|
||||||
|
|
||||||
|
func beginLoading(refreshing: Bool) {
|
||||||
|
if !refreshing { beginNavigationLoading(spinner) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func endLoading() {
|
||||||
|
endNavigationLoading(spinner)
|
||||||
|
scrollView.refreshControl?.endRefreshing()
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceContent(_ views: [UIView]) {
|
||||||
|
stack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||||
|
views.forEach(stack.addArrangedSubview)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func refreshRequested() {
|
||||||
|
loadContent(refreshing: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class IssueViewController: MarkdownPageViewController {
|
||||||
|
private let owner: String
|
||||||
|
private let repository: String
|
||||||
|
private let number: Int64
|
||||||
|
|
||||||
|
init(context: AppContext, owner: String, repository: String, number: Int64) {
|
||||||
|
self.owner = owner
|
||||||
|
self.repository = repository
|
||||||
|
self.number = number
|
||||||
|
super.init(context: context)
|
||||||
|
title = "Issue"
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 {
|
||||||
|
do {
|
||||||
|
let page = try await context.core.issue(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
number: number
|
||||||
|
)
|
||||||
|
replaceContent(detailViews(
|
||||||
|
title: page.title,
|
||||||
|
meta: page.meta,
|
||||||
|
body: page.body,
|
||||||
|
comments: page.comments,
|
||||||
|
milestone: page.milestone
|
||||||
|
))
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled { show(error: error) }
|
||||||
|
}
|
||||||
|
endLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class PullViewController: MarkdownPageViewController {
|
||||||
|
private let owner: String
|
||||||
|
private let repository: String
|
||||||
|
private let number: Int64
|
||||||
|
|
||||||
|
init(context: AppContext, owner: String, repository: String, number: Int64) {
|
||||||
|
self.owner = owner
|
||||||
|
self.repository = repository
|
||||||
|
self.number = number
|
||||||
|
super.init(context: context)
|
||||||
|
title = "Pull Request"
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 {
|
||||||
|
do {
|
||||||
|
let page = try await context.core.pull(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
number: number
|
||||||
|
)
|
||||||
|
var views = detailViews(
|
||||||
|
title: page.title,
|
||||||
|
meta: page.meta,
|
||||||
|
body: page.body,
|
||||||
|
comments: []
|
||||||
|
)
|
||||||
|
if !page.files.isEmpty {
|
||||||
|
views.append(sectionLabel(page.filesRef))
|
||||||
|
views.append(contentsOf: page.files.map { file in
|
||||||
|
detailButton(title: file.path, detail: file.status) { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.navigationController?.pushViewController(
|
||||||
|
DiffViewController(
|
||||||
|
context: self.context,
|
||||||
|
source: .pull(
|
||||||
|
owner: self.owner,
|
||||||
|
repository: self.repository,
|
||||||
|
number: self.number,
|
||||||
|
path: file.path
|
||||||
|
)
|
||||||
|
),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
views.append(contentsOf: commentViews(page.comments))
|
||||||
|
replaceContent(views)
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled { show(error: error) }
|
||||||
|
}
|
||||||
|
endLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class FilesViewController: RefreshingTableViewController {
|
||||||
|
private let context: AppContext
|
||||||
|
private let owner: String
|
||||||
|
private let repository: String
|
||||||
|
private let sha: String
|
||||||
|
private var rows: [FileRow] = []
|
||||||
|
|
||||||
|
init(context: AppContext, owner: String, repository: String, sha: String) {
|
||||||
|
self.context = context
|
||||||
|
self.owner = owner
|
||||||
|
self.repository = repository
|
||||||
|
self.sha = sha
|
||||||
|
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 loadContent(refreshing: Bool) {
|
||||||
|
beginLoading(refreshing: refreshing)
|
||||||
|
loadingTask?.cancel()
|
||||||
|
loadingTask = Task {
|
||||||
|
do {
|
||||||
|
rows = try await context.core.commitFiles(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
sha: sha
|
||||||
|
)
|
||||||
|
tableView.reloadData()
|
||||||
|
tableView.backgroundView = rows.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 {
|
||||||
|
rows.count
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
cellForRowAt indexPath: IndexPath
|
||||||
|
) -> UITableViewCell {
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: "file")
|
||||||
|
?? UITableViewCell(style: .subtitle, reuseIdentifier: "file")
|
||||||
|
let row = rows[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)
|
||||||
|
let row = rows[indexPath.row]
|
||||||
|
navigationController?.pushViewController(
|
||||||
|
DiffViewController(
|
||||||
|
context: context,
|
||||||
|
source: .commit(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
sha: sha,
|
||||||
|
path: row.path
|
||||||
|
)
|
||||||
|
),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 textView = UITextView()
|
||||||
|
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
|
||||||
|
textView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
textView.isEditable = false
|
||||||
|
textView.isSelectable = true
|
||||||
|
textView.alwaysBounceVertical = true
|
||||||
|
textView.alwaysBounceHorizontal = true
|
||||||
|
textView.showsHorizontalScrollIndicator = true
|
||||||
|
textView.textContainer.widthTracksTextView = false
|
||||||
|
textView.textContainer.lineFragmentPadding = 8
|
||||||
|
textView.refreshControl = UIRefreshControl()
|
||||||
|
textView.refreshControl?.addTarget(self, action: #selector(reload), for: .valueChanged)
|
||||||
|
view.addSubview(textView)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
textView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
textView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
textView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
textView.bottomAnchor.constraint(equalTo: view.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
|
||||||
|
textView.attributedText = diffText(page)
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled { show(error: error) }
|
||||||
|
}
|
||||||
|
endNavigationLoading(spinner)
|
||||||
|
textView.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)
|
||||||
|
default: color = .clear
|
||||||
|
}
|
||||||
|
output.append(NSAttributedString(string: text, attributes: [
|
||||||
|
.font: font,
|
||||||
|
.foregroundColor: UIColor.label,
|
||||||
|
.backgroundColor: color,
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func detailViews(
|
||||||
|
title: String,
|
||||||
|
meta: String,
|
||||||
|
body: String,
|
||||||
|
comments: [CommentRow],
|
||||||
|
milestone: String = ""
|
||||||
|
) -> [UIView] {
|
||||||
|
let titleLabel = UILabel()
|
||||||
|
titleLabel.text = title
|
||||||
|
titleLabel.font = .preferredFont(forTextStyle: .title1)
|
||||||
|
titleLabel.numberOfLines = 0
|
||||||
|
let metaLabel = UILabel()
|
||||||
|
metaLabel.text = meta
|
||||||
|
metaLabel.font = .preferredFont(forTextStyle: .subheadline)
|
||||||
|
metaLabel.textColor = .secondaryLabel
|
||||||
|
metaLabel.numberOfLines = 0
|
||||||
|
let bodyView = markdownView(body)
|
||||||
|
var views: [UIView] = [titleLabel, metaLabel]
|
||||||
|
if !milestone.isEmpty {
|
||||||
|
let milestoneLabel = UILabel()
|
||||||
|
milestoneLabel.attributedText = symbolText(
|
||||||
|
"flag.fill",
|
||||||
|
text: milestone,
|
||||||
|
font: .preferredFont(forTextStyle: .subheadline),
|
||||||
|
color: .secondaryLabel
|
||||||
|
)
|
||||||
|
milestoneLabel.accessibilityLabel = "Milestone \(milestone)"
|
||||||
|
views.append(milestoneLabel)
|
||||||
|
}
|
||||||
|
return views + [separator(), bodyView] + commentViews(comments)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func commentViews(_ comments: [CommentRow]) -> [UIView] {
|
||||||
|
guard !comments.isEmpty else { return [] }
|
||||||
|
var views: [UIView] = [sectionLabel("Comments")]
|
||||||
|
for comment in comments {
|
||||||
|
let author = UILabel()
|
||||||
|
author.text = comment.author
|
||||||
|
author.font = .preferredFont(forTextStyle: .headline)
|
||||||
|
let date = UILabel()
|
||||||
|
date.text = comment.meta
|
||||||
|
date.font = .preferredFont(forTextStyle: .caption1)
|
||||||
|
date.textColor = .tertiaryLabel
|
||||||
|
let stack = UIStackView(arrangedSubviews: [author, markdownView(comment.body), date, separator()])
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = 6
|
||||||
|
views.append(stack)
|
||||||
|
}
|
||||||
|
return views
|
||||||
|
}
|
||||||
|
|
||||||
|
private func markdownView(_ source: String) -> UITextView {
|
||||||
|
let view = UITextView()
|
||||||
|
view.attributedText = markdown(source)
|
||||||
|
view.isEditable = false
|
||||||
|
view.isSelectable = true
|
||||||
|
view.isScrollEnabled = false
|
||||||
|
view.backgroundColor = .clear
|
||||||
|
view.textContainerInset = .zero
|
||||||
|
view.textContainer.lineFragmentPadding = 0
|
||||||
|
view.adjustsFontForContentSizeCategory = true
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sectionLabel(_ text: String) -> UILabel {
|
||||||
|
let label = UILabel()
|
||||||
|
label.text = text
|
||||||
|
label.font = .preferredFont(forTextStyle: .headline)
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
|
private func separator() -> UIView {
|
||||||
|
let line = UIView()
|
||||||
|
line.backgroundColor = .separator
|
||||||
|
line.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale).isActive = true
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
private func detailButton(title: String, detail: String, action: @escaping () -> Void) -> UIButton {
|
||||||
|
var configuration = UIButton.Configuration.plain()
|
||||||
|
configuration.title = title
|
||||||
|
configuration.subtitle = detail
|
||||||
|
configuration.image = UIImage(systemName: "chevron.right")
|
||||||
|
configuration.imagePlacement = .trailing
|
||||||
|
configuration.imagePadding = 8
|
||||||
|
configuration.contentInsets = .init(top: 10, leading: 0, bottom: 10, trailing: 0)
|
||||||
|
let button = UIButton(configuration: configuration, primaryAction: UIAction { _ in action() })
|
||||||
|
button.contentHorizontalAlignment = .fill
|
||||||
|
return button
|
||||||
|
}
|
||||||
454
ios/Sources/ListScreens.swift
Normal file
454
ios/Sources/ListScreens.swift
Normal file
@@ -0,0 +1,454 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
enum RepositoryMode {
|
||||||
|
case issues
|
||||||
|
case commits
|
||||||
|
case milestones
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ServersViewController: UITableViewController {
|
||||||
|
private let context: AppContext
|
||||||
|
private let destination: RepositoryMode
|
||||||
|
private var servers: [ServerRow] = []
|
||||||
|
|
||||||
|
init(context: AppContext, destination: RepositoryMode) {
|
||||||
|
self.context = context
|
||||||
|
self.destination = destination
|
||||||
|
super.init(style: .plain)
|
||||||
|
title = "Servers"
|
||||||
|
tableView.backgroundColor = .systemGroupedBackground
|
||||||
|
tableView.separatorInset = .zero
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
servers = context.core.servers()
|
||||||
|
tableView.reloadData()
|
||||||
|
tableView.backgroundView = servers.isEmpty
|
||||||
|
? EmptyBackgroundView(title: "No servers", detail: "Add a Gitea server to get started.")
|
||||||
|
: nil
|
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
|
systemItem: .add,
|
||||||
|
primaryAction: UIAction { [weak self] _ in self?.showAddServer() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
navigationController?.setViewControllers([
|
||||||
|
RepositoriesViewController(context: context, mode: destination),
|
||||||
|
], animated: true)
|
||||||
|
} catch {
|
||||||
|
show(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showAddServer() {
|
||||||
|
let controller = AddServerViewController(context: context) { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.servers = self.context.core.servers()
|
||||||
|
self.tableView.reloadData()
|
||||||
|
}
|
||||||
|
present(UINavigationController(rootViewController: controller), animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AddServerViewController: UITableViewController, UITextFieldDelegate {
|
||||||
|
private let context: AppContext
|
||||||
|
private let completion: () -> Void
|
||||||
|
private let nameField = UITextField()
|
||||||
|
private let urlField = UITextField()
|
||||||
|
private let tokenField = UITextField()
|
||||||
|
private var saveButton: UIBarButtonItem!
|
||||||
|
|
||||||
|
init(context: AppContext, completion: @escaping () -> Void) {
|
||||||
|
self.context = context
|
||||||
|
self.completion = completion
|
||||||
|
super.init(style: .insetGrouped)
|
||||||
|
title = "Add 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: "Add",
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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? {
|
||||||
|
["Name", "Server URL", "Access token"][section]
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
cellForRowAt indexPath: IndexPath
|
||||||
|
) -> UITableViewCell {
|
||||||
|
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
|
||||||
|
let field = [nameField, urlField, tokenField][indexPath.section]
|
||||||
|
field.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
cell.contentView.addSubview(field)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
field.leadingAnchor.constraint(equalTo: cell.contentView.leadingAnchor, constant: 16),
|
||||||
|
field.trailingAnchor.constraint(equalTo: cell.contentView.trailingAnchor, constant: -16),
|
||||||
|
field.topAnchor.constraint(equalTo: cell.contentView.topAnchor),
|
||||||
|
field.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 {
|
||||||
|
let index = try await context.core.addServer(
|
||||||
|
name: nameField.text ?? "",
|
||||||
|
url: urlField.text ?? "",
|
||||||
|
token: tokenField.text ?? ""
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class RepositoriesViewController: RefreshingTableViewController {
|
||||||
|
private let context: AppContext
|
||||||
|
private let mode: RepositoryMode
|
||||||
|
private var rows: [RepositoryRow] = []
|
||||||
|
|
||||||
|
init(context: AppContext, mode: RepositoryMode) {
|
||||||
|
self.context = context
|
||||||
|
self.mode = mode
|
||||||
|
super.init()
|
||||||
|
title = context.core.activeServerName() ?? "Repositories"
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
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, destination: self.mode),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
loadContent(refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadContent(refreshing: Bool) {
|
||||||
|
beginLoading(refreshing: refreshing)
|
||||||
|
loadingTask?.cancel()
|
||||||
|
loadingTask = Task {
|
||||||
|
do {
|
||||||
|
rows = try await context.core.repositories()
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
navigationController?.pushViewController(destination, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toggleFavorite(_ row: RepositoryRow) {
|
||||||
|
do {
|
||||||
|
rows = try context.core.toggleFavorite(owner: row.owner, repository: row.name)
|
||||||
|
tableView.reloadData()
|
||||||
|
} catch {
|
||||||
|
show(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class HomeViewController: RefreshingTableViewController {
|
||||||
|
private let context: AppContext
|
||||||
|
private var page: HomePage?
|
||||||
|
|
||||||
|
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()
|
||||||
|
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)
|
||||||
|
guard page == nil else { return }
|
||||||
|
loadContent(refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
beginLoading(refreshing: refreshing)
|
||||||
|
loadingTask?.cancel()
|
||||||
|
loadingTask = Task {
|
||||||
|
do {
|
||||||
|
page = try await context.core.home()
|
||||||
|
title = page?.serverName
|
||||||
|
tableView.tableHeaderView = page.map { HeatmapView(page: $0) }
|
||||||
|
tableView.reloadData()
|
||||||
|
tableView.backgroundView = nil
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled { show(error: error) }
|
||||||
|
}
|
||||||
|
endLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
page?.activities.count ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
cellForRowAt indexPath: IndexPath
|
||||||
|
) -> UITableViewCell {
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: "activity")
|
||||||
|
?? UITableViewCell(style: .subtitle, reuseIdentifier: "activity")
|
||||||
|
guard let row = page?.activities[indexPath.row] else { return cell }
|
||||||
|
configureTextCell(
|
||||||
|
cell,
|
||||||
|
title: row.title,
|
||||||
|
detail: "\(row.detail)\n\(row.meta)",
|
||||||
|
image: context.symbol(symbolName(for: row.icon))
|
||||||
|
)
|
||||||
|
cell.accessoryType = row.target.isEmpty ? .none : .disclosureIndicator
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||||
|
92
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
|
if let row = page?.activities[indexPath.row] { context.route(row) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func symbolName(for icon: String) -> String {
|
||||||
|
switch icon {
|
||||||
|
case let value where value.contains("pull"): return "arrow.triangle.pull"
|
||||||
|
case let value where value.contains("issue"): return "exclamationmark.circle"
|
||||||
|
case let value where value.contains("branch"): return "arrow.triangle.branch"
|
||||||
|
case let value where value.contains("tag"): return "tag"
|
||||||
|
case "push": return "arrow.up.circle"
|
||||||
|
case "release": return "shippingbox"
|
||||||
|
default: return "books.vertical"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class HeatmapView: UIView {
|
||||||
|
private let cells: [HeatCell]
|
||||||
|
|
||||||
|
init(page: HomePage) {
|
||||||
|
cells = page.heatCells
|
||||||
|
super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 132))
|
||||||
|
backgroundColor = .systemBackground
|
||||||
|
let title = UILabel(frame: CGRect(x: 16, y: 12, width: 300, height: 22))
|
||||||
|
title.text = "Activity · last 12 months"
|
||||||
|
title.font = .preferredFont(forTextStyle: .subheadline)
|
||||||
|
title.textColor = .secondaryLabel
|
||||||
|
addSubview(title)
|
||||||
|
let total = UILabel(frame: CGRect(x: 16, y: 104, width: 300, height: 18))
|
||||||
|
total.text = "\(page.contributionCount) contributions"
|
||||||
|
total.font = .preferredFont(forTextStyle: .caption1)
|
||||||
|
total.textColor = .tertiaryLabel
|
||||||
|
addSubview(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
override func draw(_ rect: CGRect) {
|
||||||
|
let width = max(4, min(6, (bounds.width - 32) / 55))
|
||||||
|
let gap = width + 1
|
||||||
|
for cell in cells {
|
||||||
|
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: 16 + CGFloat(cell.week) * gap,
|
||||||
|
y: 43 + CGFloat(cell.day) * gap,
|
||||||
|
width: width,
|
||||||
|
height: width
|
||||||
|
),
|
||||||
|
cornerRadius: 1
|
||||||
|
).fill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
59
ios/Sources/SettingsViewController.swift
Normal file
59
ios/Sources/SettingsViewController.swift
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class SettingsViewController: UITableViewController {
|
||||||
|
private let context: AppContext
|
||||||
|
private let appearanceControl = UISegmentedControl(items: ["Auto", "Light", "Dark"])
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func numberOfSections(in tableView: UITableView) -> Int { 1 }
|
||||||
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 1 }
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||||
|
"Appearance"
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
|
||||||
|
"Follow iOS automatically or choose a fixed appearance."
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
cellForRowAt indexPath: IndexPath
|
||||||
|
) -> UITableViewCell {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func appearanceChanged() {
|
||||||
|
do {
|
||||||
|
try context.core.setAppearance(index: UInt32(appearanceControl.selectedSegmentIndex))
|
||||||
|
context.applyAppearance()
|
||||||
|
} catch {
|
||||||
|
show(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
140
ios/Sources/Support.swift
Normal file
140
ios/Sources/Support.swift
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
extension UIViewController {
|
||||||
|
func show(error: Error) {
|
||||||
|
present(errorAlert(error.localizedDescription), 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>?
|
||||||
|
|
||||||
|
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 beginLoading(refreshing: Bool) {
|
||||||
|
if !refreshing { beginNavigationLoading(spinner) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func endLoading() {
|
||||||
|
endNavigationLoading(spinner)
|
||||||
|
refreshControl?.endRefreshing()
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 markdown(_ source: String, textStyle: UIFont.TextStyle = .body) -> NSAttributedString {
|
||||||
|
let attributed = (try? AttributedString(
|
||||||
|
markdown: source,
|
||||||
|
options: .init(interpretedSyntax: .full)
|
||||||
|
)) ?? AttributedString(source)
|
||||||
|
var mutable = attributed
|
||||||
|
mutable.font = .preferredFont(forTextStyle: textStyle)
|
||||||
|
mutable.foregroundColor = UIColor.label
|
||||||
|
return NSAttributedString(mutable)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PAT
|
|||||||
export RUSTC="$(rustup which --toolchain stable rustc)"
|
export RUSTC="$(rustup which --toolchain stable rustc)"
|
||||||
export RUSTDOC="$(rustup which --toolchain stable rustdoc)"
|
export RUSTDOC="$(rustup which --toolchain stable rustdoc)"
|
||||||
export CARGO_TARGET_DIR="$DERIVED_FILE_DIR/cargo"
|
export CARGO_TARGET_DIR="$DERIVED_FILE_DIR/cargo"
|
||||||
export CARGO_PROFILE_RELEASE_DEBUG="${CARGO_PROFILE_RELEASE_DEBUG:-1}"
|
|
||||||
|
|
||||||
if [[ "$CONFIGURATION" == "Debug" ]]; then
|
if [[ "$CONFIGURATION" == "Debug" ]]; then
|
||||||
profile=debug
|
profile=debug
|
||||||
@@ -13,14 +12,7 @@ else
|
|||||||
profile=release
|
profile=release
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "${LLVM_TARGET_TRIPLE_SUFFIX:-}" == "-simulator" ]]; then
|
libraries=()
|
||||||
separator=$'\x1f'
|
|
||||||
entitlement_flags="-Clink-arg=-Wl,-sectcreate,__TEXT,__entitlements,$LD_ENTITLEMENTS_SECTION"
|
|
||||||
entitlement_flags+="$separator-Clink-arg=-Wl,-sectcreate,__TEXT,__ents_der,$LD_ENTITLEMENTS_SECTION_DER"
|
|
||||||
export CARGO_ENCODED_RUSTFLAGS="${CARGO_ENCODED_RUSTFLAGS:+$CARGO_ENCODED_RUSTFLAGS$separator}$entitlement_flags"
|
|
||||||
fi
|
|
||||||
|
|
||||||
executables=()
|
|
||||||
for arch in $ARCHS; do
|
for arch in $ARCHS; do
|
||||||
if [[ "$arch" == "arm64" && "${LLVM_TARGET_TRIPLE_SUFFIX:-}" == "-simulator" ]]; then
|
if [[ "$arch" == "arm64" && "${LLVM_TARGET_TRIPLE_SUFFIX:-}" == "-simulator" ]]; then
|
||||||
target=aarch64-apple-ios-sim
|
target=aarch64-apple-ios-sim
|
||||||
@@ -34,15 +26,12 @@ for arch in $ARCHS; do
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$profile" == "debug" ]]; then
|
if [[ "$profile" == "debug" ]]; then
|
||||||
rustup run stable cargo build --locked --target "$target" --bin "$1"
|
rustup run stable cargo build --locked --target "$target" --package gotcha-app --lib
|
||||||
else
|
else
|
||||||
rustup run stable cargo build --locked --release --target "$target" --bin "$1"
|
rustup run stable cargo build --locked --release --target "$target" --package gotcha-app --lib
|
||||||
fi
|
fi
|
||||||
executables+=("$CARGO_TARGET_DIR/$target/$profile/$1")
|
libraries+=("$CARGO_TARGET_DIR/$target/$profile/libgotcha_core.a")
|
||||||
done
|
done
|
||||||
|
|
||||||
lipo -create -output "$TARGET_BUILD_DIR/$EXECUTABLE_PATH" "${executables[@]}"
|
mkdir -p "$DERIVED_FILE_DIR/rust"
|
||||||
|
lipo -create -output "$DERIVED_FILE_DIR/rust/libgotcha_core.a" "${libraries[@]}"
|
||||||
if [[ -n "${DWARF_DSYM_FOLDER_PATH:-}" && -n "${DWARF_DSYM_FILE_NAME:-}" ]]; then
|
|
||||||
dsymutil "$TARGET_BUILD_DIR/$EXECUTABLE_PATH" -o "$DWARF_DSYM_FOLDER_PATH/$DWARF_DSYM_FILE_NAME"
|
|
||||||
fi
|
|
||||||
@@ -10,7 +10,14 @@ targets:
|
|||||||
deploymentTarget: "17.0"
|
deploymentTarget: "17.0"
|
||||||
settings:
|
settings:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: de.rfc1437.gotcha
|
PRODUCT_BUNDLE_IDENTIFIER: de.rfc1437.gotcha
|
||||||
|
DEVELOPMENT_TEAM: MU22FMRGK8
|
||||||
|
CODE_SIGN_STYLE: Automatic
|
||||||
|
TARGETED_DEVICE_FAMILY: "1"
|
||||||
CODE_SIGN_ENTITLEMENTS: Gotcha.entitlements
|
CODE_SIGN_ENTITLEMENTS: Gotcha.entitlements
|
||||||
|
SWIFT_VERSION: "5.0"
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER: Gotcha-Bridging-Header.h
|
||||||
|
LIBRARY_SEARCH_PATHS: "$(inherited) $(DERIVED_FILE_DIR)/rust"
|
||||||
|
OTHER_LDFLAGS: "$(inherited) -lgotcha_core"
|
||||||
info:
|
info:
|
||||||
path: Info.plist
|
path: Info.plist
|
||||||
properties:
|
properties:
|
||||||
@@ -20,10 +27,12 @@ targets:
|
|||||||
- UIInterfaceOrientationPortrait
|
- UIInterfaceOrientationPortrait
|
||||||
sources:
|
sources:
|
||||||
- Assets.xcassets
|
- Assets.xcassets
|
||||||
postCompileScripts:
|
- Sources
|
||||||
- name: Build Rust app
|
- Generated/gotcha_core.swift
|
||||||
|
preBuildScripts:
|
||||||
|
- name: Build Rust core
|
||||||
basedOnDependencyAnalysis: false
|
basedOnDependencyAnalysis: false
|
||||||
script: |
|
script: |
|
||||||
./build_for_ios_with_cargo.bash gotcha-app
|
./build_rust_core.bash
|
||||||
outputFiles:
|
outputFiles:
|
||||||
- $(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH)
|
- $(DERIVED_FILE_DIR)/rust/libgotcha_core.a
|
||||||
|
|||||||
Reference in New Issue
Block a user