From 059fd8a69b4658f7f8ead61f0be4583f89e883cb Mon Sep 17 00:00:00 2001 From: imacross <30753807+imacross@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:50:43 +0300 Subject: [PATCH] feat(github): pull request tool window, timeline and review flow (#477) Replaces the Pull Requests tool window with a full review workflow on top of gh's authentication. - List with search, quick filters and paging; a tab per pull request and a New Pull Request tab. - Details: commit picker, changes tree with viewed state, CI and reviewer status, merge, close and reopen. - Timeline editor: comments, review threads, reactions and suggested changes. - Review in the diff viewer and, for the checked-out branch, in the editor (review mode). - New generic vcs extension points: registerDiffViewerProvider, lineInlays, reviewGutter, branch widget extensions; new vcs_merge_base and gh_api commands. - CSP: img-src allows https://avatars.githubusercontent.com for avatars. Fixed while testing against a real repository: remotes behind an SSH host alias, mergeability stuck on UNKNOWN, Apply Locally staying disabled after a checkout, relative links navigating the app's page, and review inlays wider than the pane. Co-authored-by: imacross <30753807+imacross@users.noreply.github.com> --- crates/ide-github/src/lib.rs | 49 +- crates/ide-github/src/model.rs | 26 +- crates/ide-github/src/process.rs | 23 +- crates/ide-github/tests/fixtures/pr_view.json | 2 +- crates/ide-github/tests/parse.rs | 32 + crates/ide-host/src/ops/vcs/commands.rs | 16 + src-tauri/src/commands/github.rs | 10 +- src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/vcs.rs | 13 + src-tauri/tauri.conf.json | 2 +- src/bindings.ts | 27 + src/editor/api.ts | 27 + src/editor/lsp/inspectionWidget.ts | 7 +- src/features/github/Github.module.css | 371 -------- src/features/github/PullRequestsView.tsx | 609 ------------- src/features/github/api/api.test.ts | 112 +++ src/features/github/api/client.ts | 104 +++ src/features/github/api/document.ts | 78 ++ src/features/github/api/fragments.ts | 505 +++++++++++ src/features/github/api/queries.ts | 852 ++++++++++++++++++ src/features/github/api/requests.ts | 619 +++++++++++++ src/features/github/api/types.ts | 499 ++++++++++ src/features/github/create/ChangesPanel.tsx | 258 ++++++ src/features/github/create/Create.module.css | 386 ++++++++ src/features/github/create/CreatePanel.tsx | 263 ++++++ .../github/create/DirectionSelector.tsx | 196 ++++ src/features/github/create/MetadataGrid.tsx | 154 ++++ .../github/create/RemoteBranchDialog.tsx | 43 + src/features/github/create/create.test.ts | 198 ++++ src/features/github/create/model.ts | 216 +++++ src/features/github/create/store.ts | 446 +++++++++ src/features/github/data/batches.ts | 78 ++ src/features/github/data/data.test.ts | 470 ++++++++++ src/features/github/data/interaction.ts | 86 ++ src/features/github/data/list.ts | 159 ++++ src/features/github/data/local.ts | 192 ++++ src/features/github/data/model.ts | 444 +++++++++ src/features/github/data/operations.ts | 400 ++++++++ src/features/github/data/pullRequest.ts | 352 ++++++++ src/features/github/data/repository.ts | 342 +++++++ src/features/github/data/searchHistory.ts | 69 ++ src/features/github/details/BranchWidget.tsx | 103 +++ src/features/github/details/ChangesTree.tsx | 280 ++++++ src/features/github/details/CommitPicker.tsx | 242 +++++ .../github/details/Details.module.css | 481 ++++++++++ src/features/github/details/DetailsPanel.tsx | 214 +++++ src/features/github/details/ReviewActions.tsx | 216 +++++ src/features/github/details/StatusSection.tsx | 213 +++++ .../github/details/SubmitReviewPopup.tsx | 144 +++ src/features/github/details/details.test.tsx | 177 ++++ src/features/github/details/flow.ts | 162 ++++ src/features/github/details/model.test.ts | 347 +++++++ src/features/github/details/model.ts | 409 +++++++++ src/features/github/details/state.ts | 79 ++ src/features/github/dialogs.tsx | 244 ----- .../github/diff/GithubSettingsPage.tsx | 20 + src/features/github/diff/Inlays.tsx | 81 ++ .../github/diff/ReviewDiff.module.css | 34 + src/features/github/diff/ReviewToolbar.tsx | 51 ++ src/features/github/diff/index.ts | 99 ++ src/features/github/diff/model.test.ts | 226 +++++ src/features/github/diff/model.ts | 317 +++++++ src/features/github/diff/newComments.ts | 133 +++ src/features/github/diff/reviewDiff.test.tsx | 380 ++++++++ src/features/github/diff/reviewDiff.tsx | 486 ++++++++++ src/features/github/diff/settings.ts | 56 ++ src/features/github/github.test.ts | 112 --- src/features/github/index.ts | 387 ++++++-- src/features/github/list/HoverBalloon.tsx | 77 ++ src/features/github/list/List.module.css | 265 ++++++ src/features/github/list/ListPanel.tsx | 159 ++++ src/features/github/list/PullRequestRow.tsx | 133 +++ src/features/github/list/SearchPanel.tsx | 389 ++++++++ src/features/github/list/list.test.tsx | 330 +++++++ src/features/github/list/model.ts | 209 +++++ src/features/github/model.ts | 179 ---- src/features/github/ops.ts | 180 ---- .../github/reviewMode/ReviewMode.module.css | 47 + src/features/github/reviewMode/Toolbar.tsx | 98 ++ src/features/github/reviewMode/editor.tsx | 558 ++++++++++++ src/features/github/reviewMode/index.ts | 157 ++++ src/features/github/reviewMode/model.ts | 248 +++++ .../github/reviewMode/reviewMode.test.tsx | 596 ++++++++++++ src/features/github/reviewMode/settings.ts | 52 ++ src/features/github/reviewMode/state.ts | 165 ++++ src/features/github/store.ts | 219 ----- .../github/timeline/Timeline.module.css | 97 ++ .../github/timeline/TimelineEditor.tsx | 195 ++++ .../github/timeline/comments/ChatItem.tsx | 111 +++ .../timeline/comments/CommentEditor.tsx | 294 ++++++ .../github/timeline/comments/CommentItem.tsx | 126 +++ .../github/timeline/comments/DiffHunk.tsx | 71 ++ .../github/timeline/comments/Reactions.tsx | 150 +++ .../timeline/comments/SuggestedChange.tsx | 168 ++++ .../github/timeline/comments/ThreadView.tsx | 376 ++++++++ .../timeline/comments/comments.module.css | 438 +++++++++ .../github/timeline/comments/index.ts | 23 + .../github/timeline/comments/suggestion.ts | 114 +++ src/features/github/timeline/items.tsx | 387 ++++++++ src/features/github/timeline/model.test.ts | 264 ++++++ src/features/github/timeline/model.ts | 672 ++++++++++++++ .../toolwindow/PullRequestsToolWindow.tsx | 297 ++++++ .../github/toolwindow/ToolWindow.module.css | 133 +++ src/features/github/toolwindow/handlers.ts | 45 + src/features/github/toolwindow/open.ts | 41 + src/features/github/toolwindow/store.ts | 74 ++ .../github/toolwindow/timelineInput.ts | 60 ++ .../github/toolwindow/toolwindow.test.tsx | 170 ++++ src/features/github/ui/Avatar.tsx | 132 +++ src/features/github/ui/ChooserPopup.tsx | 232 +++++ src/features/github/ui/Markdown.tsx | 42 + src/features/github/ui/Placeholder.tsx | 15 + src/features/github/ui/chips.tsx | 62 ++ src/features/github/ui/dialogs.tsx | 133 +++ src/features/github/ui/palette.css | 40 + src/features/github/ui/prettyDate.ts | 46 + src/features/github/ui/ui.module.css | 270 ++++++ src/features/github/ui/ui.test.tsx | 161 ++++ src/features/vcs/BranchWidget.tsx | 33 +- src/features/vcs/DiffEditor.tsx | 168 +++- .../__tests__/branchWidgetExtensions.test.ts | 36 + .../__tests__/diffViewerProviders.test.tsx | 193 ++++ src/features/vcs/branchWidgetExtensions.ts | 60 ++ src/features/vcs/branches.ts | 2 + src/features/vcs/diffInput.ts | 9 +- src/features/vcs/diffNavigator.ts | 3 + src/features/vcs/diffViewerProviders.ts | 95 ++ src/features/vcs/lineInlays.tsx | 102 +++ src/features/vcs/reviewGutter.ts | 363 ++++++++ src/platform/keymaps/eclipse.test.ts | 16 +- src/platform/keymaps/intellij-macos.ts | 6 + src/platform/keymaps/intellij-windows.ts | 6 + src/platform/keymaps/vscode-macos.ts | 5 + src/platform/locales/en.json | 5 - src/platform/locales/text/zh.json | 332 +++++-- src/platform/locales/zh.json | 5 - src/ui/expui/actions/editSource.svg | 7 + src/ui/expui/actions/editSource_dark.svg | 7 + src/ui/expui/general/greenCheckmark.svg | 3 + src/ui/expui/general/greenCheckmark_dark.svg | 3 + src/ui/expui/github/addEmoji.svg | 8 + src/ui/expui/github/addEmojiHovered.svg | 4 + src/ui/expui/github/addEmoji_dark.svg | 8 + src/ui/expui/github/comment.svg | 4 + src/ui/expui/github/comment_dark.svg | 4 + src/ui/expui/github/githubWarning.svg | 14 + src/ui/expui/github/githubWarning_dark.svg | 14 + src/ui/expui/github/issueClosed.svg | 4 + src/ui/expui/github/issueOpened.svg | 4 + src/ui/expui/github/localBranch.svg | 4 + src/ui/expui/github/localBranch_dark.svg | 4 + src/ui/expui/github/pullRequestClosed.svg | 11 + .../expui/github/pullRequestClosed_dark.svg | 11 + src/ui/expui/github/pullRequestDraft.svg | 5 + src/ui/expui/github/pullRequestMerged.svg | 4 + src/ui/expui/github/pullRequestOpen.svg | 11 + src/ui/expui/github/pullRequestOpen_dark.svg | 11 + src/ui/expui/github/review.svg | 4 + src/ui/expui/github/review/branchCurrent.svg | 3 + src/ui/expui/github/review/commentHovered.svg | 3 + .../expui/github/review/commentReadMany.svg | 12 + .../github/review/commentReadMany_dark.svg | 12 + src/ui/expui/github/review/commentUnread.svg | 4 + .../expui/github/review/commentUnreadMany.svg | 12 + .../github/review/commentUnreadMany_dark.svg | 12 + .../github/review/commentUnread_dark.svg | 4 + src/ui/expui/github/review/defaultAvatar.svg | 7 + .../github/review/defaultAvatar_dark.svg | 7 + src/ui/expui/github/review/fileUnread.svg | 4 + .../expui/github/review/fileUnread_dark.svg | 4 + src/ui/expui/github/review/nonMergeable.svg | 6 + src/ui/expui/github/reviewAccepted.svg | 4 + src/ui/expui/github/reviewRejected.svg | 4 + src/ui/expui/github/send.svg | 3 + src/ui/expui/github/sendHovered.svg | 3 + src/ui/expui/github/timeline.svg | 7 + src/ui/expui/inline/inlineAdd.svg | 5 + src/ui/expui/inline/inlineAddHover.svg | 4 + src/ui/expui/inline/inlineAdd_dark.svg | 5 + src/ui/expui/run/testError.svg | 4 + src/ui/expui/run/testError_dark.svg | 4 + src/ui/expui/run/testNotRunYet.svg | 4 + src/ui/expui/run/testNotRunYet_dark.svg | 4 + src/ui/expui/run/testSkipped.svg | 5 + src/ui/expui/run/testSkipped_dark.svg | 5 + src/ui/expui/status/failedInProgress.svg | 9 + src/ui/expui/status/failedInProgress_dark.svg | 9 + src/ui/expui/vcs/branchLabel.svg | 5 + src/ui/expui/vcs/branchLabel_dark.svg | 5 + src/ui/expui/vcs/currentBranchLabel.svg | 5 + src/ui/expui/vcs/currentBranchLabel_dark.svg | 5 + src/ui/icons.tsx | 37 + 192 files changed, 23668 insertions(+), 2076 deletions(-) delete mode 100644 src/features/github/Github.module.css delete mode 100644 src/features/github/PullRequestsView.tsx create mode 100644 src/features/github/api/api.test.ts create mode 100644 src/features/github/api/client.ts create mode 100644 src/features/github/api/document.ts create mode 100644 src/features/github/api/fragments.ts create mode 100644 src/features/github/api/queries.ts create mode 100644 src/features/github/api/requests.ts create mode 100644 src/features/github/api/types.ts create mode 100644 src/features/github/create/ChangesPanel.tsx create mode 100644 src/features/github/create/Create.module.css create mode 100644 src/features/github/create/CreatePanel.tsx create mode 100644 src/features/github/create/DirectionSelector.tsx create mode 100644 src/features/github/create/MetadataGrid.tsx create mode 100644 src/features/github/create/RemoteBranchDialog.tsx create mode 100644 src/features/github/create/create.test.ts create mode 100644 src/features/github/create/model.ts create mode 100644 src/features/github/create/store.ts create mode 100644 src/features/github/data/batches.ts create mode 100644 src/features/github/data/data.test.ts create mode 100644 src/features/github/data/interaction.ts create mode 100644 src/features/github/data/list.ts create mode 100644 src/features/github/data/local.ts create mode 100644 src/features/github/data/model.ts create mode 100644 src/features/github/data/operations.ts create mode 100644 src/features/github/data/pullRequest.ts create mode 100644 src/features/github/data/repository.ts create mode 100644 src/features/github/data/searchHistory.ts create mode 100644 src/features/github/details/BranchWidget.tsx create mode 100644 src/features/github/details/ChangesTree.tsx create mode 100644 src/features/github/details/CommitPicker.tsx create mode 100644 src/features/github/details/Details.module.css create mode 100644 src/features/github/details/DetailsPanel.tsx create mode 100644 src/features/github/details/ReviewActions.tsx create mode 100644 src/features/github/details/StatusSection.tsx create mode 100644 src/features/github/details/SubmitReviewPopup.tsx create mode 100644 src/features/github/details/details.test.tsx create mode 100644 src/features/github/details/flow.ts create mode 100644 src/features/github/details/model.test.ts create mode 100644 src/features/github/details/model.ts create mode 100644 src/features/github/details/state.ts delete mode 100644 src/features/github/dialogs.tsx create mode 100644 src/features/github/diff/GithubSettingsPage.tsx create mode 100644 src/features/github/diff/Inlays.tsx create mode 100644 src/features/github/diff/ReviewDiff.module.css create mode 100644 src/features/github/diff/ReviewToolbar.tsx create mode 100644 src/features/github/diff/index.ts create mode 100644 src/features/github/diff/model.test.ts create mode 100644 src/features/github/diff/model.ts create mode 100644 src/features/github/diff/newComments.ts create mode 100644 src/features/github/diff/reviewDiff.test.tsx create mode 100644 src/features/github/diff/reviewDiff.tsx create mode 100644 src/features/github/diff/settings.ts delete mode 100644 src/features/github/github.test.ts create mode 100644 src/features/github/list/HoverBalloon.tsx create mode 100644 src/features/github/list/List.module.css create mode 100644 src/features/github/list/ListPanel.tsx create mode 100644 src/features/github/list/PullRequestRow.tsx create mode 100644 src/features/github/list/SearchPanel.tsx create mode 100644 src/features/github/list/list.test.tsx create mode 100644 src/features/github/list/model.ts delete mode 100644 src/features/github/model.ts delete mode 100644 src/features/github/ops.ts create mode 100644 src/features/github/reviewMode/ReviewMode.module.css create mode 100644 src/features/github/reviewMode/Toolbar.tsx create mode 100644 src/features/github/reviewMode/editor.tsx create mode 100644 src/features/github/reviewMode/index.ts create mode 100644 src/features/github/reviewMode/model.ts create mode 100644 src/features/github/reviewMode/reviewMode.test.tsx create mode 100644 src/features/github/reviewMode/settings.ts create mode 100644 src/features/github/reviewMode/state.ts delete mode 100644 src/features/github/store.ts create mode 100644 src/features/github/timeline/Timeline.module.css create mode 100644 src/features/github/timeline/TimelineEditor.tsx create mode 100644 src/features/github/timeline/comments/ChatItem.tsx create mode 100644 src/features/github/timeline/comments/CommentEditor.tsx create mode 100644 src/features/github/timeline/comments/CommentItem.tsx create mode 100644 src/features/github/timeline/comments/DiffHunk.tsx create mode 100644 src/features/github/timeline/comments/Reactions.tsx create mode 100644 src/features/github/timeline/comments/SuggestedChange.tsx create mode 100644 src/features/github/timeline/comments/ThreadView.tsx create mode 100644 src/features/github/timeline/comments/comments.module.css create mode 100644 src/features/github/timeline/comments/index.ts create mode 100644 src/features/github/timeline/comments/suggestion.ts create mode 100644 src/features/github/timeline/items.tsx create mode 100644 src/features/github/timeline/model.test.ts create mode 100644 src/features/github/timeline/model.ts create mode 100644 src/features/github/toolwindow/PullRequestsToolWindow.tsx create mode 100644 src/features/github/toolwindow/ToolWindow.module.css create mode 100644 src/features/github/toolwindow/handlers.ts create mode 100644 src/features/github/toolwindow/open.ts create mode 100644 src/features/github/toolwindow/store.ts create mode 100644 src/features/github/toolwindow/timelineInput.ts create mode 100644 src/features/github/toolwindow/toolwindow.test.tsx create mode 100644 src/features/github/ui/Avatar.tsx create mode 100644 src/features/github/ui/ChooserPopup.tsx create mode 100644 src/features/github/ui/Markdown.tsx create mode 100644 src/features/github/ui/Placeholder.tsx create mode 100644 src/features/github/ui/chips.tsx create mode 100644 src/features/github/ui/dialogs.tsx create mode 100644 src/features/github/ui/palette.css create mode 100644 src/features/github/ui/prettyDate.ts create mode 100644 src/features/github/ui/ui.module.css create mode 100644 src/features/github/ui/ui.test.tsx create mode 100644 src/features/vcs/__tests__/branchWidgetExtensions.test.ts create mode 100644 src/features/vcs/__tests__/diffViewerProviders.test.tsx create mode 100644 src/features/vcs/branchWidgetExtensions.ts create mode 100644 src/features/vcs/diffViewerProviders.ts create mode 100644 src/features/vcs/lineInlays.tsx create mode 100644 src/features/vcs/reviewGutter.ts create mode 100644 src/ui/expui/actions/editSource.svg create mode 100644 src/ui/expui/actions/editSource_dark.svg create mode 100644 src/ui/expui/general/greenCheckmark.svg create mode 100644 src/ui/expui/general/greenCheckmark_dark.svg create mode 100644 src/ui/expui/github/addEmoji.svg create mode 100644 src/ui/expui/github/addEmojiHovered.svg create mode 100644 src/ui/expui/github/addEmoji_dark.svg create mode 100644 src/ui/expui/github/comment.svg create mode 100644 src/ui/expui/github/comment_dark.svg create mode 100644 src/ui/expui/github/githubWarning.svg create mode 100644 src/ui/expui/github/githubWarning_dark.svg create mode 100644 src/ui/expui/github/issueClosed.svg create mode 100644 src/ui/expui/github/issueOpened.svg create mode 100644 src/ui/expui/github/localBranch.svg create mode 100644 src/ui/expui/github/localBranch_dark.svg create mode 100644 src/ui/expui/github/pullRequestClosed.svg create mode 100644 src/ui/expui/github/pullRequestClosed_dark.svg create mode 100644 src/ui/expui/github/pullRequestDraft.svg create mode 100644 src/ui/expui/github/pullRequestMerged.svg create mode 100644 src/ui/expui/github/pullRequestOpen.svg create mode 100644 src/ui/expui/github/pullRequestOpen_dark.svg create mode 100644 src/ui/expui/github/review.svg create mode 100644 src/ui/expui/github/review/branchCurrent.svg create mode 100644 src/ui/expui/github/review/commentHovered.svg create mode 100644 src/ui/expui/github/review/commentReadMany.svg create mode 100644 src/ui/expui/github/review/commentReadMany_dark.svg create mode 100644 src/ui/expui/github/review/commentUnread.svg create mode 100644 src/ui/expui/github/review/commentUnreadMany.svg create mode 100644 src/ui/expui/github/review/commentUnreadMany_dark.svg create mode 100644 src/ui/expui/github/review/commentUnread_dark.svg create mode 100644 src/ui/expui/github/review/defaultAvatar.svg create mode 100644 src/ui/expui/github/review/defaultAvatar_dark.svg create mode 100644 src/ui/expui/github/review/fileUnread.svg create mode 100644 src/ui/expui/github/review/fileUnread_dark.svg create mode 100644 src/ui/expui/github/review/nonMergeable.svg create mode 100644 src/ui/expui/github/reviewAccepted.svg create mode 100644 src/ui/expui/github/reviewRejected.svg create mode 100644 src/ui/expui/github/send.svg create mode 100644 src/ui/expui/github/sendHovered.svg create mode 100644 src/ui/expui/github/timeline.svg create mode 100644 src/ui/expui/inline/inlineAdd.svg create mode 100644 src/ui/expui/inline/inlineAddHover.svg create mode 100644 src/ui/expui/inline/inlineAdd_dark.svg create mode 100644 src/ui/expui/run/testError.svg create mode 100644 src/ui/expui/run/testError_dark.svg create mode 100644 src/ui/expui/run/testNotRunYet.svg create mode 100644 src/ui/expui/run/testNotRunYet_dark.svg create mode 100644 src/ui/expui/run/testSkipped.svg create mode 100644 src/ui/expui/run/testSkipped_dark.svg create mode 100644 src/ui/expui/status/failedInProgress.svg create mode 100644 src/ui/expui/status/failedInProgress_dark.svg create mode 100644 src/ui/expui/vcs/branchLabel.svg create mode 100644 src/ui/expui/vcs/branchLabel_dark.svg create mode 100644 src/ui/expui/vcs/currentBranchLabel.svg create mode 100644 src/ui/expui/vcs/currentBranchLabel_dark.svg diff --git a/crates/ide-github/src/lib.rs b/crates/ide-github/src/lib.rs index 53ee99f3..77d27131 100644 --- a/crates/ide-github/src/lib.rs +++ b/crates/ide-github/src/lib.rs @@ -107,7 +107,7 @@ pub fn availability(root: &Path) -> GhAvailability { } let (repo, login) = std::thread::scope(|s| { let repo = s.spawn(|| { - let cmd = command(root, &["repo", "view", "--json", "nameWithOwner,url,defaultBranchRef"])?; + let cmd = command(root, &["repo", "view", "--json", REPO_FIELDS])?; process::run(cmd, QUICK, None) }); let login = s.spawn(|| run(root, &["api", "user", "--jq", ".login"])); @@ -278,6 +278,53 @@ pub fn add_comment(root: &Path, number: u32, body: &str) -> IdeResult<()> { run(root, &["pr", "comment", &n, "--body", body]).map(drop) } +/// A GitHub API call through `gh api`: GraphQL (`endpoint` `graphql`, `body` +/// `{"query", "variables"}`) or REST (`repos/{owner}/{repo}/…`, gh fills in the +/// placeholders from the project's remote). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct GhApiRequest { + /// `GET`, `POST`, `PATCH`, `PUT`, `DELETE`. + pub method: String, + pub endpoint: String, + /// JSON request body, sent on stdin. + pub body: Option, + /// Follow REST `Link` pages; the result is the array of all pages (`--slurp`). + pub paginate: bool, +} + +const METHODS: &[&str] = &["GET", "POST", "PATCH", "PUT", "DELETE"]; + +/// `gh api` arguments for `req`. +pub fn api_args(req: &GhApiRequest) -> IdeResult> { + let method = req.method.to_ascii_uppercase(); + if !METHODS.contains(&method.as_str()) { + return Err(IdeError::invalid(format!("unsupported HTTP method {}", req.method))); + } + let endpoint = req.endpoint.trim(); + if endpoint.is_empty() || endpoint.starts_with('-') || endpoint.contains("://") { + return Err(IdeError::invalid(format!("not a GitHub API endpoint: {endpoint}"))); + } + let mut args: Vec = vec!["api".into(), "--method".into(), method, endpoint.into()]; + if req.body.is_some() { + args.push("--input".into()); + args.push("-".into()); + } + if req.paginate { + args.push("--paginate".into()); + args.push("--slurp".into()); + } + Ok(args) +} + +/// Runs `req`; returns the response body (JSON text, empty for `204 No Content`). +pub fn api(root: &Path, req: &GhApiRequest) -> IdeResult { + let args = api_args(req)?; + let refs: Vec<&str> = args.iter().map(String::as_str).collect(); + let out = process::run_with_input(command(root, &refs)?, req.body.as_deref(), QUICK, None)?; + if out.success { Ok(out.stdout) } else { Err(failure(&out.stderr, &out.stdout)) } +} + /// Revisions to diff a pull request's files with, present in the local repository. #[derive(Clone, Debug, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] diff --git a/crates/ide-github/src/model.rs b/crates/ide-github/src/model.rs index e2b4b9aa..d134abd3 100644 --- a/crates/ide-github/src/model.rs +++ b/crates/ide-github/src/model.rs @@ -34,6 +34,12 @@ pub struct GhRepo { pub name_with_owner: String, pub url: String, pub default_branch: Option, + /// Merge methods the repository allows (all of them when gh does not say). + pub merge_commit_allowed: bool, + pub squash_merge_allowed: bool, + pub rebase_merge_allowed: bool, + /// The signed-in user's permission: `ADMIN`, `MAINTAIN`, `WRITE`, `TRIAGE`, `READ`; empty when unknown. + pub viewer_permission: String, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] @@ -152,6 +158,8 @@ pub struct PullRequestDetails { pub base_oid: String, /// `MERGEABLE`, `CONFLICTING`, `UNKNOWN`. pub mergeable: String, + /// GitHub's merge box state: `CLEAN`, `UNSTABLE`, `HAS_HOOKS`, `BLOCKED`, `BEHIND`, `DIRTY`, `DRAFT`, `UNKNOWN`. + pub merge_state_status: String, pub is_cross_repository: bool, pub merged_at: Option, pub closed_at: Option, @@ -242,7 +250,7 @@ impl RawSummary { pub const LIST_FIELDS: &str = "number,title,author,state,isDraft,headRefName,baseRefName,createdAt,updatedAt,labels,reviewDecision,url,additions,deletions"; /// Fields requested from `gh pr view`. -pub const VIEW_FIELDS: &str = "number,title,author,state,isDraft,headRefName,baseRefName,createdAt,updatedAt,labels,reviewDecision,url,additions,deletions,body,headRefOid,baseRefOid,mergeable,isCrossRepository,mergedAt,closedAt,statusCheckRollup,reviewRequests,reviews,assignees,commits,files,comments"; +pub const VIEW_FIELDS: &str = "number,title,author,state,isDraft,headRefName,baseRefName,createdAt,updatedAt,labels,reviewDecision,url,additions,deletions,body,headRefOid,baseRefOid,mergeable,mergeStateStatus,isCrossRepository,mergedAt,closedAt,statusCheckRollup,reviewRequests,reviews,assignees,commits,files,comments"; pub fn parse_list(json: &str) -> serde_json::Result> { let raw: Vec = serde_json::from_str(json)?; @@ -348,6 +356,7 @@ struct RawDetails { head_ref_oid: String, base_ref_oid: String, mergeable: String, + merge_state_status: String, is_cross_repository: bool, merged_at: Option, closed_at: Option, @@ -442,6 +451,7 @@ pub fn parse_view(json: &str) -> serde_json::Result { head_oid: raw.head_ref_oid, base_oid: raw.base_ref_oid, mergeable: raw.mergeable, + merge_state_status: raw.merge_state_status, is_cross_repository: raw.is_cross_repository, merged_at: raw.merged_at.filter(|s| !s.is_empty()), closed_at: raw.closed_at.filter(|s| !s.is_empty()), @@ -534,6 +544,10 @@ struct RawRepo { name_with_owner: String, url: String, default_branch_ref: Option, + merge_commit_allowed: Option, + squash_merge_allowed: Option, + rebase_merge_allowed: Option, + viewer_permission: Option, } #[derive(Deserialize, Default)] @@ -542,13 +556,21 @@ struct RawBranchRef { name: String, } -/// `gh repo view --json nameWithOwner,url,defaultBranchRef`. +/// Fields requested from `gh repo view`. +pub const REPO_FIELDS: &str = + "nameWithOwner,url,defaultBranchRef,mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed,viewerPermission"; + +/// `gh repo view --json` with [`REPO_FIELDS`]. pub fn parse_repo(json: &str) -> serde_json::Result { let raw: RawRepo = serde_json::from_str(json)?; Ok(GhRepo { name_with_owner: raw.name_with_owner, url: raw.url, default_branch: raw.default_branch_ref.map(|b| b.name).filter(|n| !n.is_empty()), + merge_commit_allowed: raw.merge_commit_allowed.unwrap_or(true), + squash_merge_allowed: raw.squash_merge_allowed.unwrap_or(true), + rebase_merge_allowed: raw.rebase_merge_allowed.unwrap_or(true), + viewer_permission: raw.viewer_permission.unwrap_or_default(), }) } diff --git a/crates/ide-github/src/process.rs b/crates/ide-github/src/process.rs index f1dc29f4..d5eec750 100644 --- a/crates/ide-github/src/process.rs +++ b/crates/ide-github/src/process.rs @@ -60,9 +60,30 @@ fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle) -> IdeResult { +pub(crate) fn run(cmd: Command, timeout: Duration, cancel: Option<&CancellationToken>) -> IdeResult { + run_with_input(cmd, None, timeout, cancel) +} + +/// [`run`] with `input` written to the child's stdin (then closed). +pub(crate) fn run_with_input( + mut cmd: Command, + input: Option<&str>, + timeout: Duration, + cancel: Option<&CancellationToken>, +) -> IdeResult { prepare(&mut cmd); + if input.is_some() { + cmd.stdin(Stdio::piped()); + } let mut child = spawn(&mut cmd)?; + if let (Some(input), Some(mut stdin)) = (input, child.stdin.take()) { + let input = input.to_owned(); + // A writer thread: a large body must not block on a full pipe while gh is not reading yet. + std::thread::spawn(move || { + use std::io::Write; + let _ = stdin.write_all(input.as_bytes()); + }); + } let out = child.stdout.take().map(read_all); let err = child.stderr.take().map(read_all); let deadline = Instant::now() + timeout; diff --git a/crates/ide-github/tests/fixtures/pr_view.json b/crates/ide-github/tests/fixtures/pr_view.json index 12a35510..0bae73d5 100644 --- a/crates/ide-github/tests/fixtures/pr_view.json +++ b/crates/ide-github/tests/fixtures/pr_view.json @@ -1 +1 @@ -{"additions":42,"assignees":[],"author":{"id":"MDQ6VXNlcjM2NzI4OTMx","is_bot":false,"login":"babakks","name":"Babak K. Shandiz"},"baseRefName":"trunk","baseRefOid":"ae7b9715976c5508398b11dc8546e1279e10f6fc","body":"### Description\n\nThis PR moves gh to the tagged version of `cli/oauth`.","closedAt":"2026-10-21T12:29:58Z","comments":[{"id":"IC_1","author":{"login":"babakks"},"authorAssociation":"MEMBER","body":"Rebased on trunk.","createdAt":"2026-10-21T08:00:00Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/cli/cli/pull/14768#issuecomment-1","viewerDidAuthor":false}],"commits":[{"authoredDate":"2026-10-20T16:37:24Z","authors":[{"email":"babakks@github.com","id":"MDQ6VXNlcjM2NzI4OTMx","login":"babakks","name":"Babak K. Shandiz"}],"committedDate":"2026-10-21T12:06:51Z","messageBody":"cli/oauth v1.3.0 exposes the token expiry fields as time.Duration values.","messageHeadline":"chore(deps): bump github.com/cli/oauth to v1.3.0","oid":"fd1b1feb222dde885d00262b2c21b107c6c6e416"}],"createdAt":"2026-10-20T16:40:41Z","deletions":48,"files":[{"path":"go.mod","additions":1,"deletions":1,"changeType":"MODIFIED"},{"path":"pkg/cmd/status/status.go","additions":3,"deletions":2,"changeType":"MODIFIED"}],"headRefName":"babakks/refresh-token-bump-deps","headRefOid":"6ec1ba54542baca6cc5932582634104c83f36a28","isCrossRepository":false,"isDraft":false,"labels":[],"mergeable":"UNKNOWN","mergedAt":"2026-10-21T12:29:58Z","number":14768,"reviewDecision":"APPROVED","reviewRequests":[{"__typename":"User","login":"niik"}],"reviews":[{"id":"PRR_1","author":{"login":"copilot-pull-request-reviewer"},"authorAssociation":"CONTRIBUTOR","body":"","submittedAt":"2026-10-21T09:46:01Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"831b9343fa77c2f11f521a49fd01da2d2d007b8e"}},{"id":"PRR_2","author":{"login":"williammartin"},"authorAssociation":"MEMBER","body":"LGTM","submittedAt":"2026-10-21T12:20:00Z","includesCreatedEdit":false,"reactionGroups":[],"state":"APPROVED","commit":{"oid":"6ec1ba54542baca6cc5932582634104c83f36a28"}}],"state":"MERGED","statusCheckRollup":[{"__typename":"CheckRun","completedAt":"2026-10-21T12:09:03Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/1","name":"build (ubuntu)","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":"Tests"},{"__typename":"CheckRun","completedAt":"0001-01-01T00:00:00Z","conclusion":"","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/2","name":"build (windows)","startedAt":"2026-10-21T12:07:01Z","status":"IN_PROGRESS","workflowName":"Tests"},{"__typename":"CheckRun","completedAt":"2026-10-21T12:08:00Z","conclusion":"FAILURE","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/3","name":"lint","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":"Lint"},{"__typename":"CheckRun","completedAt":"2026-10-21T12:08:00Z","conclusion":"SKIPPED","detailsUrl":"","name":"docs","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":""},{"__typename":"StatusContext","context":"ci/legacy","state":"PENDING","targetUrl":"https://ci.example.com/1","startedAt":"2026-10-21T12:07:01Z"}],"title":"Refreshable tokens: bump cli/oauth to v1.3.0 and fix `gh status` column width","updatedAt":"2026-10-21T12:30:00Z","url":"https://github.com/cli/cli/pull/14768"} +{"additions":42,"assignees":[],"author":{"id":"MDQ6VXNlcjM2NzI4OTMx","is_bot":false,"login":"babakks","name":"Babak K. Shandiz"},"baseRefName":"trunk","baseRefOid":"ae7b9715976c5508398b11dc8546e1279e10f6fc","body":"### Description\n\nThis PR moves gh to the tagged version of `cli/oauth`.","closedAt":"2026-10-21T12:29:58Z","comments":[{"id":"IC_1","author":{"login":"babakks"},"authorAssociation":"MEMBER","body":"Rebased on trunk.","createdAt":"2026-10-21T08:00:00Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/cli/cli/pull/14768#issuecomment-1","viewerDidAuthor":false}],"commits":[{"authoredDate":"2026-10-20T16:37:24Z","authors":[{"email":"babakks@github.com","id":"MDQ6VXNlcjM2NzI4OTMx","login":"babakks","name":"Babak K. Shandiz"}],"committedDate":"2026-10-21T12:06:51Z","messageBody":"cli/oauth v1.3.0 exposes the token expiry fields as time.Duration values.","messageHeadline":"chore(deps): bump github.com/cli/oauth to v1.3.0","oid":"fd1b1feb222dde885d00262b2c21b107c6c6e416"}],"createdAt":"2026-10-20T16:40:41Z","deletions":48,"files":[{"path":"go.mod","additions":1,"deletions":1,"changeType":"MODIFIED"},{"path":"pkg/cmd/status/status.go","additions":3,"deletions":2,"changeType":"MODIFIED"}],"headRefName":"babakks/refresh-token-bump-deps","headRefOid":"6ec1ba54542baca6cc5932582634104c83f36a28","isCrossRepository":false,"isDraft":false,"labels":[],"mergeable":"UNKNOWN","mergeStateStatus":"UNKNOWN","mergedAt":"2026-10-21T12:29:58Z","number":14768,"reviewDecision":"APPROVED","reviewRequests":[{"__typename":"User","login":"niik"}],"reviews":[{"id":"PRR_1","author":{"login":"copilot-pull-request-reviewer"},"authorAssociation":"CONTRIBUTOR","body":"","submittedAt":"2026-10-21T09:46:01Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"831b9343fa77c2f11f521a49fd01da2d2d007b8e"}},{"id":"PRR_2","author":{"login":"williammartin"},"authorAssociation":"MEMBER","body":"LGTM","submittedAt":"2026-10-21T12:20:00Z","includesCreatedEdit":false,"reactionGroups":[],"state":"APPROVED","commit":{"oid":"6ec1ba54542baca6cc5932582634104c83f36a28"}}],"state":"MERGED","statusCheckRollup":[{"__typename":"CheckRun","completedAt":"2026-10-21T12:09:03Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/1","name":"build (ubuntu)","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":"Tests"},{"__typename":"CheckRun","completedAt":"0001-01-01T00:00:00Z","conclusion":"","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/2","name":"build (windows)","startedAt":"2026-10-21T12:07:01Z","status":"IN_PROGRESS","workflowName":"Tests"},{"__typename":"CheckRun","completedAt":"2026-10-21T12:08:00Z","conclusion":"FAILURE","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/3","name":"lint","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":"Lint"},{"__typename":"CheckRun","completedAt":"2026-10-21T12:08:00Z","conclusion":"SKIPPED","detailsUrl":"","name":"docs","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":""},{"__typename":"StatusContext","context":"ci/legacy","state":"PENDING","targetUrl":"https://ci.example.com/1","startedAt":"2026-10-21T12:07:01Z"}],"title":"Refreshable tokens: bump cli/oauth to v1.3.0 and fix `gh status` column width","updatedAt":"2026-10-21T12:30:00Z","url":"https://github.com/cli/cli/pull/14768"} diff --git a/crates/ide-github/tests/parse.rs b/crates/ide-github/tests/parse.rs index 407af242..4b6226cd 100644 --- a/crates/ide-github/tests/parse.rs +++ b/crates/ide-github/tests/parse.rs @@ -22,6 +22,7 @@ fn pr_view() { assert_eq!(pr.summary.number, 14768); assert_eq!(pr.summary.head_ref, "babakks/refresh-token-bump-deps"); assert_eq!(pr.head_oid, "6ec1ba54542baca6cc5932582634104c83f36a28"); + assert_eq!(pr.merge_state_status, "UNKNOWN"); assert_eq!(pr.files.len(), 2); assert_eq!(pr.files[0].change_type, "MODIFIED"); assert_eq!(pr.commits[0].headline, "chore(deps): bump github.com/cli/oauth to v1.3.0"); @@ -69,6 +70,16 @@ fn repo_view() { .unwrap(); assert_eq!(r.name_with_owner, "l0ng-ai/openidea"); assert_eq!(r.default_branch.as_deref(), Some("main")); + assert!(r.merge_commit_allowed && r.squash_merge_allowed && r.rebase_merge_allowed, "unknown: allowed"); + assert_eq!(r.viewer_permission, ""); + + let r = parse_repo( + r#"{"mergeCommitAllowed":true,"nameWithOwner":"o/r","url":"","rebaseMergeAllowed":false,"squashMergeAllowed":false,"viewerPermission":"WRITE"}"#, + ) + .unwrap(); + assert!(r.merge_commit_allowed); + assert!(!r.squash_merge_allowed && !r.rebase_merge_allowed); + assert_eq!(r.viewer_permission, "WRITE"); } #[test] @@ -87,3 +98,24 @@ fn list_arguments() { assert!(joined.contains("--limit 500 --state merged --author @me")); assert_eq!(args.last().map(String::as_str), Some("review-requested:@me fix login")); } + +#[test] +fn api_arguments() { + let req = + GhApiRequest { method: "post".into(), endpoint: "graphql".into(), body: Some("{}".into()), paginate: false }; + assert_eq!(api_args(&req).unwrap(), ["api", "--method", "POST", "graphql", "--input", "-"]); + let req = GhApiRequest { + method: "GET".into(), + endpoint: "repos/{owner}/{repo}/labels".into(), + body: None, + paginate: true, + }; + assert_eq!( + api_args(&req).unwrap(), + ["api", "--method", "GET", "repos/{owner}/{repo}/labels", "--paginate", "--slurp"] + ); + for bad in ["--hostname", "https://evil.example/x", ""] { + assert!(api_args(&GhApiRequest { endpoint: bad.into(), ..req.clone() }).is_err(), "{bad}"); + } + assert!(api_args(&GhApiRequest { method: "TRACE".into(), ..req }).is_err()); +} diff --git a/crates/ide-host/src/ops/vcs/commands.rs b/crates/ide-host/src/ops/vcs/commands.rs index 68941b0a..5eaa490a 100644 --- a/crates/ide-host/src/ops/vcs/commands.rs +++ b/crates/ide-host/src/ops/vcs/commands.rs @@ -736,6 +736,19 @@ pub async fn vcs_diff_revisions( blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await } +/// The best common ancestor of `a` and `b` (`git merge-base`), if any: the New Pull +/// Request tab's three-dot diff. +pub async fn vcs_merge_base( + host: &Host, + window: &str, + repo: Option, + a: String, + b: String, +) -> IdeResult> { + let project = host.project(window)?; + blocking(move || project.with_repo_at(repo.as_deref(), |r| r.merge_base(&a, &b))).await +} + pub async fn vcs_create_patch( host: &Host, window: &str, @@ -976,6 +989,9 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("vcs_diff_revisions", |c| async move { vcs_diff_revisions(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?).await }); + d.add("vcs_merge_base", |c| async move { + vcs_merge_base(&c.host, &c.window, c.arg("repo")?, c.arg("a")?, c.arg("b")?).await + }); d.add("vcs_create_patch", |c| async move { vcs_create_patch(&c.host, &c.window, c.arg("repo")?, c.arg("source")?).await }); diff --git a/src-tauri/src/commands/github.rs b/src-tauri/src/commands/github.rs index 407ccf6d..0ff90435 100644 --- a/src-tauri/src/commands/github.rs +++ b/src-tauri/src/commands/github.rs @@ -3,7 +3,7 @@ use crate::state::AppState; use ide_core::{IdeError, IdeResult, RequestId}; use ide_github::{ - CreatePullRequest, GhAvailability, PrDiffRevisions, PrListQuery, PrReviewComment, PullRequestDetails, + CreatePullRequest, GhApiRequest, GhAvailability, PrDiffRevisions, PrListQuery, PrReviewComment, PullRequestDetails, PullRequestSummary, ReviewEvent, }; use std::path::PathBuf; @@ -128,6 +128,14 @@ pub async fn gh_pr_comment(window: Window, state: State<'_, AppState>, number: u blocking(move || ide_github::add_comment(&root, number, &body)).await } +/// A GitHub GraphQL / REST call through `gh api`; returns the response body. +#[tauri::command] +#[specta::specta] +pub async fn gh_api(window: Window, state: State<'_, AppState>, request: GhApiRequest) -> IdeResult { + let root = root(&window, &state)?; + blocking(move || ide_github::api(&root, &request)).await +} + /// Fetches what is missing to diff a pull request locally; returns the revisions to compare. #[tauri::command] #[specta::specta] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 9ff891c8..0799187d 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -265,6 +265,7 @@ pub fn builder() -> tauri_specta::Builder { vcs::vcs_remove_worktree, vcs::vcs_diff_with_worktree, vcs::vcs_diff_revisions, + vcs::vcs_merge_base, vcs::vcs_create_patch, vcs::vcs_patch_preview, vcs::vcs_apply_patch, @@ -415,6 +416,7 @@ pub fn builder() -> tauri_specta::Builder { docker::docker_registry_login, docker::docker_run_env, github::gh_availability, + github::gh_api, github::gh_pr_list, github::gh_pr_view, github::gh_pr_review_comments, diff --git a/src-tauri/src/commands/vcs.rs b/src-tauri/src/commands/vcs.rs index e5be5991..cfde2042 100644 --- a/src-tauri/src/commands/vcs.rs +++ b/src-tauri/src/commands/vcs.rs @@ -535,6 +535,19 @@ pub async fn vcs_diff_revisions( ide_host::ops::vcs::vcs_diff_revisions(&state.host, window.label(), repo, from, to).await } +/// The best common ancestor of `a` and `b` (`git merge-base`), if any. +#[tauri::command] +#[specta::specta] +pub async fn vcs_merge_base( + window: Window, + state: State<'_, AppState>, + repo: Option, + a: String, + b: String, +) -> IdeResult> { + ide_host::ops::vcs::vcs_merge_base(&state.host, window.label(), repo, a, b).await +} + #[tauri::command] #[specta::specta] pub async fn vcs_create_patch( diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 6ec34984..cae1c98e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -30,7 +30,7 @@ } ], "security": { - "csp": "default-src 'self'; img-src 'self' asset: data: blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src ipc: http://ipc.localhost; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; frame-src http://127.0.0.1:*" + "csp": "default-src 'self'; img-src 'self' asset: data: blob: https://avatars.githubusercontent.com; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src ipc: http://ipc.localhost; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; frame-src http://127.0.0.1:*" } }, "bundle": { diff --git a/src/bindings.ts b/src/bindings.ts index ae60c794..e930e76a 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -503,6 +503,8 @@ export const commands = { vcsDiffWithWorktree: (repo: string | null, rev: string) => typedError(__TAURI_INVOKE("vcs_diff_with_worktree", { repo, rev })), /** Files that differ between two revisions (the "N files updated" of Update Project). */ vcsDiffRevisions: (repo: string | null, from: string, to: string) => typedError(__TAURI_INVOKE("vcs_diff_revisions", { repo, from, to })), + /** The best common ancestor of `a` and `b` (`git merge-base`), if any. */ + vcsMergeBase: (repo: string | null, a: string, b: string) => typedError(__TAURI_INVOKE("vcs_merge_base", { repo, a, b })), vcsCreatePatch: (repo: string | null, source: PatchSource) => typedError(__TAURI_INVOKE("vcs_create_patch", { repo, source })), vcsPatchPreview: (repo: string | null, patch: string) => typedError(__TAURI_INVOKE("vcs_patch_preview", { repo, patch })), vcsApplyPatch: (repo: string | null, patch: string) => typedError(__TAURI_INVOKE("vcs_apply_patch", { repo, patch })), @@ -897,6 +899,8 @@ export const commands = { */ dockerRunEnv: (endpoint: DockerEndpoint) => typedError<{ [key in string]: string }, IdeError>(__TAURI_INVOKE("docker_run_env", { endpoint })), ghAvailability: () => typedError(__TAURI_INVOKE("gh_availability")), + /** A GitHub GraphQL / REST call through `gh api`; returns the response body. */ + ghApi: (request: GhApiRequest) => typedError(__TAURI_INVOKE("gh_api", { request })), ghPrList: (query: PrListQuery) => typedError(__TAURI_INVOKE("gh_pr_list", { query })), ghPrView: (number: number) => typedError(__TAURI_INVOKE("gh_pr_view", { number })), ghPrReviewComments: (number: number) => typedError(__TAURI_INVOKE("gh_pr_review_comments", { number })), @@ -3121,6 +3125,21 @@ export type GeneralSettings = { projectName?: string, }; +/** + * A GitHub API call through `gh api`: GraphQL (`endpoint` `graphql`, `body` + * `{"query", "variables"}`) or REST (`repos/{owner}/{repo}/…`, gh fills in the + * placeholders from the project's remote). + */ +export type GhApiRequest = { + /** `GET`, `POST`, `PATCH`, `PUT`, `DELETE`. */ + method: string, + endpoint: string, + /** JSON request body, sent on stdin. */ + body: string | null, + /** Follow REST `Link` pages; the result is the array of all pages (`--slurp`). */ + paginate: boolean, +}; + /** Whether pull requests can be shown for the project, and why not. */ export type GhAvailability = { kind: "ready"; repo: GhRepo; /** The signed-in user. */ @@ -3137,6 +3156,12 @@ export type GhRepo = { nameWithOwner: string, url: string, defaultBranch: string | null, + /** Merge methods the repository allows (all of them when gh does not say). */ + mergeCommitAllowed: boolean, + squashMergeAllowed: boolean, + rebaseMergeAllowed: boolean, + /** The signed-in user's permission: `ADMIN`, `MAINTAIN`, `WRITE`, `TRIAGE`, `READ`; empty when unknown. */ + viewerPermission: string, }; export type GitExecutableInfo = { @@ -4745,6 +4770,8 @@ export type PullRequestDetails = { baseOid: string, /** `MERGEABLE`, `CONFLICTING`, `UNKNOWN`. */ mergeable: string, + /** GitHub's merge box state: `CLEAN`, `UNSTABLE`, `HAS_HOOKS`, `BLOCKED`, `BEHIND`, `DIRTY`, `DRAFT`, `UNKNOWN`. */ + mergeStateStatus: string, isCrossRepository: boolean, mergedAt: string | null, closedAt: string | null, diff --git a/src/editor/api.ts b/src/editor/api.ts index 488782e1..8ce6db51 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -276,6 +276,33 @@ export class LaneMarker extends GutterMarker { } } +/** + * The floating row at the editor's top-right (IDEA's inspection widget area): widgets + * append their element to it (CSS `order` sorts them; lower first) and remove it when + * done. The inspection widget sits there; code review adds its Review Mode group. + * + * const el = document.createElement("div"); + * el.style.order = "-1"; + * editorCorner(view).appendChild(el); + */ +export function editorCorner(view: EditorView): HTMLElement { + const found = view.dom.querySelector(":scope > .cm-openidea-corner"); + if (found) return found; + const el = document.createElement("div"); + el.className = "cm-openidea-corner"; + Object.assign(el.style, { + position: "absolute", + top: "4px", + right: "16px", + zIndex: "5", + display: "flex", + alignItems: "center", + gap: "4px", + }); + view.dom.appendChild(el); + return el; +} + // --- language support without a language server ---------------------------------- /** diff --git a/src/editor/lsp/inspectionWidget.ts b/src/editor/lsp/inspectionWidget.ts index 52e06d16..31d2d0d2 100644 --- a/src/editor/lsp/inspectionWidget.ts +++ b/src/editor/lsp/inspectionWidget.ts @@ -6,6 +6,7 @@ import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view"; import { forEachDiagnostic } from "@codemirror/lint"; import { shortcutLabel } from "@/platform/keymap"; +import { editorCorner } from "@/editor/api"; import { toolWindowService } from "@/workbench/contributions"; import { gotoDiagnostic } from "./diagnostics"; import { iconNode } from "./iconMarkup"; @@ -50,7 +51,7 @@ class InspectionWidget { this.dom.className = "cm-openidea-inspection"; this.dom.setAttribute("role", "toolbar"); this.dom.setAttribute("aria-label", "Inspections"); - view.dom.appendChild(this.dom); + editorCorner(view).appendChild(this.dom); this.render(); } @@ -123,10 +124,6 @@ const inspectionPlugin = ViewPlugin.fromClass(InspectionWidget); const inspectionTheme = EditorView.theme({ ".cm-openidea-inspection": { - position: "absolute", - top: "4px", - right: "16px", - zIndex: "5", display: "flex", alignItems: "center", gap: "1px", diff --git a/src/features/github/Github.module.css b/src/features/github/Github.module.css deleted file mode 100644 index 28bcfbfc..00000000 --- a/src/features/github/Github.module.css +++ /dev/null @@ -1,371 +0,0 @@ -.view { - height: 100%; - display: flex; - flex-direction: column; - min-height: 0; -} -.split { - flex: 1; - min-height: 0; -} -.flex { - flex: 1; -} -.dim { - color: var(--fg-subtle); -} -.hint { - margin-top: 6px; - color: var(--fg-subtle); - font-size: var(--fs-small); -} -.error { - padding: 10px 12px; - color: var(--danger); -} -.loading { - display: flex; - align-items: center; - gap: 8px; - padding: 12px; - color: var(--fg-muted); -} -.ellipsis { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.repo { - min-width: 0; - margin-left: 8px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: var(--fg-subtle); - font-size: var(--fs-small); -} -.banner { - flex: none; - display: flex; - align-items: center; - gap: 6px; - min-height: 28px; - padding: 2px 6px 2px 10px; - font-size: var(--fs-small); - color: var(--fg-secondary); - border-bottom: 1px solid var(--border); -} -.bannerText { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-family: var(--font-mono); -} - -/* List pane */ -.listPane { - height: 100%; - display: flex; - flex-direction: column; - min-height: 0; -} -.filters { - flex: none; - display: flex; - flex-direction: column; - gap: 6px; - padding: 6px 8px; - border-bottom: 1px solid var(--border); -} -.search { - width: 100%; -} -.filterRow { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 6px 10px; - min-height: var(--h-control); -} -.filterRow > :not(.flex) { - flex: none; -} -.list { - flex: 1; - min-height: 0; -} -.listRow { - display: flex; - flex-direction: column; - justify-content: center; - gap: 2px; - min-width: 0; - width: 100%; - padding: 0 4px; -} -.listTitle { - display: flex; - align-items: center; - gap: 6px; - color: var(--fg-strong); -} -.listMeta { - color: var(--fg-subtle); - font-size: var(--fs-small); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* Details pane */ -.details { - height: 100%; - display: flex; - flex-direction: column; - min-height: 0; -} -.header { - flex: none; - display: flex; - flex-direction: column; - gap: 6px; - padding: 10px 12px 8px; -} -.titleRow { - display: flex; - align-items: center; - gap: 8px; -} -.title { - margin: 0; - font-size: var(--fs-input-large); - font-weight: 600; - color: var(--fg-strong); - -webkit-user-select: text; - user-select: text; -} -.metaRow { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 6px; - color: var(--fg-secondary); - font-size: var(--fs-small); -} -.conflict { - color: var(--danger); -} -.actions { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 6px; -} -.actions > :not(.flex) { - flex: none; -} -.scroll { - flex: 1; - min-height: 0; - overflow: auto; - padding: 10px 12px; - -webkit-user-select: text; - user-select: text; -} -.section { - margin-top: 14px; - padding-top: 10px; - border-top: 1px solid var(--border); -} -.sectionTitle { - margin: 0 0 6px; - font-size: var(--fs-small); - font-weight: 600; - color: var(--fg-secondary); -} -.checkRow, -.commitRow { - display: flex; - align-items: center; - gap: 8px; - min-height: var(--h-row); -} -.check-success, -.review-APPROVED { - color: var(--success); -} -.check-failure, -.review-CHANGES_REQUESTED { - color: var(--danger); -} -.check-pending, -.review-REQUESTED { - color: var(--warning); -} -.check-skipped, -.check-cancelled, -.check-neutral, -.review-COMMENTED, -.review-DISMISSED { - color: var(--fg-subtle); -} -.labels { - display: flex; - flex-wrap: wrap; - gap: 4px; - margin-bottom: 6px; -} -.label { - padding: 0 6px; - border: 1px solid var(--border-strong); - border-radius: var(--radius-small); - font-size: var(--fs-small); - color: var(--fg-secondary); -} -.link { - padding: 0; - border: none; - background: none; - color: var(--link); - font: inherit; - cursor: pointer; -} -.link:hover { - color: var(--link-hover); - text-decoration: underline; -} -.fileRow { - display: flex; - align-items: center; - gap: 6px; - width: 100%; - min-height: var(--h-row); - padding: 0 4px; - border: none; - border-radius: var(--radius-row); - background: none; - color: var(--fg); - font: inherit; - text-align: left; -} -.fileRow:hover { - background: var(--bg-tree-hover); -} -.fileName { - flex: none; -} -.change-ADDED { - color: var(--vcs-added); -} -.change-DELETED { - color: var(--vcs-deleted); -} -.change-MODIFIED, -.change-CHANGED, -.change-RENAMED, -.change-COPIED { - color: var(--vcs-modified); -} -.added { - color: var(--vcs-added); - font-family: var(--font-mono); - font-size: var(--fs-small); -} -.deleted { - color: var(--vcs-deleted); - font-family: var(--font-mono); - font-size: var(--fs-small); -} -.oid { - font-family: var(--font-mono); - font-size: var(--fs-small); - color: var(--fg-subtle); -} -.comment { - margin-bottom: 10px; - padding: 8px 10px; - border: 1px solid var(--border); - border-radius: var(--radius-control); -} -.commentHead { - display: flex; - align-items: center; - gap: 8px; - font-size: var(--fs-small); - color: var(--fg-secondary); -} -.threadItem { - margin-top: 8px; -} -.threadItem + .threadItem { - padding-top: 8px; - border-top: 1px solid var(--border); -} -.newComment { - display: flex; - flex-direction: column; - gap: 6px; - margin-top: 10px; -} -.markdown { - color: var(--fg); - line-height: 1.5; - overflow-wrap: anywhere; -} -.markdown :global(pre) { - padding: 6px 8px; - overflow: auto; - background: var(--bg-editor); - border-radius: var(--radius-small); - font-family: var(--font-mono); - font-size: var(--fs-code); -} -.markdown :global(code) { - font-family: var(--font-mono); -} -.markdown :global(a) { - color: var(--link); -} -.markdown :global(img) { - max-width: 100%; -} -.markdown :global(h1), -.markdown :global(h2), -.markdown :global(h3) { - font-size: var(--fs-ui); - color: var(--fg-strong); -} - -/* Dialogs */ -.form { - display: flex; - flex-direction: column; - gap: 8px; - padding: 14px; -} -.formRow { - display: flex; - align-items: center; - gap: 10px; -} -.fieldLabel { - color: var(--fg-secondary); - font-size: var(--fs-small); -} -.textarea { - resize: vertical; - min-height: 48px; - padding: 4px 8px; - border: 1px solid var(--border); - border-radius: var(--radius-small); - background: var(--bg-input); - color: var(--fg); - font: inherit; -} -.textarea:focus { - outline: none; - border-color: var(--focus-ring); -} diff --git a/src/features/github/PullRequestsView.tsx b/src/features/github/PullRequestsView.tsx deleted file mode 100644 index 7469fd55..00000000 --- a/src/features/github/PullRequestsView.tsx +++ /dev/null @@ -1,609 +0,0 @@ -/** The Pull Requests tool window: filterable list on the left, the selected pull request on the right. */ -import { useEffect, useMemo, useState, type MouseEvent } from "react"; -import clsx from "clsx"; -import type { GhAvailability, PrCheck, PrStateFilter, PullRequestDetails, PullRequestSummary } from "@/platform/ipc"; -import { executeAction } from "@/platform/actions"; -import { useMarkupHtml } from "@/editor/lsp/markdown"; -import { - Badge, - Button, - Checkbox, - EmptyState, - Icon, - IconButton, - List, - SearchField, - Select, - SplitGroup, - SplitHandle, - SplitPanel, - Spinner, - Tabs, - showContextMenu, - type MenuItem, - T, -} from "@/ui"; -import { - checksSummary, - checksText, - relativeTime, - reviewDecisionLabel, - reviewStateLabel, - reviewThreads, - stateLabel, -} from "./model"; -import { addComment, checkoutPullRequest, openCommentLocation, openFileDiff, openUrl, submitReview } from "./ops"; -import { cancelBusy, loadDetails, refreshAll, select, setQuery, useGithub, type DetailTab } from "./store"; -import styles from "./Github.module.css"; -import { t, tx } from "@/platform/i18n"; - -const STATES: PrStateFilter[] = ["open", "merged", "closed", "all"]; - -function Unavailable({ availability }: { availability: GhAvailability }) { - const retry = ( - - ); - switch (availability.kind) { - case "notInstalled": - return ( - - - {retry} - - } - > - Pull requests use the gh command line tool. Install it, then run{" "} - gh auth login. - - ); - case "notLoggedIn": - return ( - - - {retry} - - } - > - Run gh auth login in a terminal, then retry. - - ); - case "notGitHub": - return ( - -
- This project has no remote on GitHub. -
-
{availability.message}
-
- ); - case "error": - return ( - - {availability.message} - - ); - default: - return null; - } -} - -function StateBadge({ pr }: { pr: PullRequestSummary }) { - const tone = pr.state === "merged" ? "accent" : pr.state === "closed" ? "danger" : pr.isDraft ? "neutral" : "success"; - return {stateLabel(pr)}; -} - -function Filters() { - const query = useGithub((s) => s.query); - const loading = useGithub((s) => s.listLoading); - const [search, setSearch] = useState(query.search); - useEffect(() => { - if (search === useGithub.getState().query.search) return; - const t = setTimeout(() => setQuery({ search }), 400); - return () => clearTimeout(t); - }, [search]); - return ( -
- -
-