дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
feat(vcs): git UX fixes for history, remotes, changelists, branch diff and merge view (#369)
- Git › Show History is offered for folders, multi-selection and the project root, not just single files. - The Push dialog lets you pick any configured remote; pushing to another remote keeps the upstream's branch name and shows outgoing commits against that remote's branch. - The Changes toolbar gains New Changelist (changelists already supported new/edit/delete/set active/move). - Compare with Current gains Show Diff, opening the whole-project diff between the branch and HEAD. - Merge view: F7 / Shift+F7 navigate conflicts, the three panes scroll together, and each side pane can be annotated with Git Blame of its own content. - Windows-only test fixes in ide-lsp and ide-sdk. Fixes #343 Fixes #310 Fixes #358 Fixes #348 Fixes #320 Refs #317 Co-authored-by: a-rookie-of-C-language <[email protected]>
Цей коміт міститься в:
1 джерело
545fb93744
коміт
3655961642
18 files changed
+256
-68
No files matched your search
@@ -45,13 +45,16 @@ pub enum RemoteOp {
|
||||
branch: String,
|
||||
},
|
||||
/// Pushes local `branch` (only up to `revision` when set) to its upstream, or to
|
||||
/// remote branch `target` of the same remote.
|
||||
/// remote branch `target`; `remote` overrides the upstream's remote (Push dialog's
|
||||
/// remote picker).
|
||||
PushBranch {
|
||||
branch: String,
|
||||
revision: Option<String>,
|
||||
force_with_lease: bool,
|
||||
#[serde(default)]
|
||||
target: Option<String>,
|
||||
#[serde(default)]
|
||||
remote: Option<String>,
|
||||
},
|
||||
/// Fast-forwards local `branch` (not checked out) to its upstream.
|
||||
UpdateBranch {
|
||||
@@ -198,9 +201,10 @@ pub async fn vcs_file_at(host: &Host, window: &str, path: PathBuf, rev: Option<S
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn vcs_blame(host: &Host, window: &str, path: PathBuf) -> IdeResult<Vec<BlameLine>> {
|
||||
pub async fn vcs_blame(host: &Host, window: &str, path: PathBuf, text: Option<String>) -> IdeResult<Vec<BlameLine>> {
|
||||
let project = host.project(window)?;
|
||||
let text = project.documents.text_of(&path);
|
||||
// An explicit text wins (merge view annotates its side panes, not the worktree).
|
||||
let text = text.or_else(|| project.documents.text_of(&path));
|
||||
blocking(move || {
|
||||
project.with_repo_of(&path, |r| r.blame(&path, text.as_deref(), project.vcs_encoding(&path, None)?))
|
||||
})
|
||||
@@ -455,12 +459,17 @@ pub async fn vcs_remote(
|
||||
r.push_with_tags(force_with_lease, set_upstream, tags, &cancel, &out)
|
||||
}
|
||||
RemoteOp::DeleteBranch { branch } => r.delete_remote_branch(&branch, &cancel, &out),
|
||||
RemoteOp::PushBranch { branch, revision, force_with_lease, target } => match target {
|
||||
Some(target) => {
|
||||
r.push_branch_to(&branch, revision.as_deref(), &target, force_with_lease, &cancel, &out)
|
||||
}
|
||||
None => r.push_branch(&branch, revision.as_deref(), force_with_lease, &cancel, &out),
|
||||
},
|
||||
RemoteOp::PushBranch { branch, revision, force_with_lease, target, remote } => r.push_branch_to(
|
||||
&branch,
|
||||
PushBranchOptions {
|
||||
revision: revision.as_deref(),
|
||||
target: target.as_deref(),
|
||||
remote: remote.as_deref(),
|
||||
force_with_lease,
|
||||
},
|
||||
&cancel,
|
||||
&out,
|
||||
),
|
||||
RemoteOp::UpdateBranch { branch } => r.update_branch(&branch, &cancel, &out),
|
||||
RemoteOp::PullFrom { remote, branches, rebase, options } => {
|
||||
r.pull_from(&remote, &branches, rebase, &options, &cancel, &out)
|
||||
@@ -828,7 +837,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("vcs_branch_info", |c| async move { vcs_branch_info(&c.host, &c.window, c.arg("repo")?).await });
|
||||
d.add("vcs_line_hunks", |c| async move { vcs_line_hunks(&c.host, &c.window, c.arg("path")?) });
|
||||
d.add("vcs_file_at", |c| async move { vcs_file_at(&c.host, &c.window, c.arg("path")?, c.arg("rev")?).await });
|
||||
d.add("vcs_blame", |c| async move { vcs_blame(&c.host, &c.window, c.arg("path")?).await });
|
||||
d.add("vcs_blame", |c| async move { vcs_blame(&c.host, &c.window, c.arg("path")?, c.arg("text")?).await });
|
||||
d.add("vcs_log", |c| async move { vcs_log(&c.host, &c.window, c.arg("repo")?, c.arg("query")?).await });
|
||||
d.add("vcs_commit_files", |c| async move {
|
||||
vcs_commit_files(&c.host, &c.window, c.arg("repo")?, c.arg("commit")?).await
|
||||
|
||||
@@ -96,7 +96,7 @@ async fn gbk_document_gutter_diff_stage_and_rollback_share_encoding() {
|
||||
project.documents.save(doc.id).unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), original);
|
||||
assert!(vcs::vcs_line_hunks(&host, "main", path.clone()).unwrap().is_empty());
|
||||
assert!(vcs::vcs_blame(&host, "main", path.clone()).await.unwrap().iter().all(|l| l.author == "Test"));
|
||||
assert!(vcs::vcs_blame(&host, "main", path.clone(), None).await.unwrap().iter().all(|l| l.author == "Test"));
|
||||
// Missing working files still use configured encoding for historical content.
|
||||
project.documents.close(doc.id);
|
||||
let mut defaults = project.documents.file_defaults();
|
||||
|
||||
@@ -204,7 +204,9 @@ mod tests {
|
||||
fn search_path_contains_env_path() {
|
||||
let p = search_path();
|
||||
let dirs: Vec<PathBuf> = std::env::split_paths(&p).collect();
|
||||
assert!(dirs.contains(&PathBuf::from("/usr/bin")));
|
||||
// The process's own `PATH` starts the search, then per-user and system dirs.
|
||||
let first_env = std::env::split_paths(&std::env::var_os("PATH").unwrap()).find(|d| d.is_absolute()).unwrap();
|
||||
assert_eq!(dirs.first(), Some(&first_env), "{dirs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,10 @@ mod tests {
|
||||
assert_eq!(found, Some(t.join(pkg).parent().unwrap().to_path_buf()), "{bin}");
|
||||
}
|
||||
let path = std::env::join_paths([t.join("tools/node_modules/.bin")]).unwrap();
|
||||
// npm's `.bin` shims on Windows get a `.cmd` (or `.exe`) name; the bare
|
||||
// name is not executable there.
|
||||
let shim = if cfg!(windows) { "vue-language-server.cmd" } else { SERVER };
|
||||
touch(&t.join("tools/node_modules/.bin").join(shim));
|
||||
assert_eq!(server_package(SERVER, &path), Some(t.join("tools/node_modules/@vue/language-server")));
|
||||
assert_eq!(server_package("not-installed", &path), None);
|
||||
}
|
||||
|
||||
@@ -237,6 +237,19 @@ pub enum PushTags {
|
||||
CurrentBranch,
|
||||
}
|
||||
|
||||
/// Options of [`Repo::push_branch_to`]: which commits to push and where to.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct PushBranchOptions<'a> {
|
||||
/// Push only up to this commit of the branch (`None`: the whole branch).
|
||||
pub revision: Option<&'a str>,
|
||||
/// Remote branch name (`None` keeps the upstream's / the local name).
|
||||
pub target: Option<&'a str>,
|
||||
/// Remote to push to (`None` keeps the upstream's / the default remote).
|
||||
pub remote: Option<&'a str>,
|
||||
/// `git push --force-with-lease`.
|
||||
pub force_with_lease: bool,
|
||||
}
|
||||
|
||||
/// What an interactive rebase does with one commit.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -781,20 +794,25 @@ impl Repo {
|
||||
cancel: &CancellationToken,
|
||||
on_output: &dyn Fn(String),
|
||||
) -> IdeResult<()> {
|
||||
write::push_branch(self, branch, revision, None, force_with_lease, cancel, on_output)
|
||||
write::push_branch(
|
||||
self,
|
||||
branch,
|
||||
PushBranchOptions { revision, force_with_lease, ..Default::default() },
|
||||
cancel,
|
||||
on_output,
|
||||
)
|
||||
}
|
||||
/// [`Repo::push_branch`] to remote branch `target` instead of the upstream's name (the
|
||||
/// Push dialog's editable target).
|
||||
/// [`Repo::push_branch`] to remote branch `target` (None keeps the upstream's name)
|
||||
/// and/or remote `remote` (None keeps the upstream's remote). The Push dialog's
|
||||
/// editable target and remote picker.
|
||||
pub fn push_branch_to(
|
||||
&self,
|
||||
branch: &str,
|
||||
revision: Option<&str>,
|
||||
target: &str,
|
||||
force_with_lease: bool,
|
||||
options: PushBranchOptions<'_>,
|
||||
cancel: &CancellationToken,
|
||||
on_output: &dyn Fn(String),
|
||||
) -> IdeResult<()> {
|
||||
write::push_branch(self, branch, revision, Some(target), force_with_lease, cancel, on_output)
|
||||
write::push_branch(self, branch, options, cancel, on_output)
|
||||
}
|
||||
/// Fast-forwards a local branch that is not checked out to its upstream (Branches ›
|
||||
/// Update). Fails when the branch has diverged.
|
||||
|
||||
@@ -4,7 +4,8 @@ use crate::cli::{self, run};
|
||||
use crate::repo::{gx, resolve};
|
||||
use crate::text_encoding::{decode, encode, has_bom, is_binary};
|
||||
use crate::{
|
||||
BlameLine, CheckoutMode, CheckoutOptions, CheckoutOutcome, CommitOptions, DeletedBranch, DivergedLocal, Repo,
|
||||
BlameLine, CheckoutMode, CheckoutOptions, CheckoutOutcome, CommitOptions, DeletedBranch, DivergedLocal,
|
||||
PushBranchOptions, Repo,
|
||||
};
|
||||
use encoding_rs::Encoding;
|
||||
use gix::bstr::{BStr, ByteSlice};
|
||||
@@ -750,22 +751,25 @@ fn check_local_branch(r: &Repo, branch: &str) -> IdeResult<()> {
|
||||
.map_err(|_| IdeError::invalid(format!("'{branch}' is not a local branch")))
|
||||
}
|
||||
|
||||
/// Pushes local `branch` (or only up to `revision`) to its upstream; a branch without
|
||||
/// one goes to the default remote under the same name and, when pushed whole, gets it
|
||||
/// as upstream.
|
||||
/// Pushes local `branch` (or only up to `opts.revision`) to its upstream; a branch
|
||||
/// without one goes to the default remote under the same name and, when pushed whole,
|
||||
/// gets it as upstream.
|
||||
pub(crate) fn push_branch(
|
||||
r: &Repo,
|
||||
branch: &str,
|
||||
revision: Option<&str>,
|
||||
target: Option<&str>,
|
||||
force_with_lease: bool,
|
||||
opts: PushBranchOptions<'_>,
|
||||
cancel: &CancellationToken,
|
||||
on_output: &dyn Fn(String),
|
||||
) -> IdeResult<()> {
|
||||
let PushBranchOptions { revision, target, remote: remote_override, force_with_lease } = opts;
|
||||
check_local_branch(r, branch)?;
|
||||
let (remote, mut name, set_upstream) = match branch_upstream(r, branch) {
|
||||
Some((remote, name)) => (remote, name, false),
|
||||
None => (push_remote(r)?, branch.to_string(), revision.is_none()),
|
||||
let remote_override = remote_override.map(str::trim).filter(|s| !s.is_empty());
|
||||
let (remote, mut name, set_upstream) = match (remote_override, branch_upstream(r, branch)) {
|
||||
// An explicit remote keeps the upstream's branch name but pushes elsewhere.
|
||||
(Some(remote), Some((_, name))) => (check_rev(remote)?.to_string(), name, false),
|
||||
(Some(remote), None) => (check_rev(remote)?.to_string(), branch.to_string(), revision.is_none()),
|
||||
(None, Some((remote, name))) => (remote, name, false),
|
||||
(None, None) => (push_remote(r)?, branch.to_string(), revision.is_none()),
|
||||
};
|
||||
if let Some(t) = target.map(str::trim).filter(|t| !t.is_empty()) {
|
||||
let valid =
|
||||
|
||||
@@ -498,10 +498,21 @@ fn push_branch_and_up_to_revision() {
|
||||
assert!(repo.push_branch("nope", None, false, &cancel, &out).is_err());
|
||||
|
||||
// An edited target: same remote, another branch name; the upstream stays as it was.
|
||||
repo.push_branch_to("main", None, "review/main", false, &cancel, &out).unwrap();
|
||||
fn opts<'a>(target: Option<&'a str>, remote: Option<&'a str>) -> PushBranchOptions<'a> {
|
||||
PushBranchOptions { target, remote, ..Default::default() }
|
||||
}
|
||||
repo.push_branch_to("main", opts(Some("review/main"), None), &cancel, &out).unwrap();
|
||||
assert_eq!(git_in(&bare, &["rev-parse", "review/main"]), c3);
|
||||
assert_eq!(fx.git(&["rev-parse", "--abbrev-ref", "main@{upstream}"]), "origin/main");
|
||||
assert!(repo.push_branch_to("main", None, "bad..name", false, &cancel, &out).is_err());
|
||||
assert!(repo.push_branch_to("main", opts(Some("bad..name"), None), &cancel, &out).is_err());
|
||||
|
||||
// A chosen remote: same branch name, pushed to the other remote.
|
||||
let bare2 = fx._tmp.path().join("backup.git");
|
||||
git_in(fx._tmp.path(), &["init", "-q", "--bare", bare2.to_str().unwrap()]);
|
||||
fx.git(&["remote", "add", "backup", bare2.to_str().unwrap()]);
|
||||
repo.push_branch_to("main", opts(None, Some("backup")), &cancel, &out).unwrap();
|
||||
assert_eq!(git_in(&bare2, &["rev-parse", "main"]), c3);
|
||||
assert_eq!(fx.git(&["rev-parse", "--abbrev-ref", "main@{upstream}"]), "origin/main");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -52,8 +52,13 @@ pub async fn vcs_file_at(
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn vcs_blame(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<Vec<BlameLine>> {
|
||||
ide_host::ops::vcs::vcs_blame(&state.host, window.label(), path).await
|
||||
pub async fn vcs_blame(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
path: PathBuf,
|
||||
text: Option<String>,
|
||||
) -> IdeResult<Vec<BlameLine>> {
|
||||
ide_host::ops::vcs::vcs_blame(&state.host, window.label(), path, text).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -413,7 +413,7 @@ export const commands = {
|
||||
/** Gutter change markers for a file, computed against its current (unsaved) text. */
|
||||
vcsLineHunks: (path: string) => typedError<LineHunk[], IdeError>(__TAURI_INVOKE("vcs_line_hunks", { path })),
|
||||
vcsFileAt: (path: string, rev: string | null) => typedError<string | null, IdeError>(__TAURI_INVOKE("vcs_file_at", { path, rev })),
|
||||
vcsBlame: (path: string) => typedError<BlameLine[], IdeError>(__TAURI_INVOKE("vcs_blame", { path })),
|
||||
vcsBlame: (path: string, text: string | null) => typedError<BlameLine[], IdeError>(__TAURI_INVOKE("vcs_blame", { path, text })),
|
||||
vcsLog: (repo: string | null, query: LogQuery) => typedError<Commit[], IdeError>(__TAURI_INVOKE("vcs_log", { repo, query })),
|
||||
vcsCommitFiles: (repo: string | null, commit: string) => typedError<ChangedFile[], IdeError>(__TAURI_INVOKE("vcs_commit_files", { repo, commit })),
|
||||
/** Combined changes of several commits, `commits` oldest first. */
|
||||
@@ -4865,9 +4865,10 @@ tags?: PushTags | null } |
|
||||
{ op: "deleteBranch"; branch: string } |
|
||||
/**
|
||||
* Pushes local `branch` (only up to `revision` when set) to its upstream, or to
|
||||
* remote branch `target` of the same remote.
|
||||
* remote branch `target`; `remote` overrides the upstream's remote (Push dialog's
|
||||
* remote picker).
|
||||
*/
|
||||
{ op: "pushBranch"; branch: string; revision: string | null; force_with_lease: boolean; target?: string | null } |
|
||||
{ op: "pushBranch"; branch: string; revision: string | null; force_with_lease: boolean; target?: string | null; remote?: string | null } |
|
||||
/** Fast-forwards local `branch` (not checked out) to its upstream. */
|
||||
{ op: "updateBranch"; branch: string } |
|
||||
/** Pull dialog: `branches` of `remote` (none = the configured merge branch). */
|
||||
|
||||
@@ -76,7 +76,7 @@ export function blameFor(path: string, text: Text): Promise<BlameLine[] | null>
|
||||
const hit = cache.get(path);
|
||||
if (hit && (hit.text === text || hit.text === null)) return hit.lines;
|
||||
const entry: Entry = { text, lines: Promise.resolve(null) };
|
||||
entry.lines = call(commands.vcsBlame(path)).then(
|
||||
entry.lines = call(commands.vcsBlame(path, null)).then(
|
||||
(lines) => lines,
|
||||
() => {
|
||||
entry.text = null;
|
||||
|
||||
@@ -129,6 +129,22 @@ describe("project view context menu", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("offers Show History for files, directories, multi-selection and the project root", () => {
|
||||
const dispose = registerActions([{ id: "vcs.showHistory", title: "Show History", run: () => undefined }]);
|
||||
try {
|
||||
for (const nodes of [
|
||||
[node("/p/a.py")],
|
||||
[node("/p/src", true)],
|
||||
[node("/p/a.py"), node("/p/src", true)],
|
||||
[node("/p", true, true)],
|
||||
]) {
|
||||
expect(sub(contextMenuItems(nodes), "Git")).toContain("Show History");
|
||||
}
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("shows Mark Directory as for folders, without file-only items", () => {
|
||||
const l = labels(contextMenuItems([node(`${ROOT}/src`, true)]));
|
||||
expect(l).toContain("Mark Directory as");
|
||||
|
||||
@@ -332,7 +332,7 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
|
||||
singleFile && act("vcs.annotate", singleFile.path, "Annotate with Git Blame"),
|
||||
act("vcs.showDiff", arg, "Show Diff"),
|
||||
singleFile && act("vcs.compareWithBranch", { path: singleFile.path }, "Compare with Branch…"),
|
||||
singleFile && act("vcs.showHistory", [singleFile.path], "Show History"),
|
||||
nodes.length > 0 && nodes.every((n) => !n.placeholder) && act("vcs.showHistory", arg, "Show History"),
|
||||
act("vcs.rollback", arg, "Rollback…"),
|
||||
{ kind: "separator" },
|
||||
act("vcs.push", undefined, "Push…"),
|
||||
|
||||
@@ -405,6 +405,7 @@ export function ChangesTree({ mode }: { mode: "commit" | "local" | "dialog" }) {
|
||||
<IconButton icon="refresh" title="Refresh" size="small" onClick={() => void refreshNow()} />
|
||||
{mode === "local" && <IconButton icon="commit" action="vcs.commit" size="small" />}
|
||||
<IconButton icon="undo" action="vcs.rollback" size="small" />
|
||||
{mode === "local" && <IconButton icon="changelist" action="vcs.changelist.new" size="small" />}
|
||||
{mode !== "dialog" && <IconButton icon="git-pull" action="vcs.update" size="small" />}
|
||||
<IconButton
|
||||
ref={viewRef}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { call, commands } from "@/platform/ipc";
|
||||
import { Dialog, List, Spinner, Tabs, T } from "@/ui";
|
||||
import { openDialog } from "./dialogs";
|
||||
import { formatTime } from "./model";
|
||||
import { showCompareRevisions } from "./operations";
|
||||
import { currentRepo, useVcs } from "./store";
|
||||
import styles from "./common.module.css";
|
||||
import { tx } from "@/platform/i18n";
|
||||
@@ -128,7 +129,18 @@ function CompareDialog({ branch, current, onClose }: { branch: string; current:
|
||||
title={tx`Compare ${current} with ${branch}`}
|
||||
onClose={onClose}
|
||||
width={640}
|
||||
buttons={[{ label: "Close", role: "cancel" }]}
|
||||
buttons={[
|
||||
{
|
||||
label: "Show Diff",
|
||||
role: "default",
|
||||
onClick: () => {
|
||||
onClose();
|
||||
// Whole-project diff: every file that differs between the branches.
|
||||
showCompareRevisions(branch, "HEAD", branch, current);
|
||||
},
|
||||
},
|
||||
{ label: "Close", role: "cancel" },
|
||||
]}
|
||||
>
|
||||
<Tabs
|
||||
variant="compact"
|
||||
|
||||
@@ -10,8 +10,9 @@ import type { MergeSides } from "@/bindings";
|
||||
import { embeddedEditorExtensions } from "@/editor/api";
|
||||
import { tx, useTxTemplate } from "@/platform/i18n";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { Button, IconButton, Spinner, T } from "@/ui";
|
||||
import { Button, IconButton, showContextMenu, Spinner, T } from "@/ui";
|
||||
import { editorService, type EditorInput } from "@/workbench/contributions";
|
||||
import { blameSlot, toggleAnnotations } from "./blame";
|
||||
import { languageFor, viewerBase } from "./cm";
|
||||
import { confirmDialog } from "./dialogs";
|
||||
import { initialResult, mergeTitles, resolutionText, type Resolution, type ResultConflict } from "./conflicts";
|
||||
@@ -152,6 +153,8 @@ export function MergeEditor({ input }: { input: EditorInput }) {
|
||||
const resultRef = useRef<HTMLDivElement>(null);
|
||||
const theirsRef = useRef<HTMLDivElement>(null);
|
||||
const resultView = useRef<EditorView | null>(null);
|
||||
const oursView = useRef<EditorView | null>(null);
|
||||
const theirsView = useRef<EditorView | null>(null);
|
||||
const txt = useTxTemplate();
|
||||
const titles = mergeTitles(state?.sides);
|
||||
|
||||
@@ -188,6 +191,7 @@ export function MergeEditor({ input }: { input: EditorInput }) {
|
||||
const side = () => [
|
||||
viewerBase({ readOnly: true }),
|
||||
lang,
|
||||
blameSlot(),
|
||||
state.base !== null ? unifiedMergeView({ original: state.base, mergeControls: false, gutter: true }) : [],
|
||||
];
|
||||
const ours = new EditorView({ doc: state.ours, parent: oursRef.current, extensions: side() });
|
||||
@@ -214,11 +218,36 @@ export function MergeEditor({ input }: { input: EditorInput }) {
|
||||
});
|
||||
resultView.current = result;
|
||||
setLeft(result.state.field(conflictField).conflicts.length);
|
||||
oursView.current = ours;
|
||||
theirsView.current = theirs;
|
||||
// Scroll the three panes together (by scroll ratio), like IDEA's merge view.
|
||||
const views = [ours, result, theirs];
|
||||
let syncing = false;
|
||||
const onScroll = (from: EditorView) => () => {
|
||||
if (syncing) return;
|
||||
syncing = true;
|
||||
const s = from.scrollDOM;
|
||||
const ratio = s.scrollTop / Math.max(1, s.scrollHeight - s.clientHeight);
|
||||
for (const other of views) {
|
||||
if (other === from) continue;
|
||||
const o = other.scrollDOM;
|
||||
o.scrollTop = ratio * Math.max(0, o.scrollHeight - o.clientHeight);
|
||||
}
|
||||
syncing = false;
|
||||
};
|
||||
const offs = views.map((v) => {
|
||||
const h = onScroll(v);
|
||||
v.scrollDOM.addEventListener("scroll", h, { passive: true });
|
||||
return () => v.scrollDOM.removeEventListener("scroll", h);
|
||||
});
|
||||
return () => {
|
||||
offs.forEach((off) => off());
|
||||
ours.destroy();
|
||||
theirs.destroy();
|
||||
result.destroy();
|
||||
resultView.current = null;
|
||||
oursView.current = null;
|
||||
theirsView.current = null;
|
||||
};
|
||||
}, [state, lang, path]);
|
||||
|
||||
@@ -284,6 +313,28 @@ export function MergeEditor({ input }: { input: EditorInput }) {
|
||||
}
|
||||
};
|
||||
|
||||
// IDEA's merge view: F7 / Shift+F7 jump between conflicts; each side pane can
|
||||
// annotate its version with Git Blame from the context menu.
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "F7") {
|
||||
e.preventDefault();
|
||||
goto(e.shiftKey ? -1 : 1);
|
||||
}
|
||||
};
|
||||
|
||||
const sideMenu = (e: React.MouseEvent, viewRef: { current: EditorView | null }, text: string) => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
e.preventDefault();
|
||||
showContextMenu(e.nativeEvent, [
|
||||
{
|
||||
label: "Annotate with Git Blame",
|
||||
icon: "changelist",
|
||||
run: () => void toggleAnnotations(view, path, text),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
if (error)
|
||||
return (
|
||||
<div className={styles.message}>
|
||||
@@ -304,7 +355,7 @@ export function MergeEditor({ input }: { input: EditorInput }) {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.root} onKeyDown={onKeyDown}>
|
||||
<div className={styles.toolbar} role="toolbar" aria-label={tx("Merge")}>
|
||||
<IconButton icon="arrow-up" title="Previous Conflict" size="small" onClick={() => goto(-1)} />
|
||||
<IconButton icon="arrow-down" title="Next Conflict" size="small" onClick={() => goto(1)} />
|
||||
@@ -336,9 +387,21 @@ export function MergeEditor({ input }: { input: EditorInput }) {
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.panes} hidden={!state || lang === null}>
|
||||
<div ref={oursRef} className={styles.pane} aria-label={tx("Your version")} role="region" />
|
||||
<div
|
||||
ref={oursRef}
|
||||
className={styles.pane}
|
||||
aria-label={tx("Your version")}
|
||||
role="region"
|
||||
onContextMenu={(e) => sideMenu(e, oursView, state?.ours ?? "")}
|
||||
/>
|
||||
<div ref={resultRef} className={styles.pane} aria-label={tx("Result")} role="region" />
|
||||
<div ref={theirsRef} className={styles.pane} aria-label={tx("Their version")} role="region" />
|
||||
<div
|
||||
ref={theirsRef}
|
||||
className={styles.pane}
|
||||
aria-label={tx("Their version")}
|
||||
role="region"
|
||||
onContextMenu={(e) => sideMenu(e, theirsView, state?.theirs ?? "")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -106,7 +106,7 @@ class BlameRefresher implements PluginValue {
|
||||
private schedule(delay: number) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = setTimeout(() => {
|
||||
void call(commands.vcsBlame(this.path))
|
||||
void call(commands.vcsBlame(this.path, null))
|
||||
.then((lines) => {
|
||||
if (!this.destroyed && this.view.state.field(blameField, false))
|
||||
this.view.dispatch({ effects: setBlame.of(lines) });
|
||||
@@ -153,7 +153,7 @@ function lineAt(view: EditorView, from: number): BlameLine | undefined {
|
||||
return found;
|
||||
}
|
||||
|
||||
function blameExtension(lines: BlameLine[], path: string): Extension {
|
||||
function blameExtension(lines: BlameLine[], path: string, live: boolean): Extension {
|
||||
return [
|
||||
blameField.init((state) => ({ lines, markers: build(lines, state.doc) })),
|
||||
Prec.high(
|
||||
@@ -186,14 +186,15 @@ function blameExtension(lines: BlameLine[], path: string): Extension {
|
||||
{ kind: "separator" as const },
|
||||
]
|
||||
: []),
|
||||
{ label: "Close Annotations", run: () => setAnnotations(view, null, path) },
|
||||
{ label: "Close Annotations", run: () => setAnnotations(view, null, path, false) },
|
||||
]);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
ViewPlugin.define((view) => new BlameRefresher(view, path)),
|
||||
// Static content (a merge pane's side) is not re-blamed on edits / HEAD moves.
|
||||
...(live ? [ViewPlugin.define((view) => new BlameRefresher(view, path))] : []),
|
||||
blameTheme,
|
||||
];
|
||||
}
|
||||
@@ -202,19 +203,19 @@ export function annotationsShown(view: EditorView): boolean {
|
||||
return !!view.state.field(blameField, false);
|
||||
}
|
||||
|
||||
function setAnnotations(view: EditorView, lines: BlameLine[] | null, path: string) {
|
||||
view.dispatch({ effects: blameCompartment.reconfigure(lines ? blameExtension(lines, path) : []) });
|
||||
function setAnnotations(view: EditorView, lines: BlameLine[] | null, path: string, live: boolean) {
|
||||
view.dispatch({ effects: blameCompartment.reconfigure(lines ? blameExtension(lines, path, live) : []) });
|
||||
}
|
||||
|
||||
/** Toggles annotations in `view` (showing `path`). */
|
||||
export async function toggleAnnotations(view: EditorView, path: string) {
|
||||
/** Toggles annotations in `view`; `text` blames that content instead of the file on disk. */
|
||||
export async function toggleAnnotations(view: EditorView, path: string, text?: string) {
|
||||
if (annotationsShown(view)) {
|
||||
setAnnotations(view, null, path);
|
||||
setAnnotations(view, null, path, false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const lines = await call(commands.vcsBlame(path));
|
||||
setAnnotations(view, lines, path);
|
||||
const lines = await call(commands.vcsBlame(path, text ?? null));
|
||||
setAnnotations(view, lines, path, text === undefined);
|
||||
} catch (e) {
|
||||
notifications().notify({
|
||||
group: "vcs",
|
||||
|
||||
@@ -472,6 +472,13 @@ export function showCompareVersions(older: Commit, newer: Commit) {
|
||||
));
|
||||
}
|
||||
|
||||
/** Files that differ between two revisions (e.g. two branches from Compare with Current). */
|
||||
export function showCompareRevisions(from: string, to: string, fromLabel = from, toLabel = to) {
|
||||
openDialog((close) => (
|
||||
<ChangesBetweenDialog from={{ rev: from, label: fromLabel }} to={{ rev: to, label: toLabel }} onClose={close} />
|
||||
));
|
||||
}
|
||||
|
||||
interface Revision {
|
||||
rev: string;
|
||||
label: string;
|
||||
|
||||
@@ -288,49 +288,74 @@ function PushDialog({ target: pushTarget, onClose }: { target: PushTarget; onClo
|
||||
const [commits, setCommits] = useState<Commit[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [force, setForce] = useState(false);
|
||||
const [remotes, setRemotes] = useState<string[]>([]);
|
||||
const current = branch?.head ?? null;
|
||||
const head = pushTarget.branch ?? current;
|
||||
// Another branch, or a partial push: `git push <remote> <src>:<dst>` instead of the plain push.
|
||||
const explicit = head !== current || !!pushTarget.revision;
|
||||
const upstream = explicit
|
||||
const tracked = explicit
|
||||
? (branches.find((b) => !b.remote && b.name === head)?.upstream ?? null)
|
||||
: (branch?.upstream ?? null);
|
||||
const revision = pushTarget.revision?.id ?? null;
|
||||
const [setUpstream, setSetUpstream] = useState(!upstream);
|
||||
const trackedRemote = tracked ? tracked.slice(0, tracked.indexOf("/")) : null;
|
||||
const trackedBranch = tracked ? tracked.slice(tracked.indexOf("/") + 1) : null;
|
||||
// The picked remote (Git › Push can target any configured remote, like IDEA).
|
||||
const [remote, setRemote] = useState<string | null>(trackedRemote);
|
||||
const [setUpstream, setSetUpstream] = useState(!tracked);
|
||||
const [pushTags, setPushTags] = useState(false);
|
||||
const [tagsMode, setTagsMode] = useState<PushTags>("all");
|
||||
const upstream = trackedBranch ? (remote ? `${remote}/${trackedBranch}` : tracked) : tracked;
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
call(commands.vcsRemotes(repo))
|
||||
.then((list) => {
|
||||
if (!alive) return;
|
||||
setRemotes(list.map((r) => r.name));
|
||||
setRemote((r) => r ?? (list.some((x) => x.name === "origin") ? "origin" : (list[0]?.name ?? null)));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [repo]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
if (!head) return [];
|
||||
if (!explicit) {
|
||||
const q = outgoingQuery(upstream);
|
||||
return logOf(q.branch, q.limit, q.range, repo);
|
||||
if (!explicit && upstream === tracked) {
|
||||
// The tracked upstream: `@{u}` resolves it even when the name is ambiguous.
|
||||
return logOf("HEAD", 1000, "@{u}..HEAD", repo).catch(() => logOf("HEAD", 50, null, repo));
|
||||
}
|
||||
const tip = revision ?? head;
|
||||
return upstream ? logOf(tip, 1000, `${upstream}..${tip}`, repo) : logOf(tip, 50, null, repo);
|
||||
// `remote/branch` may not exist locally yet: fall back to the recent history.
|
||||
return upstream
|
||||
? logOf(tip, 1000, `${upstream}..${tip}`, repo).catch(() => logOf(tip, 50, null, repo))
|
||||
: logOf(tip, 50, null, repo);
|
||||
})()
|
||||
.then((c) => alive && setCommits(c))
|
||||
.catch((e) => alive && setError(e instanceof Error ? e.message : String(e)));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [head, upstream, explicit, revision]);
|
||||
}, [head, upstream, explicit, revision, tracked, repo]);
|
||||
|
||||
const remote = upstream ? upstream.slice(0, upstream.indexOf("/")) : null;
|
||||
const defaultTarget = upstream ? upstream.slice(upstream.indexOf("/") + 1) : head;
|
||||
const defaultTarget = trackedBranch ?? head;
|
||||
// IDEA's push target is editable: another name pushes to that branch of the same remote.
|
||||
const [targetName, setTargetName] = useState(defaultTarget ?? "");
|
||||
const edited = targetName.trim();
|
||||
const retarget = !!head && !!edited && edited !== defaultTarget;
|
||||
const target = retarget ? edited : defaultTarget;
|
||||
const targetLabel = retarget ? `${remote ? `${remote}/` : ""}${edited}` : (upstream ?? "new remote branch");
|
||||
// A remote different from the tracked one always goes through `pushBranch`.
|
||||
const remoteChanged = !!remote && remote !== trackedRemote;
|
||||
const targetLabel =
|
||||
remoteChanged || retarget ? `${remote ? `${remote}/` : ""}${target}` : (upstream ?? "new remote branch");
|
||||
const protectedTarget = !!target && isProtectedBranch(target);
|
||||
const push = async () => {
|
||||
if (force && !protectedTarget && !(await confirmForcePush(remote ? `${remote}/${target}` : (target ?? "")))) return;
|
||||
onClose();
|
||||
if ((explicit || retarget) && head) {
|
||||
if ((explicit || retarget || remoteChanged) && head) {
|
||||
await runRemote(
|
||||
{
|
||||
op: "pushBranch",
|
||||
@@ -338,6 +363,7 @@ function PushDialog({ target: pushTarget, onClose }: { target: PushTarget; onClo
|
||||
revision,
|
||||
force_with_lease: force && !protectedTarget,
|
||||
target: retarget ? edited : null,
|
||||
remote: remoteChanged ? remote : null,
|
||||
},
|
||||
`${revision ? `${head} up to ${revision.slice(0, 8)}` : head} → ${targetLabel}`,
|
||||
[repo],
|
||||
@@ -372,7 +398,7 @@ function PushDialog({ target: pushTarget, onClose }: { target: PushTarget; onClo
|
||||
label="Force (with lease)"
|
||||
/>
|
||||
</span>
|
||||
{!explicit && !retarget && (
|
||||
{!explicit && !retarget && !remoteChanged && (
|
||||
<>
|
||||
<Checkbox
|
||||
checked={setUpstream || !upstream}
|
||||
@@ -409,11 +435,19 @@ function PushDialog({ target: pushTarget, onClose }: { target: PushTarget; onClo
|
||||
<div className={styles.section}>
|
||||
{!head
|
||||
? tx("HEAD is detached; check out a branch to push.")
|
||||
: upstream
|
||||
? `${head}${revision ? ` up to ${revision.slice(0, 8)}` : ""} → ${remote}/`
|
||||
: trackedBranch
|
||||
? `${head}${revision ? ` up to ${revision.slice(0, 8)}` : ""} → ${remote ?? trackedRemote ?? ""}/`
|
||||
: revision
|
||||
? tx`${head} has no upstream: commits up to ${revision.slice(0, 8)} will be pushed as a new branch:`
|
||||
: tx`${head} has no upstream: it will be pushed as a new branch and tracked:`}
|
||||
{head && remotes.length > 1 && (
|
||||
<Select<string>
|
||||
value={remote ?? ""}
|
||||
aria-label="Remote"
|
||||
onChange={setRemote}
|
||||
options={remotes.map((r) => ({ value: r, label: r }))}
|
||||
/>
|
||||
)}
|
||||
{head && (
|
||||
<Input
|
||||
aria-label="Target branch"
|
||||
|
||||
Reference in new issue
Заблокувати користувача