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]>
Цей коміт міститься в:
arookieofcтаa-rookie-of-C-language authored and GitHub committed 2026-10-30 15:17:11 +08:00
1 джерело 545fb93744
коміт 3655961642
18 files changed
+256 -68

No files matched your search

+19 -10
Переглянути файл
@@ -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
+1 -1
Переглянути файл
@@ -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();
+3 -1
Переглянути файл
@@ -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:?}");
}
}
+4
Переглянути файл
@@ -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);
}
+25 -7
Переглянути файл
@@ -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.
+14 -10
Переглянути файл
@@ -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 =
+13 -2
Переглянути файл
@@ -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]
+7 -2
Переглянути файл
@@ -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]
+4 -3
Переглянути файл
@@ -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). */
+1 -1
Переглянути файл
@@ -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;
+16
Переглянути файл
@@ -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");
+1 -1
Переглянути файл
@@ -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…"),
+1
Переглянути файл
@@ -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}
+13 -1
Переглянути файл
@@ -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"
+67 -4
Переглянути файл
@@ -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>
);
+12 -11
Переглянути файл
@@ -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",
+7
Переглянути файл
@@ -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;
+48 -14
Переглянути файл
@@ -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"