wip: pr file diffing
This commit is contained in:
@@ -14,11 +14,14 @@ paste = "1.0"
|
||||
rand = "0.10.1"
|
||||
regex = "1.12.3"
|
||||
reqwest = { version = "0.13.2", features = ["form", "json", "query"] }
|
||||
bytes = "1.11.0"
|
||||
serde = "1.0.228"
|
||||
serde_json = "1.0.149"
|
||||
tokio = { version = "1.52.1", features = ["rt-multi-thread", "net", "time"] }
|
||||
similar = { version = "2", features = ["bytes"] }
|
||||
tokio = { version = "1.52.1", features = ["rt-multi-thread", "net", "time", "macros"] }
|
||||
tree-sitter = "0.19.5"
|
||||
tree-sitter-markdown = "0.7.1"
|
||||
memchr = "2.8.0"
|
||||
|
||||
[build-dependencies]
|
||||
serde_json = "1.0.149"
|
||||
|
||||
153
build.rs
153
build.rs
@@ -133,9 +133,13 @@ fn render_assets(
|
||||
fn render_github_fixtures(fixture_root: &Path) -> String {
|
||||
let user_fetch = read_json_fixture(&fixture_root.join("user.fetch.json"));
|
||||
let repo_list = read_json_fixture(&fixture_root.join("repo.list.json"));
|
||||
let repo_file_content_root = fixture_root.join("repo.file_content");
|
||||
|
||||
let mut issue_fixtures = BTreeMap::<(String, u32), String>::new();
|
||||
let mut pull_request_fixtures = BTreeMap::<String, String>::new();
|
||||
let mut pull_request_file_tree_fixtures = BTreeMap::<String, String>::new();
|
||||
let mut repo_file_content_fixtures =
|
||||
BTreeMap::<(String, String, String, Option<String>), String>::new();
|
||||
let mut pull_request_timeline_fixtures =
|
||||
BTreeMap::<String, BTreeMap<u32, TimelineFixturePage>>::new();
|
||||
let mut entries = fs::read_dir(fixture_root)
|
||||
@@ -144,6 +148,14 @@ fn render_github_fixtures(fixture_root: &Path) -> String {
|
||||
.collect::<Vec<_>>();
|
||||
entries.sort_by_key(|entry| entry.file_name());
|
||||
|
||||
if repo_file_content_root.exists() {
|
||||
collect_repo_file_content_fixtures(
|
||||
&repo_file_content_root,
|
||||
&repo_file_content_root,
|
||||
&mut repo_file_content_fixtures,
|
||||
);
|
||||
}
|
||||
|
||||
for entry in entries {
|
||||
let file_name = entry
|
||||
.file_name()
|
||||
@@ -161,6 +173,13 @@ fn render_github_fixtures(fixture_root: &Path) -> String {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(id) = parse_pull_request_file_tree_fixture_name(&file_name) {
|
||||
let value = read_fixture_value(&entry.path());
|
||||
pull_request_file_tree_fixtures
|
||||
.insert(id, read_pull_request_file_tree_fixture(&value, &entry.path()));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((id, page)) = parse_pull_request_timeline_fixture_name(&file_name) {
|
||||
let value = read_fixture_value(&entry.path());
|
||||
pull_request_timeline_fixtures
|
||||
@@ -199,6 +218,35 @@ fn render_github_fixtures(fixture_root: &Path) -> String {
|
||||
output.push_str(" }\n");
|
||||
output.push_str("}\n");
|
||||
|
||||
output.push_str("\n");
|
||||
output.push_str(
|
||||
"pub fn repo_file_content(owner: &str, repo: &str, path: &str, reff: Option<&str>) -> Option<&'static str> {\n",
|
||||
);
|
||||
output.push_str(" match (owner, repo, path, reff) {\n");
|
||||
for ((owner, repo, path, reff), content) in repo_file_content_fixtures {
|
||||
output.push_str(" (");
|
||||
output.push_str(&string_literal(&owner));
|
||||
output.push_str(", ");
|
||||
output.push_str(&string_literal(&repo));
|
||||
output.push_str(", ");
|
||||
output.push_str(&string_literal(&path));
|
||||
output.push_str(", ");
|
||||
match reff {
|
||||
| Some(reff) => {
|
||||
output.push_str("Some(");
|
||||
output.push_str(&string_literal(&reff));
|
||||
output.push(')');
|
||||
}
|
||||
| None => output.push_str("None"),
|
||||
}
|
||||
output.push_str(") => Some(");
|
||||
output.push_str(&string_literal(&content));
|
||||
output.push_str("),\n");
|
||||
}
|
||||
output.push_str(" _ => None,\n");
|
||||
output.push_str(" }\n");
|
||||
output.push_str("}\n");
|
||||
|
||||
output.push_str("\n");
|
||||
output.push_str("pub fn issues_pull_request(id: &str) -> Option<&'static str> {\n");
|
||||
output.push_str(" match id {\n");
|
||||
@@ -213,6 +261,20 @@ fn render_github_fixtures(fixture_root: &Path) -> String {
|
||||
output.push_str(" }\n");
|
||||
output.push_str("}\n");
|
||||
|
||||
output.push_str("\n");
|
||||
output.push_str("pub fn issues_pull_request_file_tree(id: &str) -> Option<&'static str> {\n");
|
||||
output.push_str(" match id {\n");
|
||||
for (id, json) in pull_request_file_tree_fixtures {
|
||||
output.push_str(" ");
|
||||
output.push_str(&string_literal(&id));
|
||||
output.push_str(" => Some(");
|
||||
output.push_str(&string_literal(&json));
|
||||
output.push_str("),\n");
|
||||
}
|
||||
output.push_str(" _ => None,\n");
|
||||
output.push_str(" }\n");
|
||||
output.push_str("}\n");
|
||||
|
||||
output.push_str("\n");
|
||||
output.push_str("pub fn issues_pull_request_timeline(id: &str, after: Option<&str>) -> Option<&'static str> {\n");
|
||||
output.push_str(" match (id, after) {\n");
|
||||
@@ -322,6 +384,28 @@ fn read_timeline_fixture(value: &serde_json::Value, path: &Path) -> TimelineFixt
|
||||
TimelineFixturePage { json, end_cursor }
|
||||
}
|
||||
|
||||
fn read_pull_request_file_tree_fixture(value: &serde_json::Value, path: &Path) -> String {
|
||||
if !matches!(
|
||||
value
|
||||
.get("node")
|
||||
.and_then(|node| node.get("files"))
|
||||
.and_then(|files| files.get("edges")),
|
||||
Some(serde_json::Value::Array(_))
|
||||
) {
|
||||
panic!(
|
||||
"pull request file tree fixture {} must include node.files.edges",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
|
||||
serde_json::to_string(value).unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"failed to serialize pull request file tree fixture {}: {err}",
|
||||
path.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn map_issue_fixture(issue: &serde_json::Value) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"id": issue_fixture_graphql_id(issue),
|
||||
@@ -371,6 +455,69 @@ fn required_string<'a>(value: &'a serde_json::Value, path: &[&str]) -> &'a str {
|
||||
.unwrap_or_else(|| panic!("expected string at {} in fixture", path.join(".")))
|
||||
}
|
||||
|
||||
fn collect_repo_file_content_fixtures(
|
||||
root: &Path,
|
||||
dir: &Path,
|
||||
fixtures: &mut BTreeMap<(String, String, String, Option<String>), String>,
|
||||
) {
|
||||
let mut entries = fs::read_dir(dir)
|
||||
.unwrap_or_else(|err| panic!("failed to read {}: {err}", dir.display()))
|
||||
.map(|entry| entry.expect("failed to read repo file content fixture entry"))
|
||||
.collect::<Vec<_>>();
|
||||
entries.sort_by_key(|entry| entry.file_name());
|
||||
|
||||
for entry in entries {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect_repo_file_content_fixtures(root, &path, fixtures);
|
||||
continue;
|
||||
}
|
||||
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let relative = path.strip_prefix(root).unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"failed to compute repo file content fixture path {}: {err}",
|
||||
path.display()
|
||||
)
|
||||
});
|
||||
let parts = relative
|
||||
.components()
|
||||
.map(|component| component.as_os_str().to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if parts.len() < 4 {
|
||||
panic!(
|
||||
"repo file content fixture {} must be nested as owner/repo/ref/path",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
|
||||
let owner = parts[0].clone();
|
||||
let repo = parts[1].clone();
|
||||
let reff = match parts[2].as_str() {
|
||||
| "@default" => None,
|
||||
| value => Some(value.to_owned()),
|
||||
};
|
||||
let virtual_path = parts[3..].join("/");
|
||||
let content = fs::read_to_string(&path).unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"failed to read repo file content fixture {}: {err}",
|
||||
path.display()
|
||||
)
|
||||
});
|
||||
|
||||
if fixtures
|
||||
.insert((owner, repo, virtual_path, reff), content)
|
||||
.is_some()
|
||||
{
|
||||
panic!("duplicate repo file content fixture for {}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_issue_fixture_name(file_name: &str) -> Option<(String, u32)> {
|
||||
let name = file_name.strip_suffix(".json")?;
|
||||
let rest = name.strip_prefix("issues.pull_requests.")?;
|
||||
@@ -392,6 +539,12 @@ fn parse_pull_request_timeline_fixture_name(file_name: &str) -> Option<(String,
|
||||
Some((id.to_owned(), page))
|
||||
}
|
||||
|
||||
fn parse_pull_request_file_tree_fixture_name(file_name: &str) -> Option<String> {
|
||||
let name = file_name.strip_suffix(".json")?;
|
||||
name.strip_prefix("issues.pull_request_file_tree.")
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
fn string_literal(value: &str) -> String {
|
||||
format!("{value:?}")
|
||||
}
|
||||
|
||||
17
examples/similar_demo.rs
Normal file
17
examples/similar_demo.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use similar::{ChangeTag, TextDiff};
|
||||
|
||||
fn main() {
|
||||
let diff = TextDiff::from_lines(
|
||||
"Hello World\nThis is the second line.\nThis is the third.",
|
||||
"Hallo Welt\nThis is the second line.\nThis is life.\nMoar and more",
|
||||
);
|
||||
|
||||
for change in diff.iter_all_changes() {
|
||||
let sign = match change.tag() {
|
||||
ChangeTag::Delete => "-",
|
||||
ChangeTag::Insert => "+",
|
||||
ChangeTag::Equal => " ",
|
||||
};
|
||||
print!("{}{}", sign, change);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,10 @@
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/5151?v=4"
|
||||
},
|
||||
"base_branch_name": "main",
|
||||
"base_repo_slug": "kennethnym/agent-tooling",
|
||||
"base_ref": "8c79c9054e0c28b7ff4b79dd55b04c9af0d81132",
|
||||
"head_branch_name": "feat/worker-context-envelope",
|
||||
"head_repo_slug": "kennethnym/agent-tooling",
|
||||
"head_ref": "4a8df12be732c0f9e5d194cd2af7430c0d2fb8d4",
|
||||
"body": "## Goal\n\nSplit context loading from execution workers so delegation stays predictable while this pull request is still in draft.\n\n### Why\n- workers should receive a compact payload\n- prompt packing should be testable without spawning a worker\n- retry policy should stay in one place\n\n### Proposed flow\n1. Load repository context once.\n2. Normalize file excerpts and metadata.\n3. Hand workers a stable execution envelope.\n\n```text\nContextLoader -> PromptAssembler -> WorkerRunner\n```\n\n> Draft status stays until we decide whether token counts belong in the worker response.\n\n### Questions\n- Should `ContextLoader` expose cache hit metrics?\n- Should worker retries carry the same prompt hash?\n- [ ] Add a regression test for interrupted workers"
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/6161?v=4"
|
||||
},
|
||||
"base_branch_name": "main",
|
||||
"base_repo_slug": "kennethnym/design-notes",
|
||||
"base_ref": "7ab2d10c6f0f244ab18a28fd04c669b47e9bc611",
|
||||
"head_branch_name": "chore/dashboard-spacing-scale",
|
||||
"head_repo_slug": "kennethnym/design-notes",
|
||||
"head_ref": "5b0cf338ec46d581af0d582da6427a3dfbce9018",
|
||||
"body": "## Summary\n\nTightens the dashboard spacing scale before the next visual refresh.\n\n### Updated tokens\n- `space.3` for compact sidebar gaps\n- `space.5` for section rhythm\n- `space.8` for page-level separation\n\n| Surface | Before | After |\n| --- | --- | --- |\n| Sidebar section gap | `space.6` | `space.5` |\n| Filter row padding | `space.4` | `space.3` |\n| Dashboard gutter | `space.7` | `space.6` |\n\n### Review notes\n- verify heading baselines still align with list content\n- compare 1280px and 1440px screenshots side by side\n- [ ] revisit compact mode once the nav collapse lands\n\n**Design intent:** make dense screens feel more deliberate without looking cramped."
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/4242?v=4"
|
||||
},
|
||||
"base_branch_name": "main",
|
||||
"base_repo_slug": "kennethnym/infra-scripts",
|
||||
"base_ref": "4c0de7c2d9c38e7ab1cfe273d03c08bbcbf71740",
|
||||
"head_branch_name": "docs/manual-failover-steps",
|
||||
"head_repo_slug": "kennethnym/infra-scripts",
|
||||
"head_ref": "6fd11baf0d9d53d18f6d7b7dc265d9b09e6f4217",
|
||||
"body": "## Context\n\nDocuments the manual failover sequence for the staging stack while the automated recovery path is still unstable.\n\n### Draft runbook\n1. Put the primary deployment in maintenance mode.\n2. Promote the standby database.\n3. Repoint the app workers.\n4. Warm the cache before reopening traffic.\n\n```bash\n./scripts/failover promote-standby --env staging\n./scripts/failover repoint-workers --env staging\n./scripts/failover verify --env staging\n```\n\n> This pull request was closed because the final DNS validation steps were still changing underneath the runbook.\n\n### Remaining gaps\n- secrets rotation is still manual\n- rollback screenshots are missing\n- [ ] add the final post-cutover checklist"
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/4242?v=4"
|
||||
},
|
||||
"base_branch_name": "main",
|
||||
"base_repo_slug": "kennethnym/novem",
|
||||
"base_ref": "5e8745bfcc0c90c226d3c6af84226d6d4a5ec2d1",
|
||||
"head_branch_name": "feat/cached-issue-pane",
|
||||
"head_repo_slug": "kennethnym/novem",
|
||||
"head_ref": "2bc41de7731b9ef48f7d64ee9f0d5f497dbe0a51",
|
||||
"body": "## Summary\n\nHydrates the dashboard issue pane from cached query state so selection and scroll position stay stable during refetches.\n\n### Rendering coverage\n- [x] headings\n- [x] bullet lists\n- [x] task list items\n- [x] inline code like `use_query`\n- [x] tables\n\n### Implementation sketch\n```rust\nlet cached = query_store.read(key);\nlet selection = cached.and_then(|data| data.selected_issue_id.clone());\n```\n\n| Case | Expected behavior |\n| --- | --- |\n| Cache hit | Keep the current selection pinned |\n| Cache miss | Fall back to the first visible item |\n| Refetch in flight | Preserve scroll position |\n\n### Follow-up\n- [ ] mirror the same cache behavior in the pull request detail pane\n- [ ] add a smoke test around keyboard navigation during refetch\n\nSee also the [query store](src/query.rs) integration notes."
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/4242?v=4"
|
||||
},
|
||||
"base_branch_name": "main",
|
||||
"base_repo_slug": "kennethnym/novem",
|
||||
"base_ref": "5e8745bfcc0c90c226d3c6af84226d6d4a5ec2d1",
|
||||
"head_branch_name": "feat/cached-repo-picker",
|
||||
"head_repo_slug": "kennethnym/novem",
|
||||
"head_ref": "13af7d0b48a6ce0b22d48c9b6c1c78dfcd94e6a0",
|
||||
"body": "## Summary\n\nIntroduces a cached repository query so the titlebar picker can switch context without hitting GitHub on every open.\n\n### Why\n- reduces flicker while the picker opens\n- keeps recent repositories visible during short reconnects\n- avoids duplicate requests when the titlebar rerenders\n\n### Cache rules\n- explicit refresh invalidates the cached list\n- fresh network data still wins when available\n- empty responses should not overwrite a warm cache\n\n```text\nopen picker -> read cache -> render immediately -> refresh in background\n```\n\n### Follow-up\n1. Measure cache hit rate in debug builds.\n2. Add eviction telemetry.\n3. [ ] Consider persisting the last successful repository list across launches."
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/7171?v=4"
|
||||
},
|
||||
"base_branch_name": "main",
|
||||
"base_repo_slug": "kennethnym/sprint-planner",
|
||||
"base_ref": "c6d7e91ef84a8ce6c14f8a06f5588d60962d0af7",
|
||||
"head_branch_name": "feat/release-handoff-checklist",
|
||||
"head_repo_slug": "kennethnym/sprint-planner",
|
||||
"head_ref": "be7a8114a57f3e9d214cb9af457c10fd6c5a0b21",
|
||||
"body": "## Release handoff checklist\n\nAdds the release checklist views and closes the loop for the May rollout.\n\n### Included\n- launch readiness checklist for QA, docs, and release engineering\n- handoff status badges in the weekly planner\n- empty-state copy for weeks without a scheduled release\n\n| Stage | Owner | Status |\n| --- | --- | --- |\n| QA sign-off | `@mariahops` | Done |\n| Docs publish | `@rorycraft` | Done |\n| Release window confirm | `@kennethnym` | Done |\n\n### Verification\n1. Open a release week and confirm checklist sections render in order.\n2. Mark each handoff item complete and confirm the summary badge updates.\n3. Review the planner on a narrow viewport.\n\n> The merged version intentionally keeps the checklist readable even when one section has no pending items.\n\n- [x] QA sign-off state is visible\n- [x] Docs handoff state is visible\n- [ ] Add screenshot coverage for the compact layout"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"node": {
|
||||
"__typename": "PullRequest",
|
||||
"files": {
|
||||
"edges": [
|
||||
{
|
||||
"cursor": "file:PR_kwDONovem84:1",
|
||||
"node": {
|
||||
"changeType": "MODIFIED",
|
||||
"additions": 38,
|
||||
"deletions": 11,
|
||||
"path": "src/query.rs",
|
||||
"viewerViewedState": "UNVIEWED"
|
||||
}
|
||||
},
|
||||
{
|
||||
"cursor": "file:PR_kwDONovem84:2",
|
||||
"node": {
|
||||
"changeType": "MODIFIED",
|
||||
"additions": 54,
|
||||
"deletions": 17,
|
||||
"path": "src/screen/dashboard/issue_list.rs",
|
||||
"viewerViewedState": "VIEWED"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
use std::cmp::Reverse;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Repository {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub full_name: String,
|
||||
pub description: Option<String>,
|
||||
pub default_branch: String,
|
||||
pub private: bool,
|
||||
pub owner: Owner,
|
||||
pub html_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Owner {
|
||||
pub login: String,
|
||||
pub id: u64,
|
||||
pub avatar_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RepoMatch {
|
||||
pub full_name: String,
|
||||
pub score: usize,
|
||||
pub highlighted_description: Option<String>,
|
||||
}
|
||||
|
||||
pub fn score_repositories(repos: Vec<Repository>, needle: &str) -> Vec<RepoMatch> {
|
||||
let needle = needle.trim().to_ascii_lowercase();
|
||||
let mut matches = repos
|
||||
.into_iter()
|
||||
.filter_map(|repo| {
|
||||
let score = match (
|
||||
repo.full_name.eq_ignore_ascii_case(&needle),
|
||||
repo.name.eq_ignore_ascii_case(&needle),
|
||||
) {
|
||||
(true, _) => 400,
|
||||
(_, true) => 300,
|
||||
_ if repo.full_name.to_ascii_lowercase().contains(&needle) => 200,
|
||||
_ if repo
|
||||
.description
|
||||
.as_ref()
|
||||
.map(|description| description.to_ascii_lowercase().contains(&needle))
|
||||
.unwrap_or(false) =>
|
||||
{
|
||||
100
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some(RepoMatch {
|
||||
full_name: repo.full_name,
|
||||
score,
|
||||
highlighted_description: repo.description.map(|description| {
|
||||
description.replace(needle.as_str(), &needle.to_ascii_uppercase())
|
||||
}),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
matches.sort_by_key(|repo| (Reverse(repo.score), repo.full_name.clone()));
|
||||
matches
|
||||
}
|
||||
|
||||
pub fn build_content_path(owner: &str, repo: &str, path: &str, reff: Option<&str>) -> String {
|
||||
match reff {
|
||||
Some(reff) => format!("/repos/{owner}/{repo}/contents/{path}?ref={reff}"),
|
||||
None => format!("/repos/{owner}/{repo}/contents/{path}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
use std::{collections::HashMap, time::SystemTime};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct CachedQueryState {
|
||||
pub selected_issue_id: Option<String>,
|
||||
pub selected_pull_request_id: Option<String>,
|
||||
pub scroll_anchor: Option<String>,
|
||||
pub scroll_offset: usize,
|
||||
pub stale_at: Option<SystemTime>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct QueryStore {
|
||||
entries: HashMap<String, CachedQueryState>,
|
||||
}
|
||||
|
||||
impl QueryStore {
|
||||
pub fn remember_issue_state(
|
||||
&mut self,
|
||||
key: impl Into<String>,
|
||||
selected_issue_id: Option<String>,
|
||||
selected_pull_request_id: Option<String>,
|
||||
scroll_anchor: Option<String>,
|
||||
scroll_offset: usize,
|
||||
) {
|
||||
self.entries.insert(
|
||||
key.into(),
|
||||
CachedQueryState {
|
||||
selected_issue_id,
|
||||
selected_pull_request_id,
|
||||
scroll_anchor,
|
||||
scroll_offset,
|
||||
stale_at: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn snapshot(&self, key: &str) -> Option<CachedQueryState> {
|
||||
self.entries.get(key).cloned()
|
||||
}
|
||||
|
||||
pub fn clear_stale(&mut self) {
|
||||
self.entries
|
||||
.retain(|_, state| state.stale_at.is_none() || state.selected_issue_id.is_some());
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, key: &str) -> Option<CachedQueryState> {
|
||||
self.entries.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reconcile_selected_issue(
|
||||
cached: Option<&CachedQueryState>,
|
||||
visible_ids: &[String],
|
||||
) -> Option<String> {
|
||||
let cached_issue = cached.and_then(|state| state.selected_issue_id.as_ref());
|
||||
|
||||
cached_issue
|
||||
.filter(|issue_id| visible_ids.iter().any(|visible| visible == *issue_id))
|
||||
.cloned()
|
||||
.or_else(|| visible_ids.first().cloned())
|
||||
}
|
||||
|
||||
pub fn reconcile_scroll_anchor(
|
||||
cached: Option<&CachedQueryState>,
|
||||
visible_ids: &[String],
|
||||
) -> Option<String> {
|
||||
cached
|
||||
.and_then(|state| state.scroll_anchor.as_ref())
|
||||
.filter(|anchor| visible_ids.iter().any(|visible| visible == *anchor))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub fn should_repaint(
|
||||
cached: Option<&CachedQueryState>,
|
||||
next_issue_id: Option<&str>,
|
||||
next_pull_request_id: Option<&str>,
|
||||
) -> bool {
|
||||
cached.and_then(|state| state.selected_issue_id.as_deref()) != next_issue_id
|
||||
|| cached.and_then(|state| state.selected_pull_request_id.as_deref())
|
||||
!= next_pull_request_id
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
use std::ops::Deref;
|
||||
|
||||
use gpui::{
|
||||
InteractiveElement, IntoElement, ParentElement, StatefulInteractiveElement, Styled, div, list,
|
||||
point, prelude::FluentBuilder, px,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
api::{self},
|
||||
app,
|
||||
component::{
|
||||
font_icon::{FontIcon, FontIconSvg, font_icon},
|
||||
text::text,
|
||||
},
|
||||
query::{self, QueryStatus, read_query, use_query},
|
||||
};
|
||||
|
||||
pub(crate) struct IssueList {
|
||||
pr_query: query::Entity<api::issues::ListPullRequests>,
|
||||
|
||||
list_state: gpui::ListState,
|
||||
list_items: Vec<IssueListItem>,
|
||||
selected_item: Option<(usize, gpui::SharedString)>,
|
||||
}
|
||||
|
||||
pub(crate) enum Event {
|
||||
ItemSelected(api::issues::Id),
|
||||
}
|
||||
|
||||
#[derive(gpui::IntoElement, Clone)]
|
||||
pub(crate) struct IssueListItem {
|
||||
id: gpui::SharedString,
|
||||
repo_name: Option<gpui::SharedString>,
|
||||
title: gpui::SharedString,
|
||||
description: Option<gpui::SharedString>,
|
||||
status: api::issues::PullRequestState,
|
||||
is_selected: bool,
|
||||
is_last: bool,
|
||||
is_draft: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn new(cx: &mut gpui::Context<IssueList>) -> IssueList {
|
||||
let mut list = IssueList {
|
||||
pr_query: use_query(
|
||||
api::issues::ListPullRequests {
|
||||
filter: Some("author:@me state:open"),
|
||||
page: 1,
|
||||
},
|
||||
cx,
|
||||
),
|
||||
list_state: gpui::ListState::new(0, gpui::ListAlignment::Top, px(100.)),
|
||||
list_items: Vec::new(),
|
||||
selected_item: None,
|
||||
};
|
||||
list.on_create(cx);
|
||||
list
|
||||
}
|
||||
|
||||
impl IssueList {
|
||||
fn on_create(&mut self, cx: &mut gpui::Context<Self>) {
|
||||
cx.observe(&self.pr_query, |this, _, cx| {
|
||||
let data = read_query(&this.pr_query, cx);
|
||||
if let QueryStatus::Loaded(res) = data {
|
||||
let old_len = this.list_state.item_count();
|
||||
let new_len = res.items.len();
|
||||
|
||||
let new_items = res.items.iter().enumerate().map(|(i, it)| IssueListItem {
|
||||
id: gpui::SharedString::from(it.id.deref()),
|
||||
repo_name: Some(gpui::SharedString::new(it.repo_slug.as_str())),
|
||||
title: gpui::SharedString::new(it.title.as_str()),
|
||||
description: None,
|
||||
status: it.state,
|
||||
is_selected: this
|
||||
.selected_item
|
||||
.as_ref()
|
||||
.map(|(_, id)| id.as_str() == it.id.as_str())
|
||||
.unwrap_or(false),
|
||||
is_last: i == new_len - 1,
|
||||
is_draft: it.is_draft,
|
||||
});
|
||||
|
||||
this.list_items.splice(old_len..old_len, new_items);
|
||||
this.list_state.splice(old_len..old_len, new_len);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn on_item_click(&mut self, i: usize, cx: &mut gpui::Context<Self>) {
|
||||
let Some(item_id) = self.list_items.get(i).map(|item| item.id.clone()) else {
|
||||
return;
|
||||
};
|
||||
for (j, item) in self.list_items.iter_mut().enumerate() {
|
||||
item.is_selected = i == j;
|
||||
}
|
||||
cx.notify();
|
||||
cx.emit(Event::ItemSelected(item_id.as_str().into()));
|
||||
}
|
||||
}
|
||||
|
||||
impl gpui::Render for IssueList {
|
||||
fn render(
|
||||
&mut self,
|
||||
_window: &mut gpui::Window,
|
||||
cx: &mut gpui::Context<Self>,
|
||||
) -> impl gpui::IntoElement {
|
||||
let weak = cx.entity();
|
||||
|
||||
list(self.list_state.clone(), move |i, _, cx| {
|
||||
let item = {
|
||||
let this = weak.read(cx);
|
||||
this.list_items[i].clone()
|
||||
};
|
||||
let weak = weak.clone();
|
||||
|
||||
div()
|
||||
.id(item.id.clone())
|
||||
.on_click(move |_, _, cx| {
|
||||
_ = weak.update(cx, |this, cx| {
|
||||
this.on_item_click(i, cx);
|
||||
})
|
||||
})
|
||||
.child(item)
|
||||
.into_any_element()
|
||||
})
|
||||
.size_full()
|
||||
}
|
||||
}
|
||||
|
||||
impl gpui::EventEmitter<Event> for IssueList {}
|
||||
@@ -0,0 +1,33 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Repository {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub full_name: String,
|
||||
pub description: Option<String>,
|
||||
pub default_branch: String,
|
||||
pub private: bool,
|
||||
pub owner: Owner,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Owner {
|
||||
pub login: String,
|
||||
pub id: u64,
|
||||
}
|
||||
|
||||
pub fn filter_repositories(repos: Vec<Repository>, needle: &str) -> Vec<Repository> {
|
||||
let needle = needle.trim().to_ascii_lowercase();
|
||||
|
||||
repos.into_iter()
|
||||
.filter(|repo| {
|
||||
repo.name.to_ascii_lowercase().contains(&needle)
|
||||
|| repo.full_name.to_ascii_lowercase().contains(&needle)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn primary_repo_name(repo: &Repository) -> &str {
|
||||
repo.name.as_str()
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct CachedSelection {
|
||||
pub issue_id: Option<String>,
|
||||
pub scroll_offset: usize,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct QueryStore {
|
||||
entries: HashMap<String, CachedSelection>,
|
||||
}
|
||||
|
||||
impl QueryStore {
|
||||
pub fn snapshot(&self, key: &str) -> Option<CachedSelection> {
|
||||
self.entries.get(key).cloned()
|
||||
}
|
||||
|
||||
pub fn remember(
|
||||
&mut self,
|
||||
key: impl Into<String>,
|
||||
issue_id: Option<String>,
|
||||
scroll_offset: usize,
|
||||
) {
|
||||
self.entries.insert(
|
||||
key.into(),
|
||||
CachedSelection {
|
||||
issue_id,
|
||||
scroll_offset,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn clear(&mut self, key: &str) {
|
||||
self.entries.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reconcile_selection(
|
||||
cached: Option<&CachedSelection>,
|
||||
visible_ids: &[String],
|
||||
) -> Option<String> {
|
||||
let current = cached.and_then(|state| state.issue_id.clone());
|
||||
|
||||
if let Some(id) = current.as_ref() {
|
||||
if visible_ids.iter().any(|visible| visible == id) {
|
||||
return Some(id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
visible_ids.first().cloned()
|
||||
}
|
||||
|
||||
pub fn should_repaint(cached: Option<&CachedSelection>, next_issue_id: Option<&str>) -> bool {
|
||||
cached.and_then(|state| state.issue_id.as_deref()) != next_issue_id
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use std::ops::Deref;
|
||||
|
||||
use gpui::{IntoElement, ParentElement, Styled, div, list, px};
|
||||
|
||||
use crate::{
|
||||
api,
|
||||
component::{
|
||||
font_icon::{FontIcon, font_icon},
|
||||
text::text,
|
||||
},
|
||||
query::{self, QueryStatus, read_query, use_query},
|
||||
};
|
||||
|
||||
pub(crate) struct IssueList {
|
||||
pr_query: query::Entity<api::issues::ListPullRequests>,
|
||||
list_state: gpui::ListState,
|
||||
list_items: Vec<IssueListItem>,
|
||||
}
|
||||
|
||||
#[derive(gpui::IntoElement, Clone)]
|
||||
pub(crate) struct IssueListItem {
|
||||
id: gpui::SharedString,
|
||||
title: gpui::SharedString,
|
||||
status: api::issues::PullRequestState,
|
||||
}
|
||||
|
||||
pub(crate) fn new(cx: &mut gpui::Context<IssueList>) -> IssueList {
|
||||
let mut list = IssueList {
|
||||
pr_query: use_query(
|
||||
api::issues::ListPullRequests {
|
||||
filter: Some("author:@me state:open"),
|
||||
page: 1,
|
||||
},
|
||||
cx,
|
||||
),
|
||||
list_state: gpui::ListState::new(0, gpui::ListAlignment::Top, px(100.)),
|
||||
list_items: Vec::new(),
|
||||
};
|
||||
list.on_create(cx);
|
||||
list
|
||||
}
|
||||
|
||||
impl IssueList {
|
||||
fn on_create(&mut self, cx: &mut gpui::Context<Self>) {
|
||||
cx.observe(&self.pr_query, |this, _, cx| {
|
||||
if let QueryStatus::Loaded(res) = read_query(&this.pr_query, cx) {
|
||||
this.list_items = res
|
||||
.items
|
||||
.iter()
|
||||
.map(|it| IssueListItem {
|
||||
id: gpui::SharedString::from(it.id.deref()),
|
||||
title: gpui::SharedString::new(it.title.as_str()),
|
||||
status: it.state,
|
||||
})
|
||||
.collect();
|
||||
this.list_state.reset(this.list_items.len());
|
||||
cx.notify();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
impl gpui::Render for IssueList {
|
||||
fn render(
|
||||
&mut self,
|
||||
_window: &mut gpui::Window,
|
||||
_cx: &mut gpui::Context<Self>,
|
||||
) -> impl gpui::IntoElement {
|
||||
list(self.list_state.clone(), move |_, _, _| {
|
||||
div().child(text("pull request row")).into_any_element()
|
||||
})
|
||||
.size_full()
|
||||
}
|
||||
}
|
||||
14
src/api.rs
14
src/api.rs
@@ -34,6 +34,8 @@ pub(crate) struct GithubCredentials {
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum Error {
|
||||
Unauthenticated,
|
||||
NotAllowed,
|
||||
DoesNotExist,
|
||||
#[cfg(debug_assertions)]
|
||||
MissingMockFixture(String),
|
||||
Github(GithubError),
|
||||
@@ -104,6 +106,18 @@ impl From<serde_json::Error> for Error {
|
||||
}
|
||||
}
|
||||
|
||||
async fn raw_content(res: reqwest::Response) -> Result<bytes::Bytes, Error> {
|
||||
if res.status().is_success() {
|
||||
Ok(res.bytes().await?)
|
||||
} else {
|
||||
match res.status() {
|
||||
| reqwest::StatusCode::NOT_FOUND => Err(Error::DoesNotExist),
|
||||
| reqwest::StatusCode::FORBIDDEN => Err(Error::NotAllowed),
|
||||
| _ => Err(Error::MalformedResponse(res.status().to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn parse_response<T>(res: reqwest::Response) -> Result<T, Error>
|
||||
where
|
||||
T: serde::de::DeserializeOwned,
|
||||
|
||||
@@ -7,11 +7,15 @@ query PullRequestQuery($id: ID!) {
|
||||
state
|
||||
isDraft
|
||||
createdAt
|
||||
baseRef {
|
||||
name
|
||||
baseRefName
|
||||
baseRefOid
|
||||
headRefName
|
||||
headRefOid
|
||||
baseRepository {
|
||||
nameWithOwner
|
||||
}
|
||||
headRef {
|
||||
name
|
||||
headRepository {
|
||||
nameWithOwner
|
||||
}
|
||||
author {
|
||||
__typename
|
||||
|
||||
19
src/api/graphql/fetch_pull_request_file_tree.graphql
Normal file
19
src/api/graphql/fetch_pull_request_file_tree.graphql
Normal file
@@ -0,0 +1,19 @@
|
||||
query PullRequestFileTreeQuery($id: ID!, $first: Int!) {
|
||||
node(id: $id) {
|
||||
__typename
|
||||
... on PullRequest {
|
||||
files(first: $first) {
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
changeType
|
||||
additions
|
||||
deletions
|
||||
path
|
||||
viewerViewedState
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ use crate::{
|
||||
|
||||
type DateTime = String;
|
||||
type URI = String;
|
||||
type GitObjectID = String;
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
@@ -72,8 +73,12 @@ pub(crate) struct DetailedPullRequest {
|
||||
pub(crate) body: String,
|
||||
pub(crate) created_at: Option<chrono::DateTime<chrono::FixedOffset>>,
|
||||
pub(crate) author: Option<super::user::Actor>,
|
||||
pub(crate) base_branch_name: Option<String>,
|
||||
pub(crate) head_branch_name: Option<String>,
|
||||
pub(crate) base_branch_name: String,
|
||||
pub(crate) base_repo_slug: String,
|
||||
pub(crate) base_ref: String,
|
||||
pub(crate) head_branch_name: String,
|
||||
pub(crate) head_ref: String,
|
||||
pub(crate) head_repo_slug: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -205,6 +210,34 @@ pub(crate) enum PullRequestTimelineItem {
|
||||
},
|
||||
}
|
||||
|
||||
pub(crate) struct ChangedFile {
|
||||
pub(crate) cursor: String,
|
||||
pub(crate) change_type: ChangeType,
|
||||
pub(crate) additions: i64,
|
||||
pub(crate) deletions: i64,
|
||||
pub(crate) path: String,
|
||||
pub(crate) viewer_viewed_state: FileViewedState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub(crate) enum FileViewedState {
|
||||
Dismissed,
|
||||
Viewed,
|
||||
Unviewed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub(crate) enum ChangeType {
|
||||
Added,
|
||||
Modified,
|
||||
Deleted,
|
||||
Renamed,
|
||||
Copied,
|
||||
Changed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct TimelineActor {
|
||||
pub(crate) kind: String,
|
||||
@@ -249,6 +282,15 @@ struct PullRequestQuery;
|
||||
)]
|
||||
struct PullRequestTimelineQuery;
|
||||
|
||||
#[derive(graphql_client::GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "src/api/graphql/schema.json",
|
||||
query_path = "src/api/graphql/fetch_pull_request_file_tree.graphql",
|
||||
extern_enums("FileViewedState")
|
||||
)]
|
||||
struct PullRequestFileTreeQuery;
|
||||
|
||||
pub(super) type PullRequestFileTreeResponse = pull_request_file_tree_query::ResponseData;
|
||||
pub(super) type PullRequestTimelineResponse = pull_request_timeline_query::ResponseData;
|
||||
#[cfg(test)]
|
||||
pub(super) type PullRequestTimelineResponseNode = PullRequestTimelineQueryNode;
|
||||
@@ -289,8 +331,8 @@ impl query::QueryFn for ListPullRequests {
|
||||
}
|
||||
|
||||
let query_string = match self.filter {
|
||||
| Some(filter) => format!("is:pr archived:false sort:updated-desc {}", filter),
|
||||
| None => "is:pr archived:false sort:updated-desc".into(),
|
||||
| Some(filter) => format!("is:pr archived:false sort:updated-desc {}", filter),
|
||||
| None => "is:pr archived:false sort:updated-desc".into(),
|
||||
};
|
||||
|
||||
let gql =
|
||||
@@ -312,19 +354,19 @@ impl query::QueryFn for ListPullRequests {
|
||||
.flatten()
|
||||
.filter_map(|edge| {
|
||||
edge.node.and_then(|n| match n {
|
||||
| PullRequestPaginationQuerySearchEdgesNode::PullRequest(p) => {
|
||||
Some(PullRequest {
|
||||
id: p.id.into(),
|
||||
title: p.title,
|
||||
state: p.state,
|
||||
is_draft: p.is_draft,
|
||||
repo_slug: format!(
|
||||
"{}/{}",
|
||||
p.repository.owner.login, p.repository.name
|
||||
),
|
||||
})
|
||||
}
|
||||
| _ => None,
|
||||
| PullRequestPaginationQuerySearchEdgesNode::PullRequest(p) => {
|
||||
Some(PullRequest {
|
||||
id: p.id.into(),
|
||||
title: p.title,
|
||||
state: p.state,
|
||||
is_draft: p.is_draft,
|
||||
repo_slug: format!(
|
||||
"{}/{}",
|
||||
p.repository.owner.login, p.repository.name
|
||||
),
|
||||
})
|
||||
}
|
||||
| _ => None,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
@@ -369,36 +411,149 @@ impl query::QueryFn for FetchPullRequest {
|
||||
"missing 'node' field on PullRequestQuery response".into(),
|
||||
))
|
||||
.and_then(|n| match n {
|
||||
| PullRequestQueryNode::PullRequest(p) => {
|
||||
let created_at =
|
||||
chrono::DateTime::parse_from_rfc3339(&p.created_at).map_err(|err| {
|
||||
api::Error::MalformedResponse(format!(
|
||||
"invalid pull request createdAt {:?}: {err}",
|
||||
p.created_at
|
||||
))
|
||||
})?;
|
||||
| PullRequestQueryNode::PullRequest(p) => {
|
||||
let created_at =
|
||||
chrono::DateTime::parse_from_rfc3339(&p.created_at).map_err(|err| {
|
||||
api::Error::MalformedResponse(format!(
|
||||
"invalid pull request createdAt {:?}: {err}",
|
||||
p.created_at
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(DetailedPullRequest {
|
||||
title: p.title,
|
||||
state: p.state,
|
||||
is_draft: p.is_draft,
|
||||
body: p.body,
|
||||
author: p.author.map(|it| api::user::Actor {
|
||||
login: it.login,
|
||||
avatar_url: it.avatar_url,
|
||||
}),
|
||||
base_branch_name: p.base_ref.map(|r| r.name),
|
||||
head_branch_name: p.head_ref.map(|r| r.name),
|
||||
created_at: Some(created_at),
|
||||
})
|
||||
}
|
||||
| _ => Err(api::Error::MalformedResponse(
|
||||
"unexpected node type on PullRequestQuery".into(),
|
||||
)),
|
||||
Ok(DetailedPullRequest {
|
||||
title: p.title,
|
||||
state: p.state,
|
||||
is_draft: p.is_draft,
|
||||
body: p.body,
|
||||
author: p.author.map(|it| api::user::Actor {
|
||||
login: it.login,
|
||||
avatar_url: it.avatar_url,
|
||||
}),
|
||||
base_repo_slug: p
|
||||
.base_repository
|
||||
.map(|it| it.name_with_owner)
|
||||
.unwrap_or_default(),
|
||||
base_branch_name: p.base_ref_name,
|
||||
base_ref: p.base_ref_oid,
|
||||
head_repo_slug: p
|
||||
.head_repository
|
||||
.map(|it| it.name_with_owner)
|
||||
.unwrap_or_default(),
|
||||
head_branch_name: p.head_ref_name,
|
||||
head_ref: p.head_ref_oid,
|
||||
created_at: Some(created_at),
|
||||
})
|
||||
}
|
||||
| _ => Err(api::Error::MalformedResponse(
|
||||
"unexpected node type on PullRequestQuery".into(),
|
||||
)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct FetchPullRequestFileTree {
|
||||
pub(crate) id: Id,
|
||||
pub(crate) first: i64,
|
||||
}
|
||||
|
||||
impl query::QueryFn for FetchPullRequestFileTree {
|
||||
type Data = Vec<ChangedFile>;
|
||||
type Error = api::Error;
|
||||
type Context = api::QueryContext;
|
||||
|
||||
fn key(&self) -> query::Key {
|
||||
format!("issues/{}/files?first={}", self.id, self.first).into()
|
||||
}
|
||||
|
||||
async fn run(&self, c: &Self::Context) -> Result<Self::Data, Self::Error> {
|
||||
#[cfg(debug_assertions)]
|
||||
let data = if c.should_use_fixtures {
|
||||
super::mock::fetch_pull_request_file_tree(&self.id)?
|
||||
} else {
|
||||
let gql =
|
||||
PullRequestFileTreeQuery::build_query(pull_request_file_tree_query::Variables {
|
||||
id: self.id.clone().into(),
|
||||
first: self.first,
|
||||
});
|
||||
|
||||
let res = c.github_graphql_request(&gql)?.send().await?;
|
||||
|
||||
api::parse_graphql_response::<PullRequestFileTreeResponse>(res)
|
||||
.await?
|
||||
.1
|
||||
};
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
let data = {
|
||||
let gql =
|
||||
PullRequestFileTreeQuery::build_query(pull_request_file_tree_query::Variables {
|
||||
id: self.id.clone().into(),
|
||||
first: self.first,
|
||||
});
|
||||
|
||||
let res = c.github_graphql_request(&gql)?.send().await?;
|
||||
|
||||
api::parse_graphql_response::<PullRequestFileTreeResponse>(res)
|
||||
.await?
|
||||
.1
|
||||
};
|
||||
|
||||
let pull_request = data
|
||||
.node
|
||||
.ok_or(api::Error::MalformedResponse(
|
||||
"missing 'node' field on PullRequestFileTreeQuery response".into(),
|
||||
))
|
||||
.and_then(|node| match node {
|
||||
| pull_request_file_tree_query::PullRequestFileTreeQueryNode::PullRequest(
|
||||
pull_request,
|
||||
) => Ok(pull_request),
|
||||
| _ => Err(api::Error::MalformedResponse(
|
||||
"unexpected node type on PullRequestFileTreeQuery".into(),
|
||||
)),
|
||||
})?;
|
||||
|
||||
Ok(pull_request
|
||||
.files
|
||||
.and_then(|files| files.edges)
|
||||
.map(|it| {
|
||||
it.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|edge| {
|
||||
let cursor = edge.cursor;
|
||||
edge.node.map(|node| ChangedFile {
|
||||
cursor,
|
||||
change_type: match node.change_type {
|
||||
| pull_request_file_tree_query::PatchStatus::ADDED => ChangeType::Added,
|
||||
| pull_request_file_tree_query::PatchStatus::MODIFIED => {
|
||||
ChangeType::Modified
|
||||
}
|
||||
| pull_request_file_tree_query::PatchStatus::DELETED => {
|
||||
ChangeType::Deleted
|
||||
}
|
||||
| pull_request_file_tree_query::PatchStatus::RENAMED => {
|
||||
ChangeType::Renamed
|
||||
}
|
||||
| pull_request_file_tree_query::PatchStatus::COPIED => {
|
||||
ChangeType::Copied
|
||||
}
|
||||
| pull_request_file_tree_query::PatchStatus::CHANGED => {
|
||||
ChangeType::Changed
|
||||
}
|
||||
| _ => ChangeType::Changed,
|
||||
},
|
||||
additions: node.additions,
|
||||
deletions: node.deletions,
|
||||
path: node.path,
|
||||
viewer_viewed_state: node.viewer_viewed_state,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct FetchPullRequestTimeline {
|
||||
pub(crate) id: Id,
|
||||
@@ -406,12 +561,6 @@ pub(crate) struct FetchPullRequestTimeline {
|
||||
pub(crate) after: Option<String>,
|
||||
}
|
||||
|
||||
impl FetchPullRequestTimeline {
|
||||
pub(crate) fn new(id: Id, first: i64, after: Option<String>) -> Self {
|
||||
Self { id, first, after }
|
||||
}
|
||||
}
|
||||
|
||||
impl query::QueryFn for FetchPullRequestTimeline {
|
||||
type Data = PullRequestTimeline;
|
||||
type Error = api::Error;
|
||||
@@ -437,11 +586,11 @@ impl query::QueryFn for FetchPullRequestTimeline {
|
||||
|
||||
TimelineActor {
|
||||
kind: match on {
|
||||
| actorFieldsOn::Bot => "Bot",
|
||||
| actorFieldsOn::EnterpriseUserAccount => "EnterpriseUserAccount",
|
||||
| actorFieldsOn::Mannequin => "Mannequin",
|
||||
| actorFieldsOn::Organization => "Organization",
|
||||
| actorFieldsOn::User => "User",
|
||||
| actorFieldsOn::Bot => "Bot",
|
||||
| actorFieldsOn::EnterpriseUserAccount => "EnterpriseUserAccount",
|
||||
| actorFieldsOn::Mannequin => "Mannequin",
|
||||
| actorFieldsOn::Organization => "Organization",
|
||||
| actorFieldsOn::User => "User",
|
||||
}
|
||||
.into(),
|
||||
name: login,
|
||||
@@ -451,62 +600,62 @@ impl query::QueryFn for FetchPullRequestTimeline {
|
||||
|
||||
fn normalize_assignee(actor: assigneeFields) -> TimelineActor {
|
||||
match actor {
|
||||
| assigneeFields::Bot(actor) => TimelineActor {
|
||||
kind: "Bot".into(),
|
||||
name: actor.login,
|
||||
avatar_url: Some(actor.avatar_url),
|
||||
},
|
||||
| assigneeFields::Mannequin(actor) => TimelineActor {
|
||||
kind: "Mannequin".into(),
|
||||
name: actor.login,
|
||||
avatar_url: Some(actor.avatar_url),
|
||||
},
|
||||
| assigneeFields::Organization(actor) => TimelineActor {
|
||||
kind: "Organization".into(),
|
||||
name: actor.login,
|
||||
avatar_url: Some(actor.avatar_url),
|
||||
},
|
||||
| assigneeFields::User(actor) => TimelineActor {
|
||||
kind: "User".into(),
|
||||
name: actor.login,
|
||||
avatar_url: Some(actor.avatar_url),
|
||||
},
|
||||
| assigneeFields::Bot(actor) => TimelineActor {
|
||||
kind: "Bot".into(),
|
||||
name: actor.login,
|
||||
avatar_url: Some(actor.avatar_url),
|
||||
},
|
||||
| assigneeFields::Mannequin(actor) => TimelineActor {
|
||||
kind: "Mannequin".into(),
|
||||
name: actor.login,
|
||||
avatar_url: Some(actor.avatar_url),
|
||||
},
|
||||
| assigneeFields::Organization(actor) => TimelineActor {
|
||||
kind: "Organization".into(),
|
||||
name: actor.login,
|
||||
avatar_url: Some(actor.avatar_url),
|
||||
},
|
||||
| assigneeFields::User(actor) => TimelineActor {
|
||||
kind: "User".into(),
|
||||
name: actor.login,
|
||||
avatar_url: Some(actor.avatar_url),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_requested_reviewer(actor: requestedReviewerFields) -> TimelineActor {
|
||||
match actor {
|
||||
| requestedReviewerFields::Bot(actor) => TimelineActor {
|
||||
kind: "Bot".into(),
|
||||
name: actor.login,
|
||||
avatar_url: Some(actor.avatar_url),
|
||||
},
|
||||
| requestedReviewerFields::Mannequin(actor) => TimelineActor {
|
||||
kind: "Mannequin".into(),
|
||||
name: actor.login,
|
||||
avatar_url: Some(actor.avatar_url),
|
||||
},
|
||||
| requestedReviewerFields::Team(actor) => TimelineActor {
|
||||
kind: "Team".into(),
|
||||
name: actor.name,
|
||||
avatar_url: None,
|
||||
},
|
||||
| requestedReviewerFields::User(actor) => TimelineActor {
|
||||
kind: "User".into(),
|
||||
name: actor.login,
|
||||
avatar_url: Some(actor.avatar_url),
|
||||
},
|
||||
| requestedReviewerFields::Bot(actor) => TimelineActor {
|
||||
kind: "Bot".into(),
|
||||
name: actor.login,
|
||||
avatar_url: Some(actor.avatar_url),
|
||||
},
|
||||
| requestedReviewerFields::Mannequin(actor) => TimelineActor {
|
||||
kind: "Mannequin".into(),
|
||||
name: actor.login,
|
||||
avatar_url: Some(actor.avatar_url),
|
||||
},
|
||||
| requestedReviewerFields::Team(actor) => TimelineActor {
|
||||
kind: "Team".into(),
|
||||
name: actor.name,
|
||||
avatar_url: None,
|
||||
},
|
||||
| requestedReviewerFields::User(actor) => TimelineActor {
|
||||
kind: "User".into(),
|
||||
name: actor.login,
|
||||
avatar_url: Some(actor.avatar_url),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_review_state(state: PullRequestReviewState) -> String {
|
||||
match state {
|
||||
| PullRequestReviewState::PENDING => "PENDING",
|
||||
| PullRequestReviewState::COMMENTED => "COMMENTED",
|
||||
| PullRequestReviewState::APPROVED => "APPROVED",
|
||||
| PullRequestReviewState::CHANGES_REQUESTED => "CHANGES_REQUESTED",
|
||||
| PullRequestReviewState::DISMISSED => "DISMISSED",
|
||||
| _ => "OTHER",
|
||||
| PullRequestReviewState::PENDING => "PENDING",
|
||||
| PullRequestReviewState::COMMENTED => "COMMENTED",
|
||||
| PullRequestReviewState::APPROVED => "APPROVED",
|
||||
| PullRequestReviewState::CHANGES_REQUESTED => "CHANGES_REQUESTED",
|
||||
| PullRequestReviewState::DISMISSED => "DISMISSED",
|
||||
| _ => "OTHER",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
@@ -726,10 +875,10 @@ impl query::QueryFn for FetchPullRequestTimeline {
|
||||
"missing 'node' field on PullRequestTimelineQuery response".into(),
|
||||
))
|
||||
.and_then(|node| match node {
|
||||
| PullRequestTimelineQueryNode::PullRequest(pull_request) => Ok(pull_request),
|
||||
| _ => Err(api::Error::MalformedResponse(
|
||||
"unexpected node type on PullRequestTimelineQuery".into(),
|
||||
)),
|
||||
| PullRequestTimelineQueryNode::PullRequest(pull_request) => Ok(pull_request),
|
||||
| _ => Err(api::Error::MalformedResponse(
|
||||
"unexpected node type on PullRequestTimelineQuery".into(),
|
||||
)),
|
||||
})?;
|
||||
|
||||
let timeline = pull_request.timeline_items;
|
||||
|
||||
181
src/api/mock.rs
181
src/api/mock.rs
@@ -24,6 +24,27 @@ pub(crate) fn list_repos() -> Result<Vec<repo::Repository>, api::Error> {
|
||||
parse_fixture("repo.list", repo_list())
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_file_content(
|
||||
repo_slug: &str,
|
||||
path: &str,
|
||||
reff: Option<&str>,
|
||||
) -> Result<bytes::Bytes, api::Error> {
|
||||
let (owner, repo) = repo_slug.split_once('/').ok_or_else(|| {
|
||||
api::Error::MalformedResponse(format!(
|
||||
"invalid repo slug for repo.file_content fixture: {repo_slug}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let content = repo_file_content(owner, repo, path, reff).ok_or_else(|| {
|
||||
api::Error::MissingMockFixture(format!(
|
||||
"repo.file_content repo_slug={repo_slug} owner={owner} repo={repo} path={path} ref={}",
|
||||
reff.unwrap_or_default()
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(bytes::Bytes::from_static(content.as_bytes()))
|
||||
}
|
||||
|
||||
pub(crate) fn list_pull_requests(
|
||||
filter: Option<&str>,
|
||||
page: u32,
|
||||
@@ -52,6 +73,17 @@ pub(crate) fn fetch_pull_request(
|
||||
parse_fixture(&format!("issues.pull_request.{id}"), json)
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_pull_request_file_tree(
|
||||
id: &issues::Id,
|
||||
) -> Result<issues::PullRequestFileTreeResponse, api::Error> {
|
||||
let id = id.to_string();
|
||||
let json = issues_pull_request_file_tree(&id).ok_or_else(|| {
|
||||
api::Error::MissingMockFixture(format!("issues.pull_request_file_tree id={id}"))
|
||||
})?;
|
||||
|
||||
parse_fixture(&format!("issues.pull_request_file_tree.{id}"), json)
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_pull_request_timeline(
|
||||
id: &issues::Id,
|
||||
after: Option<&str>,
|
||||
@@ -146,10 +178,10 @@ mod tests {
|
||||
merged.author.as_ref().map(|author| author.login.as_str()),
|
||||
Some("rorycraft")
|
||||
);
|
||||
assert_eq!(merged.base_branch_name.as_deref(), Some("main"));
|
||||
assert_eq!(merged.base_branch_name.as_str(), "main");
|
||||
assert_eq!(
|
||||
merged.head_branch_name.as_deref(),
|
||||
Some("feat/release-handoff-checklist")
|
||||
merged.head_branch_name.as_str(),
|
||||
"feat/release-handoff-checklist"
|
||||
);
|
||||
assert_eq!(
|
||||
merged.created_at,
|
||||
@@ -167,23 +199,28 @@ mod tests {
|
||||
.map(|author| author.login.as_str()),
|
||||
Some("kennethnym")
|
||||
);
|
||||
assert_eq!(documented_failover.base_branch_name.as_str(), "main");
|
||||
assert_eq!(
|
||||
documented_failover.base_branch_name.as_deref(),
|
||||
Some("main")
|
||||
);
|
||||
assert_eq!(
|
||||
documented_failover.head_branch_name.as_deref(),
|
||||
Some("docs/manual-failover-steps")
|
||||
documented_failover.head_branch_name.as_str(),
|
||||
"docs/manual-failover-steps"
|
||||
);
|
||||
assert_eq!(
|
||||
documented_failover.created_at,
|
||||
Some(chrono::DateTime::parse_from_rfc3339("2026-04-24T06:40:00Z").unwrap())
|
||||
);
|
||||
assert!(dashboard_markdown.body.contains("```rust"));
|
||||
assert_eq!(dashboard_markdown.base_branch_name.as_deref(), Some("main"));
|
||||
assert_eq!(dashboard_markdown.base_branch_name.as_str(), "main");
|
||||
assert_eq!(
|
||||
dashboard_markdown.head_branch_name.as_deref(),
|
||||
Some("feat/cached-issue-pane")
|
||||
dashboard_markdown.head_branch_name.as_str(),
|
||||
"feat/cached-issue-pane"
|
||||
);
|
||||
assert_eq!(
|
||||
dashboard_markdown.base_ref.as_str(),
|
||||
"5e8745bfcc0c90c226d3c6af84226d6d4a5ec2d1"
|
||||
);
|
||||
assert_eq!(
|
||||
dashboard_markdown.head_ref.as_str(),
|
||||
"2bc41de7731b9ef48f7d64ee9f0d5f497dbe0a51"
|
||||
);
|
||||
assert_eq!(
|
||||
dashboard_markdown.created_at,
|
||||
@@ -196,10 +233,18 @@ mod tests {
|
||||
.map(|author| author.login.as_str()),
|
||||
Some("kennethnym")
|
||||
);
|
||||
assert_eq!(cached_repo_picker.base_branch_name.as_deref(), Some("main"));
|
||||
assert_eq!(cached_repo_picker.base_branch_name.as_str(), "main");
|
||||
assert_eq!(
|
||||
cached_repo_picker.head_branch_name.as_deref(),
|
||||
Some("feat/cached-repo-picker")
|
||||
cached_repo_picker.head_branch_name.as_str(),
|
||||
"feat/cached-repo-picker"
|
||||
);
|
||||
assert_eq!(
|
||||
cached_repo_picker.base_ref.as_str(),
|
||||
"5e8745bfcc0c90c226d3c6af84226d6d4a5ec2d1"
|
||||
);
|
||||
assert_eq!(
|
||||
cached_repo_picker.head_ref.as_str(),
|
||||
"13af7d0b48a6ce0b22d48c9b6c1c78dfcd94e6a0"
|
||||
);
|
||||
assert_eq!(
|
||||
cached_repo_picker.created_at,
|
||||
@@ -212,10 +257,10 @@ mod tests {
|
||||
.map(|author| author.login.as_str()),
|
||||
Some("leaferiksen")
|
||||
);
|
||||
assert_eq!(worker_split.base_branch_name.as_deref(), Some("main"));
|
||||
assert_eq!(worker_split.base_branch_name.as_str(), "main");
|
||||
assert_eq!(
|
||||
worker_split.head_branch_name.as_deref(),
|
||||
Some("feat/worker-context-envelope")
|
||||
worker_split.head_branch_name.as_str(),
|
||||
"feat/worker-context-envelope"
|
||||
);
|
||||
assert_eq!(
|
||||
worker_split.created_at,
|
||||
@@ -228,10 +273,10 @@ mod tests {
|
||||
.map(|author| author.login.as_str()),
|
||||
Some("mariahops")
|
||||
);
|
||||
assert_eq!(spacing_tokens.base_branch_name.as_deref(), Some("main"));
|
||||
assert_eq!(spacing_tokens.base_branch_name.as_str(), "main");
|
||||
assert_eq!(
|
||||
spacing_tokens.head_branch_name.as_deref(),
|
||||
Some("chore/dashboard-spacing-scale")
|
||||
spacing_tokens.head_branch_name.as_str(),
|
||||
"chore/dashboard-spacing-scale"
|
||||
);
|
||||
assert_eq!(
|
||||
spacing_tokens.created_at,
|
||||
@@ -239,6 +284,100 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_file_content_fixtures_align_with_pull_request_refs() {
|
||||
let dashboard_markdown = fetch_pull_request(&issues::Id::from("PR_kwDONovem84"))
|
||||
.expect("dashboard pull request fixture should parse");
|
||||
let cached_repo_picker = fetch_pull_request(&issues::Id::from("PR_kwDONovem85"))
|
||||
.expect("repo picker pull request fixture should parse");
|
||||
|
||||
let base_query = fetch_file_content(
|
||||
"kennethnym/novem",
|
||||
"src/query.rs",
|
||||
Some(dashboard_markdown.base_ref.as_str()),
|
||||
)
|
||||
.expect("base query fixture should exist");
|
||||
let head_query = fetch_file_content(
|
||||
"kennethnym/novem",
|
||||
"src/query.rs",
|
||||
Some(dashboard_markdown.head_ref.as_str()),
|
||||
)
|
||||
.expect("head query fixture should exist");
|
||||
let base_query = std::str::from_utf8(base_query.as_ref())
|
||||
.expect("base query fixture should be utf-8 text");
|
||||
let head_query = std::str::from_utf8(head_query.as_ref())
|
||||
.expect("head query fixture should be utf-8 text");
|
||||
|
||||
assert!(base_query.contains("pub struct CachedSelection"));
|
||||
assert!(head_query.contains("pub struct CachedQueryState"));
|
||||
assert!(head_query.contains("selected_pull_request_id"));
|
||||
assert!(head_query.contains("reconcile_scroll_anchor"));
|
||||
assert_ne!(base_query, head_query);
|
||||
|
||||
let base_repo = fetch_file_content(
|
||||
"kennethnym/novem",
|
||||
"src/api/repo.rs",
|
||||
Some(cached_repo_picker.base_ref.as_str()),
|
||||
)
|
||||
.expect("base repo fixture should exist");
|
||||
let head_repo = fetch_file_content(
|
||||
"kennethnym/novem",
|
||||
"src/api/repo.rs",
|
||||
Some(cached_repo_picker.head_ref.as_str()),
|
||||
)
|
||||
.expect("head repo fixture should exist");
|
||||
let base_repo =
|
||||
std::str::from_utf8(base_repo.as_ref()).expect("base repo fixture should be utf-8");
|
||||
let head_repo =
|
||||
std::str::from_utf8(head_repo.as_ref()).expect("head repo fixture should be utf-8");
|
||||
|
||||
assert!(base_repo.contains("pub fn filter_repositories"));
|
||||
assert!(head_repo.contains("pub struct RepoMatch"));
|
||||
assert!(head_repo.contains("pub fn build_content_path"));
|
||||
assert_ne!(base_repo, head_repo);
|
||||
|
||||
let _ = fetch_pull_request_file_tree(&issues::Id::from("PR_kwDONovem84"))
|
||||
.expect("pull request file tree fixture should parse");
|
||||
|
||||
let file_tree_json: serde_json::Value = serde_json::from_str(
|
||||
issues_pull_request_file_tree("PR_kwDONovem84")
|
||||
.expect("pull request file tree fixture json should exist"),
|
||||
)
|
||||
.expect("pull request file tree fixture json should parse");
|
||||
|
||||
let file_paths = file_tree_json
|
||||
.get("node")
|
||||
.and_then(|node| node.get("files"))
|
||||
.and_then(|files| files.get("edges"))
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.expect("pull request file tree fixture should contain file edges")
|
||||
.iter()
|
||||
.filter_map(|edge| edge.get("node"))
|
||||
.filter_map(|node| node.get("path"))
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
file_paths,
|
||||
vec!["src/query.rs", "src/screen/dashboard/issue_list.rs"]
|
||||
);
|
||||
|
||||
for path in file_paths {
|
||||
fetch_file_content(
|
||||
dashboard_markdown.base_repo_slug.as_str(),
|
||||
path,
|
||||
Some(dashboard_markdown.base_ref.as_str()),
|
||||
)
|
||||
.unwrap_or_else(|_| panic!("base fixture should exist for {path}"));
|
||||
fetch_file_content(
|
||||
dashboard_markdown.head_repo_slug.as_str(),
|
||||
path,
|
||||
Some(dashboard_markdown.head_ref.as_str()),
|
||||
)
|
||||
.unwrap_or_else(|_| panic!("head fixture should exist for {path}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_timeline_fixtures_parse() {
|
||||
let first_page = fetch_pull_request_timeline(&issues::Id::from("PR_kwDOSprint62"), None)
|
||||
|
||||
126
src/api/repo.rs
126
src/api/repo.rs
@@ -1,6 +1,13 @@
|
||||
use futures::{FutureExt, TryFutureExt};
|
||||
use reqwest::Method;
|
||||
use serde::Deserialize;
|
||||
use tokio::sync::OwnedRwLockReadGuard;
|
||||
|
||||
use crate::{api, query};
|
||||
use crate::{
|
||||
api,
|
||||
query::{self, Query, fetch_query},
|
||||
util::file,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Repository {
|
||||
@@ -22,6 +29,13 @@ pub struct Owner {
|
||||
pub html_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileRef {
|
||||
pub repo_slug: String,
|
||||
pub path: String,
|
||||
pub reff: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct List;
|
||||
|
||||
@@ -50,3 +64,113 @@ impl query::QueryFn for List {
|
||||
api::parse_response(res).await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FetchFileContent {
|
||||
pub repo_slug: String,
|
||||
pub path: String,
|
||||
pub reff: Option<String>,
|
||||
}
|
||||
|
||||
impl query::QueryFn for FetchFileContent {
|
||||
type Data = bytes::Bytes;
|
||||
type Error = api::Error;
|
||||
type Context = api::QueryContext;
|
||||
|
||||
fn key(&self) -> query::Key {
|
||||
match &self.reff {
|
||||
| Some(reff) => format!("repo/fetch/{}/{}/{}", self.repo_slug, self.path, reff).into(),
|
||||
| None => format!("repo/fetch/{}/{}", self.repo_slug, self.path).into(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(&self, c: &Self::Context) -> Result<Self::Data, Self::Error> {
|
||||
#[cfg(debug_assertions)]
|
||||
if c.should_use_fixtures {
|
||||
return super::mock::fetch_file_content(
|
||||
&self.repo_slug,
|
||||
&self.path,
|
||||
self.reff.as_deref(),
|
||||
);
|
||||
}
|
||||
|
||||
let path = match &self.reff {
|
||||
| Some(reff) => format!(
|
||||
"/repos/{}/contents/{}?ref={}",
|
||||
self.repo_slug, self.path, reff
|
||||
),
|
||||
| None => format!("/repos/{}/contents/{}", self.repo_slug, self.path),
|
||||
};
|
||||
|
||||
let res = c
|
||||
.github_request(Method::GET, &path)?
|
||||
.header("Accept", "application/vnd.github.raw+json")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
api::raw_content(res).await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct QueryFileDiff {
|
||||
pub base: FileRef,
|
||||
pub head: FileRef,
|
||||
}
|
||||
|
||||
impl query::QueryFn for QueryFileDiff {
|
||||
type Data = Option<()>;
|
||||
type Error = api::Error;
|
||||
type Context = api::QueryContext;
|
||||
|
||||
fn key(&self) -> query::Key {
|
||||
format!(
|
||||
"repo/diff/{}/{}/{}",
|
||||
self.base.repo_slug, self.base.path, self.head.path
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
async fn run(&self, c: &Self::Context) -> Result<Self::Data, Self::Error> {
|
||||
async fn fetch_content(
|
||||
r: &FileRef,
|
||||
c: &<QueryFileDiff as query::QueryFn>::Context,
|
||||
) -> Result<Option<bytes::Bytes>, api::Error> {
|
||||
let path = match &r.reff {
|
||||
| Some(reff) => format!("/repos/{}/contents/{}?ref={}", r.repo_slug, r.path, reff),
|
||||
| None => format!("/repos/{}/contents/{}", r.repo_slug, r.path),
|
||||
};
|
||||
|
||||
let res = c
|
||||
.github_request(Method::GET, &path)?
|
||||
.header("Accept", "application/vnd.github.raw+json")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
res.headers().get("Content-Type");
|
||||
|
||||
let bytes = api::raw_content(res).await?;
|
||||
let file::ContentType::Text = file::classify_content(&bytes) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(Some(bytes))
|
||||
}
|
||||
|
||||
let (old, new) = tokio::join!(fetch_content(&self.base, c), fetch_content(&self.head, c),);
|
||||
|
||||
match (old, new) {
|
||||
| (Ok(Some(ref old)), Ok(Some(ref new))) => {
|
||||
let diff = similar::TextDiff::from_lines::<[u8]>(old, new);
|
||||
for change in diff.iter_all_changes() {}
|
||||
}
|
||||
| _ => {
|
||||
return Err(api::Error::MalformedResponse(
|
||||
"failed to fetch content".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,8 +87,8 @@ impl gpui::RenderOnce for Button {
|
||||
let theme = app::current_theme(cx);
|
||||
|
||||
let icon_color = match self.variant {
|
||||
| Variant::Primary => theme.colors.accent_on_solid,
|
||||
| Variant::Secondary => theme.colors.text,
|
||||
| Variant::Primary => theme.colors.accent_on_solid,
|
||||
| Variant::Secondary => theme.colors.text,
|
||||
};
|
||||
|
||||
let mut children: Vec<AnyElement> = Vec::with_capacity(3);
|
||||
|
||||
108
src/component/code_view.rs
Normal file
108
src/component/code_view.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
use std::{rc::Rc, sync::Arc};
|
||||
|
||||
use gpui::{IntoElement, ParentElement, Styled, div, list, px, rems};
|
||||
|
||||
use crate::app;
|
||||
|
||||
#[derive(gpui::IntoElement, Clone)]
|
||||
pub(crate) struct Line {
|
||||
line_number_col_width: gpui::Pixels,
|
||||
line_number: usize,
|
||||
content: gpui::SharedString,
|
||||
diff_marker: DiffMarker,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum DiffMarker {
|
||||
Added,
|
||||
Deleted,
|
||||
Unchanged,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CodeViewState(gpui::ListState);
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Lines(Rc<Vec<Line>>);
|
||||
|
||||
struct CodeView {
|
||||
state: CodeViewState,
|
||||
lines: Lines,
|
||||
}
|
||||
|
||||
pub(crate) fn line(
|
||||
line_number: usize,
|
||||
content: impl Into<Arc<str>>,
|
||||
diff_marker: DiffMarker,
|
||||
) -> Line {
|
||||
Line {
|
||||
line_number,
|
||||
diff_marker,
|
||||
content: gpui::SharedString::new(content),
|
||||
line_number_col_width: px(0.),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn code_view(state: CodeViewState, lines: Lines) -> CodeView {
|
||||
CodeView { state, lines }
|
||||
}
|
||||
|
||||
impl FromIterator<Line> for Lines {
|
||||
fn from_iter<T: IntoIterator<Item = Line>>(iter: T) -> Self {
|
||||
Lines(Rc::new(iter.into_iter().collect()))
|
||||
}
|
||||
}
|
||||
|
||||
impl gpui::RenderOnce for CodeView {
|
||||
fn render(self, window: &mut gpui::Window, cx: &mut gpui::App) -> impl gpui::IntoElement {
|
||||
let digits = self
|
||||
.lines
|
||||
.0
|
||||
.last()
|
||||
.map(|l| l.line_number.to_string().len())
|
||||
.unwrap_or(0);
|
||||
|
||||
let text_style = window.text_style();
|
||||
let font_size = text_style.font_size.to_pixels(window.rem_size());
|
||||
let font_id = window.text_system().resolve_font(&gpui::font("Menlo"));
|
||||
|
||||
let line_number_col_width = window
|
||||
.text_system()
|
||||
.ch_advance(font_id, font_size)
|
||||
.unwrap_or(px(7.2))
|
||||
* digits;
|
||||
|
||||
list(self.state.0, move |i, _window, _app| {
|
||||
let mut line = self.lines.0[i].clone();
|
||||
line.line_number_col_width = line_number_col_width;
|
||||
|
||||
div()
|
||||
.flex()
|
||||
.flex_row()
|
||||
.items_start()
|
||||
.w_full()
|
||||
.child(line)
|
||||
.into_any_element()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl gpui::RenderOnce for Line {
|
||||
fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement {
|
||||
let theme = app::current_theme(cx);
|
||||
|
||||
div()
|
||||
.flex()
|
||||
.flex_row()
|
||||
.font_family("Menlo")
|
||||
.text_color(theme.colors.text)
|
||||
.child(
|
||||
div()
|
||||
.bg(theme.colors.surface)
|
||||
.w(self.line_number_col_width)
|
||||
.text_align(gpui::TextAlign::Right)
|
||||
.child(self.line_number.to_string()),
|
||||
)
|
||||
.child(self.content)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub(crate) mod button;
|
||||
pub(crate) mod code_view;
|
||||
pub(crate) mod font_icon;
|
||||
pub(crate) mod markdown;
|
||||
pub(crate) mod text;
|
||||
|
||||
@@ -148,8 +148,8 @@ impl gpui::RenderOnce for IssueListItem {
|
||||
}
|
||||
|
||||
let repo_name_text = match self.repo_name {
|
||||
| Some(name) => text(name),
|
||||
| None => text("Unknown repo"),
|
||||
| Some(name) => text(name),
|
||||
| None => text("Unknown repo"),
|
||||
}
|
||||
.text_xs()
|
||||
.opacity(0.5);
|
||||
@@ -162,21 +162,21 @@ impl gpui::RenderOnce for IssueListItem {
|
||||
.bg(theme.colors.surface)
|
||||
} else {
|
||||
match self.status {
|
||||
| api::issues::PullRequestState::Closed => pill(
|
||||
text("Closed").text_color(theme.colors.danger_on_solid),
|
||||
font_icon(FontIcon::PullRequestClosed).text_color(theme.colors.danger_on_solid),
|
||||
)
|
||||
.bg(theme.colors.danger_solid),
|
||||
| api::issues::PullRequestState::Merged => pill(
|
||||
text("Merged").text_color(theme.colors.accent_on_solid),
|
||||
font_icon(FontIcon::PullRequestClosed).text_color(theme.colors.accent_on_solid),
|
||||
)
|
||||
.bg(theme.colors.accent_solid),
|
||||
| _ => pill(
|
||||
text("Open").text_color(theme.colors.success_on_solid),
|
||||
font_icon(FontIcon::PullRequestArrow).text_color(theme.colors.success_on_solid),
|
||||
)
|
||||
.bg(theme.colors.success_solid),
|
||||
| api::issues::PullRequestState::Closed => pill(
|
||||
text("Closed").text_color(theme.colors.danger_on_solid),
|
||||
font_icon(FontIcon::PullRequestClosed).text_color(theme.colors.danger_on_solid),
|
||||
)
|
||||
.bg(theme.colors.danger_solid),
|
||||
| api::issues::PullRequestState::Merged => pill(
|
||||
text("Merged").text_color(theme.colors.accent_on_solid),
|
||||
font_icon(FontIcon::PullRequestClosed).text_color(theme.colors.accent_on_solid),
|
||||
)
|
||||
.bg(theme.colors.accent_solid),
|
||||
| _ => pill(
|
||||
text("Open").text_color(theme.colors.success_on_solid),
|
||||
font_icon(FontIcon::PullRequestArrow).text_color(theme.colors.success_on_solid),
|
||||
)
|
||||
.bg(theme.colors.success_solid),
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod issue_list;
|
||||
mod pull_request_diff_view;
|
||||
mod pull_request_view;
|
||||
mod screen;
|
||||
mod sidebar;
|
||||
|
||||
79
src/screen/dashboard/pull_request_diff_view.rs
Normal file
79
src/screen/dashboard/pull_request_diff_view.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
use crate::{
|
||||
api,
|
||||
query::{self, QueryStatus, read_query, use_query},
|
||||
};
|
||||
|
||||
pub(crate) struct PullRequestDiffView {
|
||||
selected_file_path: Option<String>,
|
||||
|
||||
pr_query: query::Entity<api::issues::FetchPullRequest>,
|
||||
old_content_query: Option<query::Entity<api::repo::FetchFileContent>>,
|
||||
new_content_query: Option<query::Entity<api::repo::FetchFileContent>>,
|
||||
}
|
||||
|
||||
fn new(pr_id: api::issues::Id, cx: &mut gpui::Context<PullRequestDiffView>) -> PullRequestDiffView {
|
||||
let mut view = PullRequestDiffView {
|
||||
selected_file_path: None,
|
||||
pr_query: use_query(api::issues::FetchPullRequest { id: pr_id }, cx),
|
||||
old_content_query: None,
|
||||
new_content_query: None,
|
||||
};
|
||||
view.on_create(cx);
|
||||
view
|
||||
}
|
||||
|
||||
impl PullRequestDiffView {
|
||||
fn on_create(&mut self, cx: &mut gpui::Context<Self>) {
|
||||
_ = cx
|
||||
.observe(&self.pr_query, |this, _, cx| {
|
||||
this.start_content_queries(cx);
|
||||
})
|
||||
.detach();
|
||||
|
||||
// if pr is already loaded, start content queries
|
||||
self.start_content_queries(cx);
|
||||
}
|
||||
|
||||
fn start_content_queries(&mut self, cx: &mut gpui::Context<Self>) {
|
||||
let Some((old_content_query, new_content_query)) = ({
|
||||
if let QueryStatus::Loaded(pr) = read_query(&self.pr_query, cx) {
|
||||
Some((
|
||||
api::repo::FetchFileContent {
|
||||
repo_slug: pr.base_repo_slug.clone(),
|
||||
path: pr.base_branch_name.clone(),
|
||||
reff: Some(pr.base_ref.clone()),
|
||||
},
|
||||
api::repo::FetchFileContent {
|
||||
repo_slug: pr.head_repo_slug.clone(),
|
||||
path: pr.head_branch_name.clone(),
|
||||
reff: Some(pr.head_ref.clone()),
|
||||
},
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let old_content_query = use_query(old_content_query, cx);
|
||||
let new_content_query = use_query(new_content_query, cx);
|
||||
|
||||
_ = cx.observe(&old_content_query, |this, _, cx| {}).detach();
|
||||
|
||||
_ = cx.observe(&new_content_query, |this, _, cx| {}).detach();
|
||||
|
||||
self.old_content_query = Some(old_content_query);
|
||||
self.new_content_query = Some(new_content_query);
|
||||
}
|
||||
}
|
||||
|
||||
impl gpui::Render for PullRequestDiffView {
|
||||
fn render(
|
||||
&mut self,
|
||||
window: &mut gpui::Window,
|
||||
cx: &mut gpui::Context<Self>,
|
||||
) -> impl gpui::IntoElement {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,12 @@ use crate::{
|
||||
text::text,
|
||||
},
|
||||
query::{self, QueryStatus, read_query, use_query},
|
||||
screen::dashboard::pull_request_diff_view::PullRequestDiffView,
|
||||
};
|
||||
|
||||
pub(crate) struct PullRequestView {
|
||||
markdown_viewer: Option<gpui::Entity<MarkdownText>>,
|
||||
diff_view: Option<gpui::Entity<PullRequestDiffView>>,
|
||||
|
||||
pull_request_query: Option<query::Entity<api::issues::FetchPullRequest>>,
|
||||
}
|
||||
@@ -27,6 +29,7 @@ struct Toolbar {}
|
||||
pub fn new(_cx: &mut gpui::Context<PullRequestView>) -> PullRequestView {
|
||||
PullRequestView {
|
||||
markdown_viewer: None,
|
||||
diff_view: None,
|
||||
pull_request_query: None,
|
||||
}
|
||||
}
|
||||
@@ -126,12 +129,9 @@ impl PullRequestView {
|
||||
}
|
||||
}
|
||||
|
||||
let merge_text = match (
|
||||
pr.author.as_ref(),
|
||||
pr.base_branch_name.as_ref(),
|
||||
pr.head_branch_name.as_ref(),
|
||||
) {
|
||||
| (Some(author), Some(base_branch), Some(head_branch)) => {
|
||||
let merge_text = pr.author.as_ref().map(|author| {
|
||||
let base_branch = pr.base_branch_name.as_str();
|
||||
let head_branch = pr.head_branch_name.as_str();
|
||||
let str = format!(
|
||||
"{} requested to merge {} into {}",
|
||||
author.login, head_branch, base_branch
|
||||
@@ -166,14 +166,11 @@ impl PullRequestView {
|
||||
),
|
||||
];
|
||||
|
||||
Some((
|
||||
(
|
||||
author,
|
||||
gpui::StyledText::new(str).with_highlights(highlights),
|
||||
))
|
||||
}
|
||||
|
||||
| _ => None,
|
||||
};
|
||||
)
|
||||
});
|
||||
|
||||
let metadata_line =
|
||||
div()
|
||||
|
||||
@@ -33,9 +33,9 @@ impl Screen {
|
||||
fn on_create(&mut self, cx: &mut gpui::Context<Self>) {
|
||||
_ = cx
|
||||
.subscribe(&self.issue_list, |this, _, event, cx| match event {
|
||||
| issue_list::Event::ItemSelected(pr_id) => {
|
||||
this.handle_issue_list_item_selected(pr_id, cx);
|
||||
}
|
||||
| issue_list::Event::ItemSelected(pr_id) => {
|
||||
this.handle_issue_list_item_selected(pr_id, cx);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
48
src/util/file.rs
Normal file
48
src/util/file.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use memchr::memchr;
|
||||
|
||||
pub(crate) enum ContentType {
|
||||
Text,
|
||||
Binary,
|
||||
}
|
||||
|
||||
pub(crate) struct ContentDiff {
|
||||
old_content: bytes::Bytes,
|
||||
new_content: bytes::Bytes,
|
||||
}
|
||||
|
||||
pub(crate) struct LineDiff {
|
||||
old_line: Option<usize>,
|
||||
old_content_range: std::ops::Range<usize>,
|
||||
new_line: Option<usize>,
|
||||
new_content_range: std::ops::Range<usize>,
|
||||
}
|
||||
|
||||
pub(crate) fn classify_content(content: &[u8]) -> ContentType {
|
||||
if content.is_empty() {
|
||||
ContentType::Text
|
||||
} else if content.starts_with(&[0xEF, 0xBB, 0xBF]) // UTF-8
|
||||
|| content.starts_with(&[0x00, 0x00, 0xFE, 0xFF]) // UTF-32 BE
|
||||
|| content.starts_with(&[0xFF, 0xFE, 0x00, 0x00]) // UTF-32 LE
|
||||
|| content.starts_with(&[0xFE, 0xFF]) // UTF-16 BE
|
||||
|| content.starts_with(&[0xFF, 0xFE])
|
||||
{
|
||||
ContentType::Text
|
||||
} else {
|
||||
match memchr(0, &content[0..8192]) {
|
||||
| None => ContentType::Text,
|
||||
| Some(_) => ContentType::Binary,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn diff_content(old: &[u8], new: &[u8]) -> ContentDiff {
|
||||
similar::TextDiff::from_lines::<[u8]>(old, new)
|
||||
.iter_all_changes()
|
||||
.map(|change| LineDiff {
|
||||
old_line: change.old_index(),
|
||||
old_content_range: change.old_range,
|
||||
new_line: change.new_index(),
|
||||
new_content_range: change.new_range,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
pub(crate) mod file;
|
||||
pub(crate) mod timeout;
|
||||
|
||||
Reference in New Issue
Block a user