From 553af0290ff23bf84e9c503cd8677b8e0a59f640 Mon Sep 17 00:00:00 2001 From: Kenneth Date: Mon, 18 May 2026 22:30:46 +0800 Subject: [PATCH] wip: pr file diffing --- Cargo.toml | 5 +- build.rs | 153 ++++++++ examples/similar_demo.rs | 17 + .../issues.pull_request.PR_kwDOAgent47.json | 4 + .../issues.pull_request.PR_kwDODesign31.json | 4 + .../issues.pull_request.PR_kwDOInfra19.json | 4 + .../issues.pull_request.PR_kwDONovem84.json | 4 + .../issues.pull_request.PR_kwDONovem85.json | 4 + .../issues.pull_request.PR_kwDOSprint62.json | 4 + ...pull_request_file_tree.PR_kwDONovem84.json | 29 ++ .../src/api/repo.rs | 73 ++++ .../src/query.rs | 82 ++++ .../src/screen/dashboard/issue_list.rs | 130 +++++++ .../src/api/repo.rs | 33 ++ .../src/query.rs | 56 +++ .../src/screen/dashboard/issue_list.rs | 75 ++++ src/api.rs | 14 + src/api/graphql/fetch_pull_request.graphql | 12 +- .../fetch_pull_request_file_tree.graphql | 19 + src/api/issues.rs | 355 +++++++++++++----- src/api/mock.rs | 181 +++++++-- src/api/repo.rs | 126 ++++++- src/component/button.rs | 4 +- src/component/code_view.rs | 108 ++++++ src/component/mod.rs | 1 + src/screen/dashboard/issue_list.rs | 34 +- src/screen/dashboard/mod.rs | 1 + .../dashboard/pull_request_diff_view.rs | 79 ++++ src/screen/dashboard/pull_request_view.rs | 21 +- src/screen/dashboard/screen.rs | 6 +- src/util/file.rs | 48 +++ src/util/mod.rs | 1 + 32 files changed, 1523 insertions(+), 164 deletions(-) create mode 100644 examples/similar_demo.rs create mode 100644 fixtures/github/issues.pull_request_file_tree.PR_kwDONovem84.json create mode 100644 fixtures/github/repo.file_content/kennethnym/novem/13af7d0b48a6ce0b22d48c9b6c1c78dfcd94e6a0/src/api/repo.rs create mode 100644 fixtures/github/repo.file_content/kennethnym/novem/2bc41de7731b9ef48f7d64ee9f0d5f497dbe0a51/src/query.rs create mode 100644 fixtures/github/repo.file_content/kennethnym/novem/2bc41de7731b9ef48f7d64ee9f0d5f497dbe0a51/src/screen/dashboard/issue_list.rs create mode 100644 fixtures/github/repo.file_content/kennethnym/novem/5e8745bfcc0c90c226d3c6af84226d6d4a5ec2d1/src/api/repo.rs create mode 100644 fixtures/github/repo.file_content/kennethnym/novem/5e8745bfcc0c90c226d3c6af84226d6d4a5ec2d1/src/query.rs create mode 100644 fixtures/github/repo.file_content/kennethnym/novem/5e8745bfcc0c90c226d3c6af84226d6d4a5ec2d1/src/screen/dashboard/issue_list.rs create mode 100644 src/api/graphql/fetch_pull_request_file_tree.graphql create mode 100644 src/component/code_view.rs create mode 100644 src/screen/dashboard/pull_request_diff_view.rs create mode 100644 src/util/file.rs diff --git a/Cargo.toml b/Cargo.toml index 55c7f35..be8d330 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,11 +14,14 @@ paste = "1.0" rand = "0.10.1" regex = "1.12.3" reqwest = { version = "0.13.2", features = ["form", "json", "query"] } +bytes = "1.11.0" serde = "1.0.228" serde_json = "1.0.149" -tokio = { version = "1.52.1", features = ["rt-multi-thread", "net", "time"] } +similar = { version = "2", features = ["bytes"] } +tokio = { version = "1.52.1", features = ["rt-multi-thread", "net", "time", "macros"] } tree-sitter = "0.19.5" tree-sitter-markdown = "0.7.1" +memchr = "2.8.0" [build-dependencies] serde_json = "1.0.149" diff --git a/build.rs b/build.rs index 473fb70..0a9333b 100644 --- a/build.rs +++ b/build.rs @@ -133,9 +133,13 @@ fn render_assets( fn render_github_fixtures(fixture_root: &Path) -> String { let user_fetch = read_json_fixture(&fixture_root.join("user.fetch.json")); let repo_list = read_json_fixture(&fixture_root.join("repo.list.json")); + let repo_file_content_root = fixture_root.join("repo.file_content"); let mut issue_fixtures = BTreeMap::<(String, u32), String>::new(); let mut pull_request_fixtures = BTreeMap::::new(); + let mut pull_request_file_tree_fixtures = BTreeMap::::new(); + let mut repo_file_content_fixtures = + BTreeMap::<(String, String, String, Option), String>::new(); let mut pull_request_timeline_fixtures = BTreeMap::>::new(); let mut entries = fs::read_dir(fixture_root) @@ -144,6 +148,14 @@ fn render_github_fixtures(fixture_root: &Path) -> String { .collect::>(); entries.sort_by_key(|entry| entry.file_name()); + if repo_file_content_root.exists() { + collect_repo_file_content_fixtures( + &repo_file_content_root, + &repo_file_content_root, + &mut repo_file_content_fixtures, + ); + } + for entry in entries { let file_name = entry .file_name() @@ -161,6 +173,13 @@ fn render_github_fixtures(fixture_root: &Path) -> String { continue; } + if let Some(id) = parse_pull_request_file_tree_fixture_name(&file_name) { + let value = read_fixture_value(&entry.path()); + pull_request_file_tree_fixtures + .insert(id, read_pull_request_file_tree_fixture(&value, &entry.path())); + continue; + } + if let Some((id, page)) = parse_pull_request_timeline_fixture_name(&file_name) { let value = read_fixture_value(&entry.path()); pull_request_timeline_fixtures @@ -199,6 +218,35 @@ fn render_github_fixtures(fixture_root: &Path) -> String { output.push_str(" }\n"); output.push_str("}\n"); + output.push_str("\n"); + output.push_str( + "pub fn repo_file_content(owner: &str, repo: &str, path: &str, reff: Option<&str>) -> Option<&'static str> {\n", + ); + output.push_str(" match (owner, repo, path, reff) {\n"); + for ((owner, repo, path, reff), content) in repo_file_content_fixtures { + output.push_str(" ("); + output.push_str(&string_literal(&owner)); + output.push_str(", "); + output.push_str(&string_literal(&repo)); + output.push_str(", "); + output.push_str(&string_literal(&path)); + output.push_str(", "); + match reff { + | Some(reff) => { + output.push_str("Some("); + output.push_str(&string_literal(&reff)); + output.push(')'); + } + | None => output.push_str("None"), + } + output.push_str(") => Some("); + output.push_str(&string_literal(&content)); + output.push_str("),\n"); + } + output.push_str(" _ => None,\n"); + output.push_str(" }\n"); + output.push_str("}\n"); + output.push_str("\n"); output.push_str("pub fn issues_pull_request(id: &str) -> Option<&'static str> {\n"); output.push_str(" match id {\n"); @@ -213,6 +261,20 @@ fn render_github_fixtures(fixture_root: &Path) -> String { output.push_str(" }\n"); output.push_str("}\n"); + output.push_str("\n"); + output.push_str("pub fn issues_pull_request_file_tree(id: &str) -> Option<&'static str> {\n"); + output.push_str(" match id {\n"); + for (id, json) in pull_request_file_tree_fixtures { + output.push_str(" "); + output.push_str(&string_literal(&id)); + output.push_str(" => Some("); + output.push_str(&string_literal(&json)); + output.push_str("),\n"); + } + output.push_str(" _ => None,\n"); + output.push_str(" }\n"); + output.push_str("}\n"); + output.push_str("\n"); output.push_str("pub fn issues_pull_request_timeline(id: &str, after: Option<&str>) -> Option<&'static str> {\n"); output.push_str(" match (id, after) {\n"); @@ -322,6 +384,28 @@ fn read_timeline_fixture(value: &serde_json::Value, path: &Path) -> TimelineFixt TimelineFixturePage { json, end_cursor } } +fn read_pull_request_file_tree_fixture(value: &serde_json::Value, path: &Path) -> String { + if !matches!( + value + .get("node") + .and_then(|node| node.get("files")) + .and_then(|files| files.get("edges")), + Some(serde_json::Value::Array(_)) + ) { + panic!( + "pull request file tree fixture {} must include node.files.edges", + path.display() + ); + } + + serde_json::to_string(value).unwrap_or_else(|err| { + panic!( + "failed to serialize pull request file tree fixture {}: {err}", + path.display() + ) + }) +} + fn map_issue_fixture(issue: &serde_json::Value) -> serde_json::Value { serde_json::json!({ "id": issue_fixture_graphql_id(issue), @@ -371,6 +455,69 @@ fn required_string<'a>(value: &'a serde_json::Value, path: &[&str]) -> &'a str { .unwrap_or_else(|| panic!("expected string at {} in fixture", path.join("."))) } +fn collect_repo_file_content_fixtures( + root: &Path, + dir: &Path, + fixtures: &mut BTreeMap<(String, String, String, Option), String>, +) { + 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::>(); + 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::>(); + + if parts.len() < 4 { + panic!( + "repo file content fixture {} must be nested as owner/repo/ref/path", + path.display() + ); + } + + let owner = parts[0].clone(); + let repo = parts[1].clone(); + let reff = match parts[2].as_str() { + | "@default" => None, + | value => Some(value.to_owned()), + }; + let virtual_path = parts[3..].join("/"); + let content = fs::read_to_string(&path).unwrap_or_else(|err| { + panic!( + "failed to read repo file content fixture {}: {err}", + path.display() + ) + }); + + if fixtures + .insert((owner, repo, virtual_path, reff), content) + .is_some() + { + panic!("duplicate repo file content fixture for {}", path.display()); + } + } +} + fn parse_issue_fixture_name(file_name: &str) -> Option<(String, u32)> { let name = file_name.strip_suffix(".json")?; let rest = name.strip_prefix("issues.pull_requests.")?; @@ -392,6 +539,12 @@ fn parse_pull_request_timeline_fixture_name(file_name: &str) -> Option<(String, Some((id.to_owned(), page)) } +fn parse_pull_request_file_tree_fixture_name(file_name: &str) -> Option { + let name = file_name.strip_suffix(".json")?; + name.strip_prefix("issues.pull_request_file_tree.") + .map(str::to_owned) +} + fn string_literal(value: &str) -> String { format!("{value:?}") } diff --git a/examples/similar_demo.rs b/examples/similar_demo.rs new file mode 100644 index 0000000..a3021bf --- /dev/null +++ b/examples/similar_demo.rs @@ -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); + } +} diff --git a/fixtures/github/issues.pull_request.PR_kwDOAgent47.json b/fixtures/github/issues.pull_request.PR_kwDOAgent47.json index fa21b22..9e06a6c 100644 --- a/fixtures/github/issues.pull_request.PR_kwDOAgent47.json +++ b/fixtures/github/issues.pull_request.PR_kwDOAgent47.json @@ -8,6 +8,10 @@ "avatar_url": "https://avatars.githubusercontent.com/u/5151?v=4" }, "base_branch_name": "main", + "base_repo_slug": "kennethnym/agent-tooling", + "base_ref": "8c79c9054e0c28b7ff4b79dd55b04c9af0d81132", "head_branch_name": "feat/worker-context-envelope", + "head_repo_slug": "kennethnym/agent-tooling", + "head_ref": "4a8df12be732c0f9e5d194cd2af7430c0d2fb8d4", "body": "## Goal\n\nSplit context loading from execution workers so delegation stays predictable while this pull request is still in draft.\n\n### Why\n- workers should receive a compact payload\n- prompt packing should be testable without spawning a worker\n- retry policy should stay in one place\n\n### Proposed flow\n1. Load repository context once.\n2. Normalize file excerpts and metadata.\n3. Hand workers a stable execution envelope.\n\n```text\nContextLoader -> PromptAssembler -> WorkerRunner\n```\n\n> Draft status stays until we decide whether token counts belong in the worker response.\n\n### Questions\n- Should `ContextLoader` expose cache hit metrics?\n- Should worker retries carry the same prompt hash?\n- [ ] Add a regression test for interrupted workers" } diff --git a/fixtures/github/issues.pull_request.PR_kwDODesign31.json b/fixtures/github/issues.pull_request.PR_kwDODesign31.json index 44c0d25..2b9af72 100644 --- a/fixtures/github/issues.pull_request.PR_kwDODesign31.json +++ b/fixtures/github/issues.pull_request.PR_kwDODesign31.json @@ -8,6 +8,10 @@ "avatar_url": "https://avatars.githubusercontent.com/u/6161?v=4" }, "base_branch_name": "main", + "base_repo_slug": "kennethnym/design-notes", + "base_ref": "7ab2d10c6f0f244ab18a28fd04c669b47e9bc611", "head_branch_name": "chore/dashboard-spacing-scale", + "head_repo_slug": "kennethnym/design-notes", + "head_ref": "5b0cf338ec46d581af0d582da6427a3dfbce9018", "body": "## Summary\n\nTightens the dashboard spacing scale before the next visual refresh.\n\n### Updated tokens\n- `space.3` for compact sidebar gaps\n- `space.5` for section rhythm\n- `space.8` for page-level separation\n\n| Surface | Before | After |\n| --- | --- | --- |\n| Sidebar section gap | `space.6` | `space.5` |\n| Filter row padding | `space.4` | `space.3` |\n| Dashboard gutter | `space.7` | `space.6` |\n\n### Review notes\n- verify heading baselines still align with list content\n- compare 1280px and 1440px screenshots side by side\n- [ ] revisit compact mode once the nav collapse lands\n\n**Design intent:** make dense screens feel more deliberate without looking cramped." } diff --git a/fixtures/github/issues.pull_request.PR_kwDOInfra19.json b/fixtures/github/issues.pull_request.PR_kwDOInfra19.json index 9ebcc12..1d45af2 100644 --- a/fixtures/github/issues.pull_request.PR_kwDOInfra19.json +++ b/fixtures/github/issues.pull_request.PR_kwDOInfra19.json @@ -8,6 +8,10 @@ "avatar_url": "https://avatars.githubusercontent.com/u/4242?v=4" }, "base_branch_name": "main", + "base_repo_slug": "kennethnym/infra-scripts", + "base_ref": "4c0de7c2d9c38e7ab1cfe273d03c08bbcbf71740", "head_branch_name": "docs/manual-failover-steps", + "head_repo_slug": "kennethnym/infra-scripts", + "head_ref": "6fd11baf0d9d53d18f6d7b7dc265d9b09e6f4217", "body": "## Context\n\nDocuments the manual failover sequence for the staging stack while the automated recovery path is still unstable.\n\n### Draft runbook\n1. Put the primary deployment in maintenance mode.\n2. Promote the standby database.\n3. Repoint the app workers.\n4. Warm the cache before reopening traffic.\n\n```bash\n./scripts/failover promote-standby --env staging\n./scripts/failover repoint-workers --env staging\n./scripts/failover verify --env staging\n```\n\n> This pull request was closed because the final DNS validation steps were still changing underneath the runbook.\n\n### Remaining gaps\n- secrets rotation is still manual\n- rollback screenshots are missing\n- [ ] add the final post-cutover checklist" } diff --git a/fixtures/github/issues.pull_request.PR_kwDONovem84.json b/fixtures/github/issues.pull_request.PR_kwDONovem84.json index 019ad0e..3748cf5 100644 --- a/fixtures/github/issues.pull_request.PR_kwDONovem84.json +++ b/fixtures/github/issues.pull_request.PR_kwDONovem84.json @@ -8,6 +8,10 @@ "avatar_url": "https://avatars.githubusercontent.com/u/4242?v=4" }, "base_branch_name": "main", + "base_repo_slug": "kennethnym/novem", + "base_ref": "5e8745bfcc0c90c226d3c6af84226d6d4a5ec2d1", "head_branch_name": "feat/cached-issue-pane", + "head_repo_slug": "kennethnym/novem", + "head_ref": "2bc41de7731b9ef48f7d64ee9f0d5f497dbe0a51", "body": "## Summary\n\nHydrates the dashboard issue pane from cached query state so selection and scroll position stay stable during refetches.\n\n### Rendering coverage\n- [x] headings\n- [x] bullet lists\n- [x] task list items\n- [x] inline code like `use_query`\n- [x] tables\n\n### Implementation sketch\n```rust\nlet cached = query_store.read(key);\nlet selection = cached.and_then(|data| data.selected_issue_id.clone());\n```\n\n| Case | Expected behavior |\n| --- | --- |\n| Cache hit | Keep the current selection pinned |\n| Cache miss | Fall back to the first visible item |\n| Refetch in flight | Preserve scroll position |\n\n### Follow-up\n- [ ] mirror the same cache behavior in the pull request detail pane\n- [ ] add a smoke test around keyboard navigation during refetch\n\nSee also the [query store](src/query.rs) integration notes." } diff --git a/fixtures/github/issues.pull_request.PR_kwDONovem85.json b/fixtures/github/issues.pull_request.PR_kwDONovem85.json index 965d5df..83ec988 100644 --- a/fixtures/github/issues.pull_request.PR_kwDONovem85.json +++ b/fixtures/github/issues.pull_request.PR_kwDONovem85.json @@ -8,6 +8,10 @@ "avatar_url": "https://avatars.githubusercontent.com/u/4242?v=4" }, "base_branch_name": "main", + "base_repo_slug": "kennethnym/novem", + "base_ref": "5e8745bfcc0c90c226d3c6af84226d6d4a5ec2d1", "head_branch_name": "feat/cached-repo-picker", + "head_repo_slug": "kennethnym/novem", + "head_ref": "13af7d0b48a6ce0b22d48c9b6c1c78dfcd94e6a0", "body": "## Summary\n\nIntroduces a cached repository query so the titlebar picker can switch context without hitting GitHub on every open.\n\n### Why\n- reduces flicker while the picker opens\n- keeps recent repositories visible during short reconnects\n- avoids duplicate requests when the titlebar rerenders\n\n### Cache rules\n- explicit refresh invalidates the cached list\n- fresh network data still wins when available\n- empty responses should not overwrite a warm cache\n\n```text\nopen picker -> read cache -> render immediately -> refresh in background\n```\n\n### Follow-up\n1. Measure cache hit rate in debug builds.\n2. Add eviction telemetry.\n3. [ ] Consider persisting the last successful repository list across launches." } diff --git a/fixtures/github/issues.pull_request.PR_kwDOSprint62.json b/fixtures/github/issues.pull_request.PR_kwDOSprint62.json index 7a2f60e..703df91 100644 --- a/fixtures/github/issues.pull_request.PR_kwDOSprint62.json +++ b/fixtures/github/issues.pull_request.PR_kwDOSprint62.json @@ -8,6 +8,10 @@ "avatar_url": "https://avatars.githubusercontent.com/u/7171?v=4" }, "base_branch_name": "main", + "base_repo_slug": "kennethnym/sprint-planner", + "base_ref": "c6d7e91ef84a8ce6c14f8a06f5588d60962d0af7", "head_branch_name": "feat/release-handoff-checklist", + "head_repo_slug": "kennethnym/sprint-planner", + "head_ref": "be7a8114a57f3e9d214cb9af457c10fd6c5a0b21", "body": "## Release handoff checklist\n\nAdds the release checklist views and closes the loop for the May rollout.\n\n### Included\n- launch readiness checklist for QA, docs, and release engineering\n- handoff status badges in the weekly planner\n- empty-state copy for weeks without a scheduled release\n\n| Stage | Owner | Status |\n| --- | --- | --- |\n| QA sign-off | `@mariahops` | Done |\n| Docs publish | `@rorycraft` | Done |\n| Release window confirm | `@kennethnym` | Done |\n\n### Verification\n1. Open a release week and confirm checklist sections render in order.\n2. Mark each handoff item complete and confirm the summary badge updates.\n3. Review the planner on a narrow viewport.\n\n> The merged version intentionally keeps the checklist readable even when one section has no pending items.\n\n- [x] QA sign-off state is visible\n- [x] Docs handoff state is visible\n- [ ] Add screenshot coverage for the compact layout" } diff --git a/fixtures/github/issues.pull_request_file_tree.PR_kwDONovem84.json b/fixtures/github/issues.pull_request_file_tree.PR_kwDONovem84.json new file mode 100644 index 0000000..a60f911 --- /dev/null +++ b/fixtures/github/issues.pull_request_file_tree.PR_kwDONovem84.json @@ -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" + } + } + ] + } + } +} diff --git a/fixtures/github/repo.file_content/kennethnym/novem/13af7d0b48a6ce0b22d48c9b6c1c78dfcd94e6a0/src/api/repo.rs b/fixtures/github/repo.file_content/kennethnym/novem/13af7d0b48a6ce0b22d48c9b6c1c78dfcd94e6a0/src/api/repo.rs new file mode 100644 index 0000000..969e17c --- /dev/null +++ b/fixtures/github/repo.file_content/kennethnym/novem/13af7d0b48a6ce0b22d48c9b6c1c78dfcd94e6a0/src/api/repo.rs @@ -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, + 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, +} + +pub fn score_repositories(repos: Vec, needle: &str) -> Vec { + 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::>(); + + 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}"), + } +} diff --git a/fixtures/github/repo.file_content/kennethnym/novem/2bc41de7731b9ef48f7d64ee9f0d5f497dbe0a51/src/query.rs b/fixtures/github/repo.file_content/kennethnym/novem/2bc41de7731b9ef48f7d64ee9f0d5f497dbe0a51/src/query.rs new file mode 100644 index 0000000..039e2e3 --- /dev/null +++ b/fixtures/github/repo.file_content/kennethnym/novem/2bc41de7731b9ef48f7d64ee9f0d5f497dbe0a51/src/query.rs @@ -0,0 +1,82 @@ +use std::{collections::HashMap, time::SystemTime}; + +#[derive(Clone, Default)] +pub struct CachedQueryState { + pub selected_issue_id: Option, + pub selected_pull_request_id: Option, + pub scroll_anchor: Option, + pub scroll_offset: usize, + pub stale_at: Option, +} + +#[derive(Default)] +pub struct QueryStore { + entries: HashMap, +} + +impl QueryStore { + pub fn remember_issue_state( + &mut self, + key: impl Into, + selected_issue_id: Option, + selected_pull_request_id: Option, + scroll_anchor: Option, + 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 { + 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 { + self.entries.remove(key) + } +} + +pub fn reconcile_selected_issue( + cached: Option<&CachedQueryState>, + visible_ids: &[String], +) -> Option { + 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 { + 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 +} diff --git a/fixtures/github/repo.file_content/kennethnym/novem/2bc41de7731b9ef48f7d64ee9f0d5f497dbe0a51/src/screen/dashboard/issue_list.rs b/fixtures/github/repo.file_content/kennethnym/novem/2bc41de7731b9ef48f7d64ee9f0d5f497dbe0a51/src/screen/dashboard/issue_list.rs new file mode 100644 index 0000000..7ca3db6 --- /dev/null +++ b/fixtures/github/repo.file_content/kennethnym/novem/2bc41de7731b9ef48f7d64ee9f0d5f497dbe0a51/src/screen/dashboard/issue_list.rs @@ -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, + + list_state: gpui::ListState, + list_items: Vec, + 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, + title: gpui::SharedString, + description: Option, + status: api::issues::PullRequestState, + is_selected: bool, + is_last: bool, + is_draft: bool, +} + +pub(crate) fn new(cx: &mut gpui::Context) -> 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) { + 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) { + 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, + ) -> 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 for IssueList {} diff --git a/fixtures/github/repo.file_content/kennethnym/novem/5e8745bfcc0c90c226d3c6af84226d6d4a5ec2d1/src/api/repo.rs b/fixtures/github/repo.file_content/kennethnym/novem/5e8745bfcc0c90c226d3c6af84226d6d4a5ec2d1/src/api/repo.rs new file mode 100644 index 0000000..5c1ca97 --- /dev/null +++ b/fixtures/github/repo.file_content/kennethnym/novem/5e8745bfcc0c90c226d3c6af84226d6d4a5ec2d1/src/api/repo.rs @@ -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, + 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, needle: &str) -> Vec { + 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() +} diff --git a/fixtures/github/repo.file_content/kennethnym/novem/5e8745bfcc0c90c226d3c6af84226d6d4a5ec2d1/src/query.rs b/fixtures/github/repo.file_content/kennethnym/novem/5e8745bfcc0c90c226d3c6af84226d6d4a5ec2d1/src/query.rs new file mode 100644 index 0000000..c1a4fac --- /dev/null +++ b/fixtures/github/repo.file_content/kennethnym/novem/5e8745bfcc0c90c226d3c6af84226d6d4a5ec2d1/src/query.rs @@ -0,0 +1,56 @@ +use std::collections::HashMap; + +#[derive(Clone, Default)] +pub struct CachedSelection { + pub issue_id: Option, + pub scroll_offset: usize, +} + +#[derive(Default)] +pub struct QueryStore { + entries: HashMap, +} + +impl QueryStore { + pub fn snapshot(&self, key: &str) -> Option { + self.entries.get(key).cloned() + } + + pub fn remember( + &mut self, + key: impl Into, + issue_id: Option, + 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 { + 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 +} diff --git a/fixtures/github/repo.file_content/kennethnym/novem/5e8745bfcc0c90c226d3c6af84226d6d4a5ec2d1/src/screen/dashboard/issue_list.rs b/fixtures/github/repo.file_content/kennethnym/novem/5e8745bfcc0c90c226d3c6af84226d6d4a5ec2d1/src/screen/dashboard/issue_list.rs new file mode 100644 index 0000000..9dcfab2 --- /dev/null +++ b/fixtures/github/repo.file_content/kennethnym/novem/5e8745bfcc0c90c226d3c6af84226d6d4a5ec2d1/src/screen/dashboard/issue_list.rs @@ -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, + list_state: gpui::ListState, + list_items: Vec, +} + +#[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 { + 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) { + 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, + ) -> impl gpui::IntoElement { + list(self.list_state.clone(), move |_, _, _| { + div().child(text("pull request row")).into_any_element() + }) + .size_full() + } +} diff --git a/src/api.rs b/src/api.rs index f35bd9b..01df1e8 100644 --- a/src/api.rs +++ b/src/api.rs @@ -34,6 +34,8 @@ pub(crate) struct GithubCredentials { #[derive(Debug)] pub(crate) enum Error { Unauthenticated, + NotAllowed, + DoesNotExist, #[cfg(debug_assertions)] MissingMockFixture(String), Github(GithubError), @@ -104,6 +106,18 @@ impl From for Error { } } +async fn raw_content(res: reqwest::Response) -> Result { + 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(res: reqwest::Response) -> Result where T: serde::de::DeserializeOwned, diff --git a/src/api/graphql/fetch_pull_request.graphql b/src/api/graphql/fetch_pull_request.graphql index 5ff81fe..36bba12 100644 --- a/src/api/graphql/fetch_pull_request.graphql +++ b/src/api/graphql/fetch_pull_request.graphql @@ -7,11 +7,15 @@ query PullRequestQuery($id: ID!) { state isDraft createdAt - baseRef { - name + baseRefName + baseRefOid + headRefName + headRefOid + baseRepository { + nameWithOwner } - headRef { - name + headRepository { + nameWithOwner } author { __typename diff --git a/src/api/graphql/fetch_pull_request_file_tree.graphql b/src/api/graphql/fetch_pull_request_file_tree.graphql new file mode 100644 index 0000000..b938170 --- /dev/null +++ b/src/api/graphql/fetch_pull_request_file_tree.graphql @@ -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 + } + } + } + } + } +} diff --git a/src/api/issues.rs b/src/api/issues.rs index 5488145..efa444c 100644 --- a/src/api/issues.rs +++ b/src/api/issues.rs @@ -16,6 +16,7 @@ use crate::{ type DateTime = String; type URI = String; +type GitObjectID = String; #[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Deserialize)] #[serde(transparent)] @@ -72,8 +73,12 @@ pub(crate) struct DetailedPullRequest { pub(crate) body: String, pub(crate) created_at: Option>, pub(crate) author: Option, - pub(crate) base_branch_name: Option, - pub(crate) head_branch_name: Option, + pub(crate) base_branch_name: String, + pub(crate) base_repo_slug: String, + pub(crate) base_ref: String, + pub(crate) head_branch_name: String, + pub(crate) head_ref: String, + pub(crate) head_repo_slug: String, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -205,6 +210,34 @@ pub(crate) enum PullRequestTimelineItem { }, } +pub(crate) struct ChangedFile { + pub(crate) cursor: String, + pub(crate) change_type: ChangeType, + pub(crate) additions: i64, + pub(crate) deletions: i64, + pub(crate) path: String, + pub(crate) viewer_viewed_state: FileViewedState, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub(crate) enum FileViewedState { + Dismissed, + Viewed, + Unviewed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub(crate) enum ChangeType { + Added, + Modified, + Deleted, + Renamed, + Copied, + Changed, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct TimelineActor { pub(crate) kind: String, @@ -249,6 +282,15 @@ struct PullRequestQuery; )] struct PullRequestTimelineQuery; +#[derive(graphql_client::GraphQLQuery)] +#[graphql( + schema_path = "src/api/graphql/schema.json", + query_path = "src/api/graphql/fetch_pull_request_file_tree.graphql", + extern_enums("FileViewedState") +)] +struct PullRequestFileTreeQuery; + +pub(super) type PullRequestFileTreeResponse = pull_request_file_tree_query::ResponseData; pub(super) type PullRequestTimelineResponse = pull_request_timeline_query::ResponseData; #[cfg(test)] pub(super) type PullRequestTimelineResponseNode = PullRequestTimelineQueryNode; @@ -289,8 +331,8 @@ impl query::QueryFn for ListPullRequests { } let query_string = match self.filter { - | Some(filter) => format!("is:pr archived:false sort:updated-desc {}", filter), - | None => "is:pr archived:false sort:updated-desc".into(), + | Some(filter) => format!("is:pr archived:false sort:updated-desc {}", filter), + | None => "is:pr archived:false sort:updated-desc".into(), }; let gql = @@ -312,19 +354,19 @@ impl query::QueryFn for ListPullRequests { .flatten() .filter_map(|edge| { edge.node.and_then(|n| match n { - | PullRequestPaginationQuerySearchEdgesNode::PullRequest(p) => { - Some(PullRequest { - id: p.id.into(), - title: p.title, - state: p.state, - is_draft: p.is_draft, - repo_slug: format!( - "{}/{}", - p.repository.owner.login, p.repository.name - ), - }) - } - | _ => None, + | PullRequestPaginationQuerySearchEdgesNode::PullRequest(p) => { + Some(PullRequest { + id: p.id.into(), + title: p.title, + state: p.state, + is_draft: p.is_draft, + repo_slug: format!( + "{}/{}", + p.repository.owner.login, p.repository.name + ), + }) + } + | _ => None, }) }) .collect::>() @@ -369,36 +411,149 @@ impl query::QueryFn for FetchPullRequest { "missing 'node' field on PullRequestQuery response".into(), )) .and_then(|n| match n { - | PullRequestQueryNode::PullRequest(p) => { - let created_at = - chrono::DateTime::parse_from_rfc3339(&p.created_at).map_err(|err| { - api::Error::MalformedResponse(format!( - "invalid pull request createdAt {:?}: {err}", - p.created_at - )) - })?; + | PullRequestQueryNode::PullRequest(p) => { + let created_at = + chrono::DateTime::parse_from_rfc3339(&p.created_at).map_err(|err| { + api::Error::MalformedResponse(format!( + "invalid pull request createdAt {:?}: {err}", + p.created_at + )) + })?; - Ok(DetailedPullRequest { - title: p.title, - state: p.state, - is_draft: p.is_draft, - body: p.body, - author: p.author.map(|it| api::user::Actor { - login: it.login, - avatar_url: it.avatar_url, - }), - base_branch_name: p.base_ref.map(|r| r.name), - head_branch_name: p.head_ref.map(|r| r.name), - created_at: Some(created_at), - }) - } - | _ => Err(api::Error::MalformedResponse( - "unexpected node type on PullRequestQuery".into(), - )), + Ok(DetailedPullRequest { + title: p.title, + state: p.state, + is_draft: p.is_draft, + body: p.body, + author: p.author.map(|it| api::user::Actor { + login: it.login, + avatar_url: it.avatar_url, + }), + base_repo_slug: p + .base_repository + .map(|it| it.name_with_owner) + .unwrap_or_default(), + base_branch_name: p.base_ref_name, + base_ref: p.base_ref_oid, + head_repo_slug: p + .head_repository + .map(|it| it.name_with_owner) + .unwrap_or_default(), + head_branch_name: p.head_ref_name, + head_ref: p.head_ref_oid, + created_at: Some(created_at), + }) + } + | _ => Err(api::Error::MalformedResponse( + "unexpected node type on PullRequestQuery".into(), + )), }) } } +#[derive(Clone)] +pub(crate) struct FetchPullRequestFileTree { + pub(crate) id: Id, + pub(crate) first: i64, +} + +impl query::QueryFn for FetchPullRequestFileTree { + type Data = Vec; + 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 { + #[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::(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::(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::>() + }) + .unwrap_or_default()) + } +} + #[derive(Clone)] pub(crate) struct FetchPullRequestTimeline { pub(crate) id: Id, @@ -406,12 +561,6 @@ pub(crate) struct FetchPullRequestTimeline { pub(crate) after: Option, } -impl FetchPullRequestTimeline { - pub(crate) fn new(id: Id, first: i64, after: Option) -> Self { - Self { id, first, after } - } -} - impl query::QueryFn for FetchPullRequestTimeline { type Data = PullRequestTimeline; type Error = api::Error; @@ -437,11 +586,11 @@ impl query::QueryFn for FetchPullRequestTimeline { TimelineActor { kind: match on { - | actorFieldsOn::Bot => "Bot", - | actorFieldsOn::EnterpriseUserAccount => "EnterpriseUserAccount", - | actorFieldsOn::Mannequin => "Mannequin", - | actorFieldsOn::Organization => "Organization", - | actorFieldsOn::User => "User", + | actorFieldsOn::Bot => "Bot", + | actorFieldsOn::EnterpriseUserAccount => "EnterpriseUserAccount", + | actorFieldsOn::Mannequin => "Mannequin", + | actorFieldsOn::Organization => "Organization", + | actorFieldsOn::User => "User", } .into(), name: login, @@ -451,62 +600,62 @@ impl query::QueryFn for FetchPullRequestTimeline { fn normalize_assignee(actor: assigneeFields) -> TimelineActor { match actor { - | assigneeFields::Bot(actor) => TimelineActor { - kind: "Bot".into(), - name: actor.login, - avatar_url: Some(actor.avatar_url), - }, - | assigneeFields::Mannequin(actor) => TimelineActor { - kind: "Mannequin".into(), - name: actor.login, - avatar_url: Some(actor.avatar_url), - }, - | assigneeFields::Organization(actor) => TimelineActor { - kind: "Organization".into(), - name: actor.login, - avatar_url: Some(actor.avatar_url), - }, - | assigneeFields::User(actor) => TimelineActor { - kind: "User".into(), - name: actor.login, - avatar_url: Some(actor.avatar_url), - }, + | assigneeFields::Bot(actor) => TimelineActor { + kind: "Bot".into(), + name: actor.login, + avatar_url: Some(actor.avatar_url), + }, + | assigneeFields::Mannequin(actor) => TimelineActor { + kind: "Mannequin".into(), + name: actor.login, + avatar_url: Some(actor.avatar_url), + }, + | assigneeFields::Organization(actor) => TimelineActor { + kind: "Organization".into(), + name: actor.login, + avatar_url: Some(actor.avatar_url), + }, + | assigneeFields::User(actor) => TimelineActor { + kind: "User".into(), + name: actor.login, + avatar_url: Some(actor.avatar_url), + }, } } fn normalize_requested_reviewer(actor: requestedReviewerFields) -> TimelineActor { match actor { - | requestedReviewerFields::Bot(actor) => TimelineActor { - kind: "Bot".into(), - name: actor.login, - avatar_url: Some(actor.avatar_url), - }, - | requestedReviewerFields::Mannequin(actor) => TimelineActor { - kind: "Mannequin".into(), - name: actor.login, - avatar_url: Some(actor.avatar_url), - }, - | requestedReviewerFields::Team(actor) => TimelineActor { - kind: "Team".into(), - name: actor.name, - avatar_url: None, - }, - | requestedReviewerFields::User(actor) => TimelineActor { - kind: "User".into(), - name: actor.login, - avatar_url: Some(actor.avatar_url), - }, + | requestedReviewerFields::Bot(actor) => TimelineActor { + kind: "Bot".into(), + name: actor.login, + avatar_url: Some(actor.avatar_url), + }, + | requestedReviewerFields::Mannequin(actor) => TimelineActor { + kind: "Mannequin".into(), + name: actor.login, + avatar_url: Some(actor.avatar_url), + }, + | requestedReviewerFields::Team(actor) => TimelineActor { + kind: "Team".into(), + name: actor.name, + avatar_url: None, + }, + | requestedReviewerFields::User(actor) => TimelineActor { + kind: "User".into(), + name: actor.login, + avatar_url: Some(actor.avatar_url), + }, } } fn normalize_review_state(state: PullRequestReviewState) -> String { match state { - | PullRequestReviewState::PENDING => "PENDING", - | PullRequestReviewState::COMMENTED => "COMMENTED", - | PullRequestReviewState::APPROVED => "APPROVED", - | PullRequestReviewState::CHANGES_REQUESTED => "CHANGES_REQUESTED", - | PullRequestReviewState::DISMISSED => "DISMISSED", - | _ => "OTHER", + | PullRequestReviewState::PENDING => "PENDING", + | PullRequestReviewState::COMMENTED => "COMMENTED", + | PullRequestReviewState::APPROVED => "APPROVED", + | PullRequestReviewState::CHANGES_REQUESTED => "CHANGES_REQUESTED", + | PullRequestReviewState::DISMISSED => "DISMISSED", + | _ => "OTHER", } .into() } @@ -726,10 +875,10 @@ impl query::QueryFn for FetchPullRequestTimeline { "missing 'node' field on PullRequestTimelineQuery response".into(), )) .and_then(|node| match node { - | PullRequestTimelineQueryNode::PullRequest(pull_request) => Ok(pull_request), - | _ => Err(api::Error::MalformedResponse( - "unexpected node type on PullRequestTimelineQuery".into(), - )), + | PullRequestTimelineQueryNode::PullRequest(pull_request) => Ok(pull_request), + | _ => Err(api::Error::MalformedResponse( + "unexpected node type on PullRequestTimelineQuery".into(), + )), })?; let timeline = pull_request.timeline_items; diff --git a/src/api/mock.rs b/src/api/mock.rs index a4a4878..ceee939 100644 --- a/src/api/mock.rs +++ b/src/api/mock.rs @@ -24,6 +24,27 @@ pub(crate) fn list_repos() -> Result, api::Error> { parse_fixture("repo.list", repo_list()) } +pub(crate) fn fetch_file_content( + repo_slug: &str, + path: &str, + reff: Option<&str>, +) -> Result { + let (owner, repo) = repo_slug.split_once('/').ok_or_else(|| { + api::Error::MalformedResponse(format!( + "invalid repo slug for repo.file_content fixture: {repo_slug}" + )) + })?; + + let content = repo_file_content(owner, repo, path, reff).ok_or_else(|| { + api::Error::MissingMockFixture(format!( + "repo.file_content repo_slug={repo_slug} owner={owner} repo={repo} path={path} ref={}", + reff.unwrap_or_default() + )) + })?; + + Ok(bytes::Bytes::from_static(content.as_bytes())) +} + pub(crate) fn list_pull_requests( filter: Option<&str>, page: u32, @@ -52,6 +73,17 @@ pub(crate) fn fetch_pull_request( parse_fixture(&format!("issues.pull_request.{id}"), json) } +pub(crate) fn fetch_pull_request_file_tree( + id: &issues::Id, +) -> Result { + let id = id.to_string(); + let json = issues_pull_request_file_tree(&id).ok_or_else(|| { + api::Error::MissingMockFixture(format!("issues.pull_request_file_tree id={id}")) + })?; + + parse_fixture(&format!("issues.pull_request_file_tree.{id}"), json) +} + pub(crate) fn fetch_pull_request_timeline( id: &issues::Id, after: Option<&str>, @@ -146,10 +178,10 @@ mod tests { merged.author.as_ref().map(|author| author.login.as_str()), Some("rorycraft") ); - assert_eq!(merged.base_branch_name.as_deref(), Some("main")); + assert_eq!(merged.base_branch_name.as_str(), "main"); assert_eq!( - merged.head_branch_name.as_deref(), - Some("feat/release-handoff-checklist") + merged.head_branch_name.as_str(), + "feat/release-handoff-checklist" ); assert_eq!( merged.created_at, @@ -167,23 +199,28 @@ mod tests { .map(|author| author.login.as_str()), Some("kennethnym") ); + assert_eq!(documented_failover.base_branch_name.as_str(), "main"); assert_eq!( - documented_failover.base_branch_name.as_deref(), - Some("main") - ); - assert_eq!( - documented_failover.head_branch_name.as_deref(), - Some("docs/manual-failover-steps") + documented_failover.head_branch_name.as_str(), + "docs/manual-failover-steps" ); assert_eq!( documented_failover.created_at, Some(chrono::DateTime::parse_from_rfc3339("2026-04-24T06:40:00Z").unwrap()) ); assert!(dashboard_markdown.body.contains("```rust")); - assert_eq!(dashboard_markdown.base_branch_name.as_deref(), Some("main")); + assert_eq!(dashboard_markdown.base_branch_name.as_str(), "main"); assert_eq!( - dashboard_markdown.head_branch_name.as_deref(), - Some("feat/cached-issue-pane") + dashboard_markdown.head_branch_name.as_str(), + "feat/cached-issue-pane" + ); + assert_eq!( + dashboard_markdown.base_ref.as_str(), + "5e8745bfcc0c90c226d3c6af84226d6d4a5ec2d1" + ); + assert_eq!( + dashboard_markdown.head_ref.as_str(), + "2bc41de7731b9ef48f7d64ee9f0d5f497dbe0a51" ); assert_eq!( dashboard_markdown.created_at, @@ -196,10 +233,18 @@ mod tests { .map(|author| author.login.as_str()), Some("kennethnym") ); - assert_eq!(cached_repo_picker.base_branch_name.as_deref(), Some("main")); + assert_eq!(cached_repo_picker.base_branch_name.as_str(), "main"); assert_eq!( - cached_repo_picker.head_branch_name.as_deref(), - Some("feat/cached-repo-picker") + cached_repo_picker.head_branch_name.as_str(), + "feat/cached-repo-picker" + ); + assert_eq!( + cached_repo_picker.base_ref.as_str(), + "5e8745bfcc0c90c226d3c6af84226d6d4a5ec2d1" + ); + assert_eq!( + cached_repo_picker.head_ref.as_str(), + "13af7d0b48a6ce0b22d48c9b6c1c78dfcd94e6a0" ); assert_eq!( cached_repo_picker.created_at, @@ -212,10 +257,10 @@ mod tests { .map(|author| author.login.as_str()), Some("leaferiksen") ); - assert_eq!(worker_split.base_branch_name.as_deref(), Some("main")); + assert_eq!(worker_split.base_branch_name.as_str(), "main"); assert_eq!( - worker_split.head_branch_name.as_deref(), - Some("feat/worker-context-envelope") + worker_split.head_branch_name.as_str(), + "feat/worker-context-envelope" ); assert_eq!( worker_split.created_at, @@ -228,10 +273,10 @@ mod tests { .map(|author| author.login.as_str()), Some("mariahops") ); - assert_eq!(spacing_tokens.base_branch_name.as_deref(), Some("main")); + assert_eq!(spacing_tokens.base_branch_name.as_str(), "main"); assert_eq!( - spacing_tokens.head_branch_name.as_deref(), - Some("chore/dashboard-spacing-scale") + spacing_tokens.head_branch_name.as_str(), + "chore/dashboard-spacing-scale" ); assert_eq!( spacing_tokens.created_at, @@ -239,6 +284,100 @@ mod tests { ); } + #[test] + fn repo_file_content_fixtures_align_with_pull_request_refs() { + let dashboard_markdown = fetch_pull_request(&issues::Id::from("PR_kwDONovem84")) + .expect("dashboard pull request fixture should parse"); + let cached_repo_picker = fetch_pull_request(&issues::Id::from("PR_kwDONovem85")) + .expect("repo picker pull request fixture should parse"); + + let base_query = fetch_file_content( + "kennethnym/novem", + "src/query.rs", + Some(dashboard_markdown.base_ref.as_str()), + ) + .expect("base query fixture should exist"); + let head_query = fetch_file_content( + "kennethnym/novem", + "src/query.rs", + Some(dashboard_markdown.head_ref.as_str()), + ) + .expect("head query fixture should exist"); + let base_query = std::str::from_utf8(base_query.as_ref()) + .expect("base query fixture should be utf-8 text"); + let head_query = std::str::from_utf8(head_query.as_ref()) + .expect("head query fixture should be utf-8 text"); + + assert!(base_query.contains("pub struct CachedSelection")); + assert!(head_query.contains("pub struct CachedQueryState")); + assert!(head_query.contains("selected_pull_request_id")); + assert!(head_query.contains("reconcile_scroll_anchor")); + assert_ne!(base_query, head_query); + + let base_repo = fetch_file_content( + "kennethnym/novem", + "src/api/repo.rs", + Some(cached_repo_picker.base_ref.as_str()), + ) + .expect("base repo fixture should exist"); + let head_repo = fetch_file_content( + "kennethnym/novem", + "src/api/repo.rs", + Some(cached_repo_picker.head_ref.as_str()), + ) + .expect("head repo fixture should exist"); + let base_repo = + std::str::from_utf8(base_repo.as_ref()).expect("base repo fixture should be utf-8"); + let head_repo = + std::str::from_utf8(head_repo.as_ref()).expect("head repo fixture should be utf-8"); + + assert!(base_repo.contains("pub fn filter_repositories")); + assert!(head_repo.contains("pub struct RepoMatch")); + assert!(head_repo.contains("pub fn build_content_path")); + assert_ne!(base_repo, head_repo); + + let _ = fetch_pull_request_file_tree(&issues::Id::from("PR_kwDONovem84")) + .expect("pull request file tree fixture should parse"); + + let file_tree_json: serde_json::Value = serde_json::from_str( + issues_pull_request_file_tree("PR_kwDONovem84") + .expect("pull request file tree fixture json should exist"), + ) + .expect("pull request file tree fixture json should parse"); + + let file_paths = file_tree_json + .get("node") + .and_then(|node| node.get("files")) + .and_then(|files| files.get("edges")) + .and_then(serde_json::Value::as_array) + .expect("pull request file tree fixture should contain file edges") + .iter() + .filter_map(|edge| edge.get("node")) + .filter_map(|node| node.get("path")) + .filter_map(serde_json::Value::as_str) + .collect::>(); + + assert_eq!( + file_paths, + vec!["src/query.rs", "src/screen/dashboard/issue_list.rs"] + ); + + for path in file_paths { + fetch_file_content( + dashboard_markdown.base_repo_slug.as_str(), + path, + Some(dashboard_markdown.base_ref.as_str()), + ) + .unwrap_or_else(|_| panic!("base fixture should exist for {path}")); + fetch_file_content( + dashboard_markdown.head_repo_slug.as_str(), + path, + Some(dashboard_markdown.head_ref.as_str()), + ) + .unwrap_or_else(|_| panic!("head fixture should exist for {path}")); + } + } + #[test] fn pull_request_timeline_fixtures_parse() { let first_page = fetch_pull_request_timeline(&issues::Id::from("PR_kwDOSprint62"), None) diff --git a/src/api/repo.rs b/src/api/repo.rs index c15bc50..54e64b3 100644 --- a/src/api/repo.rs +++ b/src/api/repo.rs @@ -1,6 +1,13 @@ +use futures::{FutureExt, TryFutureExt}; +use reqwest::Method; use serde::Deserialize; +use tokio::sync::OwnedRwLockReadGuard; -use crate::{api, query}; +use crate::{ + api, + query::{self, Query, fetch_query}, + util::file, +}; #[derive(Debug, Deserialize)] pub struct Repository { @@ -22,6 +29,13 @@ pub struct Owner { pub html_url: String, } +#[derive(Debug, Clone)] +pub struct FileRef { + pub repo_slug: String, + pub path: String, + pub reff: Option, +} + #[derive(Clone)] pub struct List; @@ -50,3 +64,113 @@ impl query::QueryFn for List { api::parse_response(res).await } } + +#[derive(Clone)] +pub struct FetchFileContent { + pub repo_slug: String, + pub path: String, + pub reff: Option, +} + +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 { + #[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 { + async fn fetch_content( + r: &FileRef, + c: &::Context, + ) -> Result, 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!() + } +} diff --git a/src/component/button.rs b/src/component/button.rs index 7ad8c01..74132a8 100644 --- a/src/component/button.rs +++ b/src/component/button.rs @@ -87,8 +87,8 @@ impl gpui::RenderOnce for Button { let theme = app::current_theme(cx); let icon_color = match self.variant { - | Variant::Primary => theme.colors.accent_on_solid, - | Variant::Secondary => theme.colors.text, + | Variant::Primary => theme.colors.accent_on_solid, + | Variant::Secondary => theme.colors.text, }; let mut children: Vec = Vec::with_capacity(3); diff --git a/src/component/code_view.rs b/src/component/code_view.rs new file mode 100644 index 0000000..55e5703 --- /dev/null +++ b/src/component/code_view.rs @@ -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>); + +struct CodeView { + state: CodeViewState, + lines: Lines, +} + +pub(crate) fn line( + line_number: usize, + content: impl Into>, + 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 for Lines { + fn from_iter>(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) + } +} diff --git a/src/component/mod.rs b/src/component/mod.rs index 778ae21..bef24f4 100644 --- a/src/component/mod.rs +++ b/src/component/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod button; +pub(crate) mod code_view; pub(crate) mod font_icon; pub(crate) mod markdown; pub(crate) mod text; diff --git a/src/screen/dashboard/issue_list.rs b/src/screen/dashboard/issue_list.rs index 57a9505..646e82d 100644 --- a/src/screen/dashboard/issue_list.rs +++ b/src/screen/dashboard/issue_list.rs @@ -148,8 +148,8 @@ impl gpui::RenderOnce for IssueListItem { } let repo_name_text = match self.repo_name { - | Some(name) => text(name), - | None => text("Unknown repo"), + | Some(name) => text(name), + | None => text("Unknown repo"), } .text_xs() .opacity(0.5); @@ -162,21 +162,21 @@ impl gpui::RenderOnce for IssueListItem { .bg(theme.colors.surface) } else { match self.status { - | api::issues::PullRequestState::Closed => pill( - text("Closed").text_color(theme.colors.danger_on_solid), - font_icon(FontIcon::PullRequestClosed).text_color(theme.colors.danger_on_solid), - ) - .bg(theme.colors.danger_solid), - | api::issues::PullRequestState::Merged => pill( - text("Merged").text_color(theme.colors.accent_on_solid), - font_icon(FontIcon::PullRequestClosed).text_color(theme.colors.accent_on_solid), - ) - .bg(theme.colors.accent_solid), - | _ => pill( - text("Open").text_color(theme.colors.success_on_solid), - font_icon(FontIcon::PullRequestArrow).text_color(theme.colors.success_on_solid), - ) - .bg(theme.colors.success_solid), + | api::issues::PullRequestState::Closed => pill( + text("Closed").text_color(theme.colors.danger_on_solid), + font_icon(FontIcon::PullRequestClosed).text_color(theme.colors.danger_on_solid), + ) + .bg(theme.colors.danger_solid), + | api::issues::PullRequestState::Merged => pill( + text("Merged").text_color(theme.colors.accent_on_solid), + font_icon(FontIcon::PullRequestClosed).text_color(theme.colors.accent_on_solid), + ) + .bg(theme.colors.accent_solid), + | _ => pill( + text("Open").text_color(theme.colors.success_on_solid), + font_icon(FontIcon::PullRequestArrow).text_color(theme.colors.success_on_solid), + ) + .bg(theme.colors.success_solid), } }; diff --git a/src/screen/dashboard/mod.rs b/src/screen/dashboard/mod.rs index d00448b..f3966b8 100644 --- a/src/screen/dashboard/mod.rs +++ b/src/screen/dashboard/mod.rs @@ -1,4 +1,5 @@ mod issue_list; +mod pull_request_diff_view; mod pull_request_view; mod screen; mod sidebar; diff --git a/src/screen/dashboard/pull_request_diff_view.rs b/src/screen/dashboard/pull_request_diff_view.rs new file mode 100644 index 0000000..73bbf5e --- /dev/null +++ b/src/screen/dashboard/pull_request_diff_view.rs @@ -0,0 +1,79 @@ +use crate::{ + api, + query::{self, QueryStatus, read_query, use_query}, +}; + +pub(crate) struct PullRequestDiffView { + selected_file_path: Option, + + pr_query: query::Entity, + old_content_query: Option>, + new_content_query: Option>, +} + +fn new(pr_id: api::issues::Id, cx: &mut gpui::Context) -> 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) { + _ = 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) { + 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, + ) -> impl gpui::IntoElement { + todo!() + } +} diff --git a/src/screen/dashboard/pull_request_view.rs b/src/screen/dashboard/pull_request_view.rs index b3f7999..306a793 100644 --- a/src/screen/dashboard/pull_request_view.rs +++ b/src/screen/dashboard/pull_request_view.rs @@ -13,10 +13,12 @@ use crate::{ text::text, }, query::{self, QueryStatus, read_query, use_query}, + screen::dashboard::pull_request_diff_view::PullRequestDiffView, }; pub(crate) struct PullRequestView { markdown_viewer: Option>, + diff_view: Option>, pull_request_query: Option>, } @@ -27,6 +29,7 @@ struct Toolbar {} pub fn new(_cx: &mut gpui::Context) -> PullRequestView { PullRequestView { markdown_viewer: None, + diff_view: None, pull_request_query: None, } } @@ -126,12 +129,9 @@ impl PullRequestView { } } - let merge_text = match ( - pr.author.as_ref(), - pr.base_branch_name.as_ref(), - pr.head_branch_name.as_ref(), - ) { - | (Some(author), Some(base_branch), Some(head_branch)) => { + let merge_text = pr.author.as_ref().map(|author| { + let base_branch = pr.base_branch_name.as_str(); + let head_branch = pr.head_branch_name.as_str(); let str = format!( "{} requested to merge {} into {}", author.login, head_branch, base_branch @@ -166,14 +166,11 @@ impl PullRequestView { ), ]; - Some(( + ( author, gpui::StyledText::new(str).with_highlights(highlights), - )) - } - - | _ => None, - }; + ) + }); let metadata_line = div() diff --git a/src/screen/dashboard/screen.rs b/src/screen/dashboard/screen.rs index 5d32b54..f6f8c9c 100644 --- a/src/screen/dashboard/screen.rs +++ b/src/screen/dashboard/screen.rs @@ -33,9 +33,9 @@ impl Screen { fn on_create(&mut self, cx: &mut gpui::Context) { _ = cx .subscribe(&self.issue_list, |this, _, event, cx| match event { - | issue_list::Event::ItemSelected(pr_id) => { - this.handle_issue_list_item_selected(pr_id, cx); - } + | issue_list::Event::ItemSelected(pr_id) => { + this.handle_issue_list_item_selected(pr_id, cx); + } }) .detach(); } diff --git a/src/util/file.rs b/src/util/file.rs new file mode 100644 index 0000000..fa95775 --- /dev/null +++ b/src/util/file.rs @@ -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, + old_content_range: std::ops::Range, + new_line: Option, + new_content_range: std::ops::Range, +} + +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() +} diff --git a/src/util/mod.rs b/src/util/mod.rs index 367793c..6c83e09 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -1 +1,2 @@ +pub(crate) mod file; pub(crate) mod timeout;