Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,22 @@ Prism is a lightweight, cross-platform desktop app (Windows / macOS / Linux) bui
<p align="center">
<img src="docs/screenshots/review.png" alt="PR review with markdown body and diff" width="900" />
</p>
<p align="center">
<img src="docs/screenshots/list_prs.png" alt="Per-repo PR list with author, age and comment counts" width="900" />
</p>
<p align="center">
<img src="docs/screenshots/command-pallet.png" alt="Command palette — fuzzy search across repos and PRs with prefix filters" width="600" />
</p>
<p align="center">
<img src="docs/screenshots/notifications.png" alt="Inbox with collapsible repo groups and deduped clusters" width="900" />
</p>

## Highlights

- **Inbox** — mirrors GitHub's notification API; mark-as-read syncs both ways.
- **Inbox** — mirrors GitHub's notification API; mark-as-read syncs both ways. Identical notifications (e.g. the same flaky CI check firing six times) collapse into a single row with a `×N` badge; repo groups are collapsible and have a one-click "mark this repo as read".
- **Per-repo PR list** — open a watched repo to see its open / closed / all PRs with author, age and comment counts; click through to the full review.
- **Command palette** (`Ctrl/Cmd+K`) — fuzzy search across watched repos and PRs. Prefix filters (`repo:`, `pr:`) narrow scope; `repo:<name> pr:<term>` searches PRs only inside that repo.
- **Sidebar with hover preview** — collapse the sidebar to a 56px rail; hovering an org avatar shows its watched repos in a hover card you can click through.
- **System tray** — unread badge, pause notifications for 1h / 4h, "mark all read" without opening the app.
- **Native push** — fires only on relevant reasons (review_requested, mention, comment, assign, state_change, ci_activity); skips first-sync and collapses bursts > 3.
- **Drag-select review** — drag across diff lines (LEFT or RIGHT side) to comment on a range. Auto-creates the pending review on first comment; submit with Approve / Comment / Request Changes.
Expand Down Expand Up @@ -125,6 +137,7 @@ src/
## Status

- ✅ Notifications, tray, drag-select review (v0.1.1)
- ✅ Per-repo PR list, command palette, inbox dedupe + collapsible groups, sidebar org hover preview (v0.1.2)
- ⏳ Deep-link from native notification body click (needs custom URI scheme)
- ⏳ Edit / delete pending review comments before submit
- ⏳ Code signing for macOS / Windows
Expand Down
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file added docs/screenshots/command-pallet.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/list_prs.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/notifications.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "prism",
"private": true,
"version": "0.1.1",
"version": "0.1.2",
"type": "module",
"scripts": {
"dev": "vite",
Expand All @@ -18,6 +18,7 @@
"@tauri-apps/api": "^2.10.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"lucide-react": "^1.11.0",
"radix-ui": "^1.4.3",
"react": "^19.2.5",
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "prism"
version = "0.1.1"
version = "0.1.2"
description = "Desktop client for GitHub Pull Requests."
authors = ["Israel Araujo de Oliveira"]
license = "MIT"
Expand Down
269 changes: 269 additions & 0 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ fn pr_from_node(n: GqlPrNode) -> Option<PullRequestRef> {
updated_at: n.updated_at?,
comments: n.comments?.total_count,
draft: n.is_draft.unwrap_or(false),
state: None,
})
}

Expand Down Expand Up @@ -1317,6 +1318,14 @@ pub async fn mark_all_notifications_read(app: tauri::AppHandle) -> AppResult<()>
notifications::mark_all_read(&app).await
}

#[tauri::command]
pub async fn mark_repo_notifications_read(
repo_full: String,
app: tauri::AppHandle,
) -> AppResult<()> {
notifications::mark_repo_read(&app, &repo_full).await
}

#[tauri::command]
pub async fn sync_notifications_now(app: tauri::AppHandle) -> AppResult<()> {
notifications::sync_once(&app).await.map(|_| ())
Expand Down Expand Up @@ -1376,6 +1385,266 @@ pub async fn get_pause_status(app: tauri::AppHandle) -> AppResult<Option<i64>> {
Ok(notifications::paused_until(&app))
}

// ── Repo PR list ───────────────────────────────────────

#[derive(Debug, Serialize)]
pub struct RepoPrPage {
pub items: Vec<PullRequestRef>,
pub total: i64,
pub next_cursor: Option<String>,
}

const REPO_PRS_QUERY: &str = r#"
query($owner: String!, $name: String!, $states: [PullRequestState!], $after: String) {
repository(owner: $owner, name: $name) {
pullRequests(
first: 30
after: $after
states: $states
orderBy: {field: UPDATED_AT, direction: DESC}
) {
totalCount
pageInfo { hasNextPage endCursor }
nodes {
databaseId
number
title
url
updatedAt
isDraft
state
comments { totalCount }
author { login avatarUrl }
repository { nameWithOwner }
}
}
}
}
"#;

#[derive(Deserialize)]
struct RepoPrsData {
repository: Option<RepoPrsRepo>,
}

#[derive(Deserialize)]
struct RepoPrsRepo {
#[serde(rename = "pullRequests")]
pull_requests: RepoPrsConnection,
}

#[derive(Deserialize)]
struct RepoPrsConnection {
#[serde(rename = "totalCount")]
total_count: i64,
#[serde(rename = "pageInfo")]
page_info: RepoPrsPageInfo,
nodes: Vec<RepoPrNode>,
}

#[derive(Deserialize)]
struct RepoPrsPageInfo {
#[serde(rename = "hasNextPage")]
has_next_page: bool,
#[serde(rename = "endCursor")]
end_cursor: Option<String>,
}

#[derive(Deserialize, Default)]
#[serde(default)]
struct RepoPrNode {
#[serde(rename = "databaseId")]
database_id: Option<i64>,
number: Option<i64>,
title: Option<String>,
url: Option<String>,
#[serde(rename = "updatedAt")]
updated_at: Option<String>,
#[serde(rename = "isDraft")]
is_draft: Option<bool>,
state: Option<String>,
comments: Option<GqlComments>,
author: Option<GqlAuthor>,
repository: Option<GqlRepoRef>,
}

fn repo_pr_from_node(n: RepoPrNode) -> Option<PullRequestRef> {
let author = n.author?;
let repo = n.repository?;
Some(PullRequestRef {
id: n.database_id?,
number: n.number?,
title: n.title?,
html_url: n.url?,
repo: repo.name_with_owner,
author: PrAuthor {
login: author.login,
avatar_url: author.avatar_url,
},
updated_at: n.updated_at?,
comments: n.comments?.total_count,
draft: n.is_draft.unwrap_or(false),
state: n.state,
})
}

#[tauri::command]
pub async fn list_repo_prs(
owner: String,
name: String,
scope: String,
after: Option<String>,
) -> AppResult<RepoPrPage> {
let states: Option<Vec<&str>> = match scope.as_str() {
"open" => Some(vec!["OPEN"]),
"closed" => Some(vec!["CLOSED", "MERGED"]),
"all" => None,
other => {
return Err(AppError::InvalidToken(format!("scope inválido: {other}")));
}
};

let token = auth::load_token()?.ok_or(AppError::NotAuthenticated)?;
let client = Client::new(token)?;

let variables = serde_json::json!({
"owner": owner,
"name": name,
"states": states,
"after": after,
});

let data: RepoPrsData = client.graphql(REPO_PRS_QUERY, variables).await?;
let repo = data
.repository
.ok_or_else(|| AppError::InvalidToken("repositório não encontrado".into()))?;

let items: Vec<PullRequestRef> = repo
.pull_requests
.nodes
.into_iter()
.filter_map(repo_pr_from_node)
.collect();

Ok(RepoPrPage {
items,
total: repo.pull_requests.total_count,
next_cursor: if repo.pull_requests.page_info.has_next_page {
repo.pull_requests.page_info.end_cursor
} else {
None
},
})
}

// ── PR search (command palette) ────────────────────────

const PR_SEARCH_QUERY: &str = r#"
query($q: String!) {
search(query: $q, type: ISSUE, first: 15) {
nodes {
... on PullRequest {
databaseId
number
title
url
updatedAt
isDraft
state
comments { totalCount }
author { login avatarUrl }
repository { nameWithOwner }
}
}
}
}
"#;

#[derive(Deserialize)]
struct PrSearchData {
search: PrSearchConnection,
}

#[derive(Deserialize)]
struct PrSearchConnection {
nodes: Vec<RepoPrNode>,
}

#[tauri::command]
pub async fn search_prs(
query: String,
db: State<'_, DbState>,
) -> AppResult<Vec<PullRequestRef>> {
let trimmed = query.trim();
if trimmed.is_empty() {
return Ok(Vec::new());
}

let repos = {
let conn = db.0.lock().unwrap();
db::list_watched(&conn)
};
if repos.is_empty() {
return Ok(Vec::new());
}

let repo_filter = repos
.iter()
.map(|r| format!("repo:{}", r.full_name))
.collect::<Vec<_>>()
.join(" ");

let token = auth::load_token()?.ok_or(AppError::NotAuthenticated)?;
let client = Client::new(token)?;

let q = format!("is:pr {trimmed} {repo_filter}");
let variables = serde_json::json!({ "q": q });

let data: PrSearchData = client.graphql(PR_SEARCH_QUERY, variables).await?;
let items: Vec<PullRequestRef> = data
.search
.nodes
.into_iter()
.filter_map(repo_pr_from_node)
.collect();

Ok(items)
}

#[tauri::command]
pub async fn search_prs_in_repo(
owner: String,
name: String,
query: String,
) -> AppResult<Vec<PullRequestRef>> {
let trimmed_owner = owner.trim();
let trimmed_name = name.trim();
if trimmed_owner.is_empty() || trimmed_name.is_empty() {
return Ok(Vec::new());
}

let token = auth::load_token()?.ok_or(AppError::NotAuthenticated)?;
let client = Client::new(token)?;

let term = query.trim();
let q = if term.is_empty() {
format!("is:pr repo:{trimmed_owner}/{trimmed_name}")
} else {
format!("is:pr {term} repo:{trimmed_owner}/{trimmed_name}")
};
let variables = serde_json::json!({ "q": q });

let data: PrSearchData = client.graphql(PR_SEARCH_QUERY, variables).await?;
let items: Vec<PullRequestRef> = data
.search
.nodes
.into_iter()
.filter_map(repo_pr_from_node)
.collect();

Ok(items)
}

// ── GitHub API ─────────────────────────────────────────

#[tauri::command]
Expand Down
8 changes: 8 additions & 0 deletions src-tauri/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,14 @@ pub fn mark_all_notifications_read(conn: &Connection) {
conn.execute("UPDATE notifications SET unread = 0", []).unwrap();
}

pub fn mark_repo_notifications_read(conn: &Connection, repo_full: &str) {
conn.execute(
"UPDATE notifications SET unread = 0 WHERE repo_full = ?1",
params![repo_full],
)
.unwrap();
}

pub fn get_sync_state(conn: &Connection, key: &str) -> Option<String> {
conn.query_row(
"SELECT value FROM sync_state WHERE key = ?1",
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ pub enum AppError {
Storage(#[from] std::io::Error),
#[error("network: {0}")]
Network(#[from] reqwest::Error),
#[error("{0}")]
Other(String),
}

impl Serialize for AppError {
Expand Down
Loading
Loading