wip: pr file diffing

This commit is contained in:
2026-05-18 22:30:46 +08:00
parent aa99ba2596
commit 553af0290f
32 changed files with 1523 additions and 164 deletions

View File

@@ -14,11 +14,14 @@ paste = "1.0"
rand = "0.10.1" rand = "0.10.1"
regex = "1.12.3" regex = "1.12.3"
reqwest = { version = "0.13.2", features = ["form", "json", "query"] } reqwest = { version = "0.13.2", features = ["form", "json", "query"] }
bytes = "1.11.0"
serde = "1.0.228" serde = "1.0.228"
serde_json = "1.0.149" 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 = "0.19.5"
tree-sitter-markdown = "0.7.1" tree-sitter-markdown = "0.7.1"
memchr = "2.8.0"
[build-dependencies] [build-dependencies]
serde_json = "1.0.149" serde_json = "1.0.149"

153
build.rs
View File

@@ -133,9 +133,13 @@ fn render_assets(
fn render_github_fixtures(fixture_root: &Path) -> String { fn render_github_fixtures(fixture_root: &Path) -> String {
let user_fetch = read_json_fixture(&fixture_root.join("user.fetch.json")); 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_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 issue_fixtures = BTreeMap::<(String, u32), String>::new();
let mut pull_request_fixtures = BTreeMap::<String, 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 = let mut pull_request_timeline_fixtures =
BTreeMap::<String, BTreeMap<u32, TimelineFixturePage>>::new(); BTreeMap::<String, BTreeMap<u32, TimelineFixturePage>>::new();
let mut entries = fs::read_dir(fixture_root) let mut entries = fs::read_dir(fixture_root)
@@ -144,6 +148,14 @@ fn render_github_fixtures(fixture_root: &Path) -> String {
.collect::<Vec<_>>(); .collect::<Vec<_>>();
entries.sort_by_key(|entry| entry.file_name()); 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 { for entry in entries {
let file_name = entry let file_name = entry
.file_name() .file_name()
@@ -161,6 +173,13 @@ fn render_github_fixtures(fixture_root: &Path) -> String {
continue; 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) { if let Some((id, page)) = parse_pull_request_timeline_fixture_name(&file_name) {
let value = read_fixture_value(&entry.path()); let value = read_fixture_value(&entry.path());
pull_request_timeline_fixtures 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("}\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("\n");
output.push_str("pub fn issues_pull_request(id: &str) -> Option<&'static str> {\n"); output.push_str("pub fn issues_pull_request(id: &str) -> Option<&'static str> {\n");
output.push_str(" match id {\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("}\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("\n");
output.push_str("pub fn issues_pull_request_timeline(id: &str, after: Option<&str>) -> Option<&'static 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"); 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 } 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 { fn map_issue_fixture(issue: &serde_json::Value) -> serde_json::Value {
serde_json::json!({ serde_json::json!({
"id": issue_fixture_graphql_id(issue), "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("."))) .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)> { fn parse_issue_fixture_name(file_name: &str) -> Option<(String, u32)> {
let name = file_name.strip_suffix(".json")?; let name = file_name.strip_suffix(".json")?;
let rest = name.strip_prefix("issues.pull_requests.")?; 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)) 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 { fn string_literal(value: &str) -> String {
format!("{value:?}") format!("{value:?}")
} }

17
examples/similar_demo.rs Normal file
View 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);
}
}

View File

@@ -8,6 +8,10 @@
"avatar_url": "https://avatars.githubusercontent.com/u/5151?v=4" "avatar_url": "https://avatars.githubusercontent.com/u/5151?v=4"
}, },
"base_branch_name": "main", "base_branch_name": "main",
"base_repo_slug": "kennethnym/agent-tooling",
"base_ref": "8c79c9054e0c28b7ff4b79dd55b04c9af0d81132",
"head_branch_name": "feat/worker-context-envelope", "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" "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"
} }

View File

@@ -8,6 +8,10 @@
"avatar_url": "https://avatars.githubusercontent.com/u/6161?v=4" "avatar_url": "https://avatars.githubusercontent.com/u/6161?v=4"
}, },
"base_branch_name": "main", "base_branch_name": "main",
"base_repo_slug": "kennethnym/design-notes",
"base_ref": "7ab2d10c6f0f244ab18a28fd04c669b47e9bc611",
"head_branch_name": "chore/dashboard-spacing-scale", "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." "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."
} }

View File

@@ -8,6 +8,10 @@
"avatar_url": "https://avatars.githubusercontent.com/u/4242?v=4" "avatar_url": "https://avatars.githubusercontent.com/u/4242?v=4"
}, },
"base_branch_name": "main", "base_branch_name": "main",
"base_repo_slug": "kennethnym/infra-scripts",
"base_ref": "4c0de7c2d9c38e7ab1cfe273d03c08bbcbf71740",
"head_branch_name": "docs/manual-failover-steps", "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" "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"
} }

View File

@@ -8,6 +8,10 @@
"avatar_url": "https://avatars.githubusercontent.com/u/4242?v=4" "avatar_url": "https://avatars.githubusercontent.com/u/4242?v=4"
}, },
"base_branch_name": "main", "base_branch_name": "main",
"base_repo_slug": "kennethnym/novem",
"base_ref": "5e8745bfcc0c90c226d3c6af84226d6d4a5ec2d1",
"head_branch_name": "feat/cached-issue-pane", "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." "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."
} }

View File

@@ -8,6 +8,10 @@
"avatar_url": "https://avatars.githubusercontent.com/u/4242?v=4" "avatar_url": "https://avatars.githubusercontent.com/u/4242?v=4"
}, },
"base_branch_name": "main", "base_branch_name": "main",
"base_repo_slug": "kennethnym/novem",
"base_ref": "5e8745bfcc0c90c226d3c6af84226d6d4a5ec2d1",
"head_branch_name": "feat/cached-repo-picker", "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." "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."
} }

View File

@@ -8,6 +8,10 @@
"avatar_url": "https://avatars.githubusercontent.com/u/7171?v=4" "avatar_url": "https://avatars.githubusercontent.com/u/7171?v=4"
}, },
"base_branch_name": "main", "base_branch_name": "main",
"base_repo_slug": "kennethnym/sprint-planner",
"base_ref": "c6d7e91ef84a8ce6c14f8a06f5588d60962d0af7",
"head_branch_name": "feat/release-handoff-checklist", "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" "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"
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,6 +34,8 @@ pub(crate) struct GithubCredentials {
#[derive(Debug)] #[derive(Debug)]
pub(crate) enum Error { pub(crate) enum Error {
Unauthenticated, Unauthenticated,
NotAllowed,
DoesNotExist,
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
MissingMockFixture(String), MissingMockFixture(String),
Github(GithubError), 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> async fn parse_response<T>(res: reqwest::Response) -> Result<T, Error>
where where
T: serde::de::DeserializeOwned, T: serde::de::DeserializeOwned,

View File

@@ -7,11 +7,15 @@ query PullRequestQuery($id: ID!) {
state state
isDraft isDraft
createdAt createdAt
baseRef { baseRefName
name baseRefOid
headRefName
headRefOid
baseRepository {
nameWithOwner
} }
headRef { headRepository {
name nameWithOwner
} }
author { author {
__typename __typename

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

View File

@@ -16,6 +16,7 @@ use crate::{
type DateTime = String; type DateTime = String;
type URI = String; type URI = String;
type GitObjectID = String;
#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Deserialize)] #[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Deserialize)]
#[serde(transparent)] #[serde(transparent)]
@@ -72,8 +73,12 @@ pub(crate) struct DetailedPullRequest {
pub(crate) body: String, pub(crate) body: String,
pub(crate) created_at: Option<chrono::DateTime<chrono::FixedOffset>>, pub(crate) created_at: Option<chrono::DateTime<chrono::FixedOffset>>,
pub(crate) author: Option<super::user::Actor>, pub(crate) author: Option<super::user::Actor>,
pub(crate) base_branch_name: Option<String>, pub(crate) base_branch_name: String,
pub(crate) head_branch_name: Option<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)] #[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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TimelineActor { pub(crate) struct TimelineActor {
pub(crate) kind: String, pub(crate) kind: String,
@@ -249,6 +282,15 @@ struct PullRequestQuery;
)] )]
struct PullRequestTimelineQuery; 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; pub(super) type PullRequestTimelineResponse = pull_request_timeline_query::ResponseData;
#[cfg(test)] #[cfg(test)]
pub(super) type PullRequestTimelineResponseNode = PullRequestTimelineQueryNode; pub(super) type PullRequestTimelineResponseNode = PullRequestTimelineQueryNode;
@@ -289,8 +331,8 @@ impl query::QueryFn for ListPullRequests {
} }
let query_string = match self.filter { let query_string = match self.filter {
| Some(filter) => format!("is:pr archived:false sort:updated-desc {}", filter), | Some(filter) => format!("is:pr archived:false sort:updated-desc {}", filter),
| None => "is:pr archived:false sort:updated-desc".into(), | None => "is:pr archived:false sort:updated-desc".into(),
}; };
let gql = let gql =
@@ -312,19 +354,19 @@ impl query::QueryFn for ListPullRequests {
.flatten() .flatten()
.filter_map(|edge| { .filter_map(|edge| {
edge.node.and_then(|n| match n { edge.node.and_then(|n| match n {
| PullRequestPaginationQuerySearchEdgesNode::PullRequest(p) => { | PullRequestPaginationQuerySearchEdgesNode::PullRequest(p) => {
Some(PullRequest { Some(PullRequest {
id: p.id.into(), id: p.id.into(),
title: p.title, title: p.title,
state: p.state, state: p.state,
is_draft: p.is_draft, is_draft: p.is_draft,
repo_slug: format!( repo_slug: format!(
"{}/{}", "{}/{}",
p.repository.owner.login, p.repository.name p.repository.owner.login, p.repository.name
), ),
}) })
} }
| _ => None, | _ => None,
}) })
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
@@ -369,36 +411,149 @@ impl query::QueryFn for FetchPullRequest {
"missing 'node' field on PullRequestQuery response".into(), "missing 'node' field on PullRequestQuery response".into(),
)) ))
.and_then(|n| match n { .and_then(|n| match n {
| PullRequestQueryNode::PullRequest(p) => { | PullRequestQueryNode::PullRequest(p) => {
let created_at = let created_at =
chrono::DateTime::parse_from_rfc3339(&p.created_at).map_err(|err| { chrono::DateTime::parse_from_rfc3339(&p.created_at).map_err(|err| {
api::Error::MalformedResponse(format!( api::Error::MalformedResponse(format!(
"invalid pull request createdAt {:?}: {err}", "invalid pull request createdAt {:?}: {err}",
p.created_at p.created_at
)) ))
})?; })?;
Ok(DetailedPullRequest { Ok(DetailedPullRequest {
title: p.title, title: p.title,
state: p.state, state: p.state,
is_draft: p.is_draft, is_draft: p.is_draft,
body: p.body, body: p.body,
author: p.author.map(|it| api::user::Actor { author: p.author.map(|it| api::user::Actor {
login: it.login, login: it.login,
avatar_url: it.avatar_url, avatar_url: it.avatar_url,
}), }),
base_branch_name: p.base_ref.map(|r| r.name), base_repo_slug: p
head_branch_name: p.head_ref.map(|r| r.name), .base_repository
created_at: Some(created_at), .map(|it| it.name_with_owner)
}) .unwrap_or_default(),
} base_branch_name: p.base_ref_name,
| _ => Err(api::Error::MalformedResponse( base_ref: p.base_ref_oid,
"unexpected node type on PullRequestQuery".into(), 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)] #[derive(Clone)]
pub(crate) struct FetchPullRequestTimeline { pub(crate) struct FetchPullRequestTimeline {
pub(crate) id: Id, pub(crate) id: Id,
@@ -406,12 +561,6 @@ pub(crate) struct FetchPullRequestTimeline {
pub(crate) after: Option<String>, 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 { impl query::QueryFn for FetchPullRequestTimeline {
type Data = PullRequestTimeline; type Data = PullRequestTimeline;
type Error = api::Error; type Error = api::Error;
@@ -437,11 +586,11 @@ impl query::QueryFn for FetchPullRequestTimeline {
TimelineActor { TimelineActor {
kind: match on { kind: match on {
| actorFieldsOn::Bot => "Bot", | actorFieldsOn::Bot => "Bot",
| actorFieldsOn::EnterpriseUserAccount => "EnterpriseUserAccount", | actorFieldsOn::EnterpriseUserAccount => "EnterpriseUserAccount",
| actorFieldsOn::Mannequin => "Mannequin", | actorFieldsOn::Mannequin => "Mannequin",
| actorFieldsOn::Organization => "Organization", | actorFieldsOn::Organization => "Organization",
| actorFieldsOn::User => "User", | actorFieldsOn::User => "User",
} }
.into(), .into(),
name: login, name: login,
@@ -451,62 +600,62 @@ impl query::QueryFn for FetchPullRequestTimeline {
fn normalize_assignee(actor: assigneeFields) -> TimelineActor { fn normalize_assignee(actor: assigneeFields) -> TimelineActor {
match actor { match actor {
| assigneeFields::Bot(actor) => TimelineActor { | assigneeFields::Bot(actor) => TimelineActor {
kind: "Bot".into(), kind: "Bot".into(),
name: actor.login, name: actor.login,
avatar_url: Some(actor.avatar_url), avatar_url: Some(actor.avatar_url),
}, },
| assigneeFields::Mannequin(actor) => TimelineActor { | assigneeFields::Mannequin(actor) => TimelineActor {
kind: "Mannequin".into(), kind: "Mannequin".into(),
name: actor.login, name: actor.login,
avatar_url: Some(actor.avatar_url), avatar_url: Some(actor.avatar_url),
}, },
| assigneeFields::Organization(actor) => TimelineActor { | assigneeFields::Organization(actor) => TimelineActor {
kind: "Organization".into(), kind: "Organization".into(),
name: actor.login, name: actor.login,
avatar_url: Some(actor.avatar_url), avatar_url: Some(actor.avatar_url),
}, },
| assigneeFields::User(actor) => TimelineActor { | assigneeFields::User(actor) => TimelineActor {
kind: "User".into(), kind: "User".into(),
name: actor.login, name: actor.login,
avatar_url: Some(actor.avatar_url), avatar_url: Some(actor.avatar_url),
}, },
} }
} }
fn normalize_requested_reviewer(actor: requestedReviewerFields) -> TimelineActor { fn normalize_requested_reviewer(actor: requestedReviewerFields) -> TimelineActor {
match actor { match actor {
| requestedReviewerFields::Bot(actor) => TimelineActor { | requestedReviewerFields::Bot(actor) => TimelineActor {
kind: "Bot".into(), kind: "Bot".into(),
name: actor.login, name: actor.login,
avatar_url: Some(actor.avatar_url), avatar_url: Some(actor.avatar_url),
}, },
| requestedReviewerFields::Mannequin(actor) => TimelineActor { | requestedReviewerFields::Mannequin(actor) => TimelineActor {
kind: "Mannequin".into(), kind: "Mannequin".into(),
name: actor.login, name: actor.login,
avatar_url: Some(actor.avatar_url), avatar_url: Some(actor.avatar_url),
}, },
| requestedReviewerFields::Team(actor) => TimelineActor { | requestedReviewerFields::Team(actor) => TimelineActor {
kind: "Team".into(), kind: "Team".into(),
name: actor.name, name: actor.name,
avatar_url: None, avatar_url: None,
}, },
| requestedReviewerFields::User(actor) => TimelineActor { | requestedReviewerFields::User(actor) => TimelineActor {
kind: "User".into(), kind: "User".into(),
name: actor.login, name: actor.login,
avatar_url: Some(actor.avatar_url), avatar_url: Some(actor.avatar_url),
}, },
} }
} }
fn normalize_review_state(state: PullRequestReviewState) -> String { fn normalize_review_state(state: PullRequestReviewState) -> String {
match state { match state {
| PullRequestReviewState::PENDING => "PENDING", | PullRequestReviewState::PENDING => "PENDING",
| PullRequestReviewState::COMMENTED => "COMMENTED", | PullRequestReviewState::COMMENTED => "COMMENTED",
| PullRequestReviewState::APPROVED => "APPROVED", | PullRequestReviewState::APPROVED => "APPROVED",
| PullRequestReviewState::CHANGES_REQUESTED => "CHANGES_REQUESTED", | PullRequestReviewState::CHANGES_REQUESTED => "CHANGES_REQUESTED",
| PullRequestReviewState::DISMISSED => "DISMISSED", | PullRequestReviewState::DISMISSED => "DISMISSED",
| _ => "OTHER", | _ => "OTHER",
} }
.into() .into()
} }
@@ -726,10 +875,10 @@ impl query::QueryFn for FetchPullRequestTimeline {
"missing 'node' field on PullRequestTimelineQuery response".into(), "missing 'node' field on PullRequestTimelineQuery response".into(),
)) ))
.and_then(|node| match node { .and_then(|node| match node {
| PullRequestTimelineQueryNode::PullRequest(pull_request) => Ok(pull_request), | PullRequestTimelineQueryNode::PullRequest(pull_request) => Ok(pull_request),
| _ => Err(api::Error::MalformedResponse( | _ => Err(api::Error::MalformedResponse(
"unexpected node type on PullRequestTimelineQuery".into(), "unexpected node type on PullRequestTimelineQuery".into(),
)), )),
})?; })?;
let timeline = pull_request.timeline_items; let timeline = pull_request.timeline_items;

View File

@@ -24,6 +24,27 @@ pub(crate) fn list_repos() -> Result<Vec<repo::Repository>, api::Error> {
parse_fixture("repo.list", repo_list()) 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( pub(crate) fn list_pull_requests(
filter: Option<&str>, filter: Option<&str>,
page: u32, page: u32,
@@ -52,6 +73,17 @@ pub(crate) fn fetch_pull_request(
parse_fixture(&format!("issues.pull_request.{id}"), json) 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( pub(crate) fn fetch_pull_request_timeline(
id: &issues::Id, id: &issues::Id,
after: Option<&str>, after: Option<&str>,
@@ -146,10 +178,10 @@ mod tests {
merged.author.as_ref().map(|author| author.login.as_str()), merged.author.as_ref().map(|author| author.login.as_str()),
Some("rorycraft") Some("rorycraft")
); );
assert_eq!(merged.base_branch_name.as_deref(), Some("main")); assert_eq!(merged.base_branch_name.as_str(), "main");
assert_eq!( assert_eq!(
merged.head_branch_name.as_deref(), merged.head_branch_name.as_str(),
Some("feat/release-handoff-checklist") "feat/release-handoff-checklist"
); );
assert_eq!( assert_eq!(
merged.created_at, merged.created_at,
@@ -167,23 +199,28 @@ mod tests {
.map(|author| author.login.as_str()), .map(|author| author.login.as_str()),
Some("kennethnym") Some("kennethnym")
); );
assert_eq!(documented_failover.base_branch_name.as_str(), "main");
assert_eq!( assert_eq!(
documented_failover.base_branch_name.as_deref(), documented_failover.head_branch_name.as_str(),
Some("main") "docs/manual-failover-steps"
);
assert_eq!(
documented_failover.head_branch_name.as_deref(),
Some("docs/manual-failover-steps")
); );
assert_eq!( assert_eq!(
documented_failover.created_at, documented_failover.created_at,
Some(chrono::DateTime::parse_from_rfc3339("2026-04-24T06:40:00Z").unwrap()) Some(chrono::DateTime::parse_from_rfc3339("2026-04-24T06:40:00Z").unwrap())
); );
assert!(dashboard_markdown.body.contains("```rust")); 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!( assert_eq!(
dashboard_markdown.head_branch_name.as_deref(), dashboard_markdown.head_branch_name.as_str(),
Some("feat/cached-issue-pane") "feat/cached-issue-pane"
);
assert_eq!(
dashboard_markdown.base_ref.as_str(),
"5e8745bfcc0c90c226d3c6af84226d6d4a5ec2d1"
);
assert_eq!(
dashboard_markdown.head_ref.as_str(),
"2bc41de7731b9ef48f7d64ee9f0d5f497dbe0a51"
); );
assert_eq!( assert_eq!(
dashboard_markdown.created_at, dashboard_markdown.created_at,
@@ -196,10 +233,18 @@ mod tests {
.map(|author| author.login.as_str()), .map(|author| author.login.as_str()),
Some("kennethnym") 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!( assert_eq!(
cached_repo_picker.head_branch_name.as_deref(), cached_repo_picker.head_branch_name.as_str(),
Some("feat/cached-repo-picker") "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!( assert_eq!(
cached_repo_picker.created_at, cached_repo_picker.created_at,
@@ -212,10 +257,10 @@ mod tests {
.map(|author| author.login.as_str()), .map(|author| author.login.as_str()),
Some("leaferiksen") 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!( assert_eq!(
worker_split.head_branch_name.as_deref(), worker_split.head_branch_name.as_str(),
Some("feat/worker-context-envelope") "feat/worker-context-envelope"
); );
assert_eq!( assert_eq!(
worker_split.created_at, worker_split.created_at,
@@ -228,10 +273,10 @@ mod tests {
.map(|author| author.login.as_str()), .map(|author| author.login.as_str()),
Some("mariahops") 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!( assert_eq!(
spacing_tokens.head_branch_name.as_deref(), spacing_tokens.head_branch_name.as_str(),
Some("chore/dashboard-spacing-scale") "chore/dashboard-spacing-scale"
); );
assert_eq!( assert_eq!(
spacing_tokens.created_at, 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] #[test]
fn pull_request_timeline_fixtures_parse() { fn pull_request_timeline_fixtures_parse() {
let first_page = fetch_pull_request_timeline(&issues::Id::from("PR_kwDOSprint62"), None) let first_page = fetch_pull_request_timeline(&issues::Id::from("PR_kwDOSprint62"), None)

View File

@@ -1,6 +1,13 @@
use futures::{FutureExt, TryFutureExt};
use reqwest::Method;
use serde::Deserialize; use serde::Deserialize;
use tokio::sync::OwnedRwLockReadGuard;
use crate::{api, query}; use crate::{
api,
query::{self, Query, fetch_query},
util::file,
};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct Repository { pub struct Repository {
@@ -22,6 +29,13 @@ pub struct Owner {
pub html_url: String, pub html_url: String,
} }
#[derive(Debug, Clone)]
pub struct FileRef {
pub repo_slug: String,
pub path: String,
pub reff: Option<String>,
}
#[derive(Clone)] #[derive(Clone)]
pub struct List; pub struct List;
@@ -50,3 +64,113 @@ impl query::QueryFn for List {
api::parse_response(res).await 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!()
}
}

View File

@@ -87,8 +87,8 @@ impl gpui::RenderOnce for Button {
let theme = app::current_theme(cx); let theme = app::current_theme(cx);
let icon_color = match self.variant { let icon_color = match self.variant {
| Variant::Primary => theme.colors.accent_on_solid, | Variant::Primary => theme.colors.accent_on_solid,
| Variant::Secondary => theme.colors.text, | Variant::Secondary => theme.colors.text,
}; };
let mut children: Vec<AnyElement> = Vec::with_capacity(3); let mut children: Vec<AnyElement> = Vec::with_capacity(3);

108
src/component/code_view.rs Normal file
View 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)
}
}

View File

@@ -1,4 +1,5 @@
pub(crate) mod button; pub(crate) mod button;
pub(crate) mod code_view;
pub(crate) mod font_icon; pub(crate) mod font_icon;
pub(crate) mod markdown; pub(crate) mod markdown;
pub(crate) mod text; pub(crate) mod text;

View File

@@ -148,8 +148,8 @@ impl gpui::RenderOnce for IssueListItem {
} }
let repo_name_text = match self.repo_name { let repo_name_text = match self.repo_name {
| Some(name) => text(name), | Some(name) => text(name),
| None => text("Unknown repo"), | None => text("Unknown repo"),
} }
.text_xs() .text_xs()
.opacity(0.5); .opacity(0.5);
@@ -162,21 +162,21 @@ impl gpui::RenderOnce for IssueListItem {
.bg(theme.colors.surface) .bg(theme.colors.surface)
} else { } else {
match self.status { match self.status {
| api::issues::PullRequestState::Closed => pill( | api::issues::PullRequestState::Closed => pill(
text("Closed").text_color(theme.colors.danger_on_solid), text("Closed").text_color(theme.colors.danger_on_solid),
font_icon(FontIcon::PullRequestClosed).text_color(theme.colors.danger_on_solid), font_icon(FontIcon::PullRequestClosed).text_color(theme.colors.danger_on_solid),
) )
.bg(theme.colors.danger_solid), .bg(theme.colors.danger_solid),
| api::issues::PullRequestState::Merged => pill( | api::issues::PullRequestState::Merged => pill(
text("Merged").text_color(theme.colors.accent_on_solid), text("Merged").text_color(theme.colors.accent_on_solid),
font_icon(FontIcon::PullRequestClosed).text_color(theme.colors.accent_on_solid), font_icon(FontIcon::PullRequestClosed).text_color(theme.colors.accent_on_solid),
) )
.bg(theme.colors.accent_solid), .bg(theme.colors.accent_solid),
| _ => pill( | _ => pill(
text("Open").text_color(theme.colors.success_on_solid), text("Open").text_color(theme.colors.success_on_solid),
font_icon(FontIcon::PullRequestArrow).text_color(theme.colors.success_on_solid), font_icon(FontIcon::PullRequestArrow).text_color(theme.colors.success_on_solid),
) )
.bg(theme.colors.success_solid), .bg(theme.colors.success_solid),
} }
}; };

View File

@@ -1,4 +1,5 @@
mod issue_list; mod issue_list;
mod pull_request_diff_view;
mod pull_request_view; mod pull_request_view;
mod screen; mod screen;
mod sidebar; mod sidebar;

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

View File

@@ -13,10 +13,12 @@ use crate::{
text::text, text::text,
}, },
query::{self, QueryStatus, read_query, use_query}, query::{self, QueryStatus, read_query, use_query},
screen::dashboard::pull_request_diff_view::PullRequestDiffView,
}; };
pub(crate) struct PullRequestView { pub(crate) struct PullRequestView {
markdown_viewer: Option<gpui::Entity<MarkdownText>>, markdown_viewer: Option<gpui::Entity<MarkdownText>>,
diff_view: Option<gpui::Entity<PullRequestDiffView>>,
pull_request_query: Option<query::Entity<api::issues::FetchPullRequest>>, pull_request_query: Option<query::Entity<api::issues::FetchPullRequest>>,
} }
@@ -27,6 +29,7 @@ struct Toolbar {}
pub fn new(_cx: &mut gpui::Context<PullRequestView>) -> PullRequestView { pub fn new(_cx: &mut gpui::Context<PullRequestView>) -> PullRequestView {
PullRequestView { PullRequestView {
markdown_viewer: None, markdown_viewer: None,
diff_view: None,
pull_request_query: None, pull_request_query: None,
} }
} }
@@ -126,12 +129,9 @@ impl PullRequestView {
} }
} }
let merge_text = match ( let merge_text = pr.author.as_ref().map(|author| {
pr.author.as_ref(), let base_branch = pr.base_branch_name.as_str();
pr.base_branch_name.as_ref(), let head_branch = pr.head_branch_name.as_str();
pr.head_branch_name.as_ref(),
) {
| (Some(author), Some(base_branch), Some(head_branch)) => {
let str = format!( let str = format!(
"{} requested to merge {} into {}", "{} requested to merge {} into {}",
author.login, head_branch, base_branch author.login, head_branch, base_branch
@@ -166,14 +166,11 @@ impl PullRequestView {
), ),
]; ];
Some(( (
author, author,
gpui::StyledText::new(str).with_highlights(highlights), gpui::StyledText::new(str).with_highlights(highlights),
)) )
} });
| _ => None,
};
let metadata_line = let metadata_line =
div() div()

View File

@@ -33,9 +33,9 @@ impl Screen {
fn on_create(&mut self, cx: &mut gpui::Context<Self>) { fn on_create(&mut self, cx: &mut gpui::Context<Self>) {
_ = cx _ = cx
.subscribe(&self.issue_list, |this, _, event, cx| match event { .subscribe(&self.issue_list, |this, _, event, cx| match event {
| issue_list::Event::ItemSelected(pr_id) => { | issue_list::Event::ItemSelected(pr_id) => {
this.handle_issue_list_item_selected(pr_id, cx); this.handle_issue_list_item_selected(pr_id, cx);
} }
}) })
.detach(); .detach();
} }

48
src/util/file.rs Normal file
View 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()
}

View File

@@ -1 +1,2 @@
pub(crate) mod file;
pub(crate) mod timeout; pub(crate) mod timeout;