wip: pr file diffing
This commit is contained in:
@@ -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
153
build.rs
@@ -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
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"
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)]
|
#[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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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 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;
|
||||||
|
|||||||
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())
|
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)
|
||||||
|
|||||||
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 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!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
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 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;
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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,
|
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()
|
||||||
|
|||||||
@@ -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
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;
|
pub(crate) mod timeout;
|
||||||
|
|||||||
Reference in New Issue
Block a user