Replace Slint UI with UIKit and add milestones

Fixes #2.\nFixes #9.
This commit is contained in:
Georg Bauer
2026-07-31 09:52:24 +02:00
parent 8f9a4dfc00
commit f201814d54
33 changed files with 7455 additions and 7878 deletions

View File

@@ -1,5 +1,11 @@
# 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
Run every gate below from the repository root before committing. Every command

4874
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,4 +14,3 @@ serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
rpassword = "7"
serde_yaml = "0.9"
slint = { version = "=1.17.1", default-features = false, features = ["std", "backend-winit", "renderer-skia", "compat-1-2"] }

View File

@@ -1,16 +1,18 @@
# Gotcha
Gotcha is a lightweight Gitea client built in Rust. Development starts with a
reusable API crate and a CLI test bed; the same crate will back the Slint iOS
application.
Gotcha is a lightweight Gitea client with a reusable Rust core, a CLI, and a
native iOS application. The iOS interface is UIKit/Swift; UniFFI exposes the
Rust application logic to Swift.
## Workspace
- `gotcha_gitea`: reusable asynchronous Gitea API client with the complete
typed Gitea 1.25 API and model surface
- `gotcha`: CLI for typed common operations and arbitrary API requests
- `gotcha-app`: Slint iOS client for servers, owned repositories, favorites,
open issues, and issue details; bundle identifier `de.rfc1437.gotcha`
- `gotcha-app`: Rust application core and UniFFI API used by iOS
- `ios`: native UIKit application for activity, repositories, favorites,
issues, milestones, pull requests, commit history, changed files, and diffs; bundle
identifier `de.rfc1437.gotcha`
The generated API modules and models cover every Gitea 1.25 operation. The
low-level request API remains available for newer instance-specific endpoints;
@@ -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
accepted as command-line arguments or environment variables.
## Direction
## Architecture
1. Exercise and type API areas in the CLI: repositories, issues and pull
requests, Actions, notifications, organizations, packages, administration.
2. Add the Slint shell and move proven read workflows into touch-first screens.
3. Add write workflows and iOS integrations such as sharing, notifications,
and background refresh.
The Rust core owns Gitea access, validation, presentation records, preferences,
favorites, and Keychain-backed credentials. UniFFI generates the Swift bridge
in `ios/Generated`. UIKit owns navigation, lists, text input, menus, scrolling,
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.
## iOS app
Server tokens are kept in the Apple Keychain; the JSON preferences contain only
server metadata and favorites. The app requires Xcode, XcodeGen, an installed
iOS Simulator runtime, and Rust's
Server tokens are kept in the Apple Keychain; JSON preferences contain only
server metadata, settings, and favorites. The app requires Xcode, XcodeGen, an
installed iOS Simulator runtime, and Rust's
`aarch64-apple-ios` and `aarch64-apple-ios-sim` targets. Generate the project
after installing those prerequisites:
@@ -76,3 +82,6 @@ open Gotcha.xcodeproj
```
For a fast host-side check, run `cargo test -p gotcha-app`.
See [TESTING.md](TESTING.md) for the complete build, simulator, gesture, and
release regression checklist.

187
TESTING.md Normal file
View 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.

View File

@@ -6,18 +6,15 @@ edition.workspace = true
license.workspace = true
rust-version.workspace = true
[[bin]]
name = "gotcha-app"
path = "src/main.rs"
[lib]
name = "gotcha_core"
crate-type = ["lib", "staticlib"]
[dependencies]
gotcha_gitea = { path = "../gitea" }
pulldown-cmark = "0.13"
serde.workspace = true
serde_json.workspace = true
security-framework = "3"
slint.workspace = true
thiserror = "2"
tokio.workspace = true
[build-dependencies]
slint-build = "=1.17.1"
uniffi = { version = "0.32", features = ["tokio"] }

View File

@@ -1,3 +0,0 @@
fn main() {
slint_build::compile("ui/app.slint").unwrap();
}

View File

@@ -7,8 +7,12 @@ use gotcha_gitea::{Client, apis, models};
use tokio::task::JoinSet;
use crate::{
domain::{HistoryCommit, HomeData, RepositoryData, Server},
view::compact_date,
diff,
domain::{
HistoryCommit, HomeData, IssueDetails, MilestoneDetails, PullDetails, RepositoryData,
Server,
},
presentation::compact_date,
};
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())
}
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> {
let client =
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)
}
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(
server: &Server,
owner: &str,

View File

@@ -3,35 +3,6 @@ use std::collections::BTreeSet;
use gotcha_gitea::models;
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)]
#[serde(rename_all = "lowercase")]
pub enum AppearanceMode {
@@ -82,8 +53,6 @@ pub struct Preferences {
#[serde(default = "open_status")]
pub pull_status: String,
#[serde(default)]
pub icon_style: IconStyle,
#[serde(default)]
pub appearance: AppearanceMode,
}
@@ -95,7 +64,6 @@ impl Default for Preferences {
last_server: None,
issue_status: open_status(),
pull_status: open_status(),
icon_style: IconStyle::default(),
appearance: AppearanceMode::default(),
}
}
@@ -126,17 +94,7 @@ pub struct HistoryCommit {
pub struct State {
pub preferences: Preferences,
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 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 {
@@ -149,6 +107,11 @@ pub struct IssueDetails {
pub comments: Vec<models::Comment>,
}
pub struct MilestoneDetails {
pub milestone: models::Milestone,
pub issues: Vec<models::Issue>,
}
pub struct PullDetails {
pub pull: models::PullRequest,
pub comments: Vec<models::Comment>,

355
crates/app/src/lib.rs Normal file
View 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

View File

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

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

View File

@@ -96,31 +96,10 @@ mod tests {
"https://gitea.example.com|octo/demo"
);
assert_eq!(Preferences::default().pull_status, "open");
assert_eq!(
Preferences::default().icon_style,
crate::domain::IconStyle::Outline
);
assert_eq!(
Preferences::default().appearance,
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!(
serde_json::from_str::<Preferences>(r#"{"appearance":"dark"}"#)
.unwrap()

View File

@@ -1 +0,0 @@
slint::include_modules!();

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View 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

View File

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

View File

@@ -7,19 +7,59 @@
objects = {
/* 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 */; };
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 */
/* 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; };
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>"; };
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 */
/* 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 = {
isa = PBXGroup;
children = (
DDAABE6B13ADC6D08D9438AF /* Assets.xcassets */,
94721140EFE7F8E7CD0F5C0B /* Generated */,
710A50F51478401FC642E6E3 /* Sources */,
F059299C038F3CAFCE470831 /* Products */,
);
sourceTree = "<group>";
@@ -39,8 +79,8 @@
isa = PBXNativeTarget;
buildConfigurationList = 7E8DF3DDA64C8C998FF3BB1D /* Build configuration list for PBXNativeTarget "Gotcha" */;
buildPhases = (
E01FE2CE8A64C51D25060BE9 /* Build Rust core */,
409DBF67E9C2743801B8F8A4 /* Sources */,
6334AD54CF86DAD2EC201EBC /* Build Rust app */,
9EDD4380917C29EE67EE344B /* Resources */,
);
buildRules = (
@@ -63,6 +103,10 @@
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1430;
TargetAttributes = {
60C9DF1AB4A7858833302BA5 = {
DevelopmentTeam = MU22FMRGK8;
ProvisioningStyle = Automatic;
};
};
};
buildConfigurationList = 807BC8918EC1B0F31384F2B7 /* Build configuration list for PBXProject "Gotcha" */;
@@ -96,7 +140,7 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
6334AD54CF86DAD2EC201EBC /* Build Rust app */ = {
E01FE2CE8A64C51D25060BE9 /* Build Rust core */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
@@ -106,15 +150,15 @@
);
inputPaths = (
);
name = "Build Rust app";
name = "Build Rust core";
outputFileListPaths = (
);
outputPaths = (
"$(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH)",
"$(DERIVED_FILE_DIR)/rust/libgotcha_core.a",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "./build_for_ios_with_cargo.bash gotcha-app\n";
shellScript = "./build_rust_core.bash\n";
};
/* End PBXShellScriptBuildPhase section */
@@ -123,6 +167,14 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
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;
};
@@ -135,15 +187,21 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Gotcha.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = MU22FMRGK8;
INFOPLIST_FILE = Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
LIBRARY_SEARCH_PATHS = "$(inherited) $(DERIVED_FILE_DIR)/rust";
OTHER_LDFLAGS = "$(inherited) -lgotcha_core";
PRODUCT_BUNDLE_IDENTIFIER = de.rfc1437.gotcha;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
SWIFT_OBJC_BRIDGING_HEADER = "Gotcha-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
};
name = Debug;
};
@@ -153,15 +211,21 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Gotcha.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = MU22FMRGK8;
INFOPLIST_FILE = Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
LIBRARY_SEARCH_PATHS = "$(inherited) $(DERIVED_FILE_DIR)/rust";
OTHER_LDFLAGS = "$(inherited) -lgotcha_core";
PRODUCT_BUNDLE_IDENTIFIER = de.rfc1437.gotcha;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
SWIFT_OBJC_BRIDGING_HEADER = "Gotcha-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
};
name = Release;
};

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

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

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

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

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

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

View File

@@ -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 RUSTDOC="$(rustup which --toolchain stable rustdoc)"
export CARGO_TARGET_DIR="$DERIVED_FILE_DIR/cargo"
export CARGO_PROFILE_RELEASE_DEBUG="${CARGO_PROFILE_RELEASE_DEBUG:-1}"
if [[ "$CONFIGURATION" == "Debug" ]]; then
profile=debug
@@ -13,14 +12,7 @@ else
profile=release
fi
if [[ "${LLVM_TARGET_TRIPLE_SUFFIX:-}" == "-simulator" ]]; then
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=()
libraries=()
for arch in $ARCHS; do
if [[ "$arch" == "arm64" && "${LLVM_TARGET_TRIPLE_SUFFIX:-}" == "-simulator" ]]; then
target=aarch64-apple-ios-sim
@@ -34,15 +26,12 @@ for arch in $ARCHS; do
fi
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
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
executables+=("$CARGO_TARGET_DIR/$target/$profile/$1")
libraries+=("$CARGO_TARGET_DIR/$target/$profile/libgotcha_core.a")
done
lipo -create -output "$TARGET_BUILD_DIR/$EXECUTABLE_PATH" "${executables[@]}"
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
mkdir -p "$DERIVED_FILE_DIR/rust"
lipo -create -output "$DERIVED_FILE_DIR/rust/libgotcha_core.a" "${libraries[@]}"

View File

@@ -10,7 +10,14 @@ targets:
deploymentTarget: "17.0"
settings:
PRODUCT_BUNDLE_IDENTIFIER: de.rfc1437.gotcha
DEVELOPMENT_TEAM: MU22FMRGK8
CODE_SIGN_STYLE: Automatic
TARGETED_DEVICE_FAMILY: "1"
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:
path: Info.plist
properties:
@@ -20,10 +27,12 @@ targets:
- UIInterfaceOrientationPortrait
sources:
- Assets.xcassets
postCompileScripts:
- name: Build Rust app
- Sources
- Generated/gotcha_core.swift
preBuildScripts:
- name: Build Rust core
basedOnDependencyAnalysis: false
script: |
./build_for_ios_with_cargo.bash gotcha-app
./build_rust_core.bash
outputFiles:
- $(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH)
- $(DERIVED_FILE_DIR)/rust/libgotcha_core.a