Merge remote-tracking branch 'origin/main' into fix/agent-chat-transcript

# Conflicts:
#	src/platform/locales/text/en.json
This commit is contained in:
l0ng-ai committed 2026-11-07 10:46:10 +08:00
commit 021e1165a7
22 files changed
+442 -75

No files matched your search

+14 -2
View File
@@ -583,12 +583,15 @@ pub async fn vcs_create_branch(
name: String,
start: Option<String>,
checkout: bool,
overwrite: Option<bool>,
) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
let recorded = checkout.then_some("Git checkout");
blocking(move || {
project.with_repo_recorded(repo.as_deref(), recorded, |r| r.create_branch(&name, start.as_deref(), checkout))
project.with_repo_recorded(repo.as_deref(), recorded, |r| {
r.create_branch_with_overwrite(&name, start.as_deref(), checkout, overwrite.unwrap_or(false))
})
})
.await
}
@@ -1335,7 +1338,16 @@ pub(crate) fn register(d: &mut Dispatcher) {
vcs_checkout(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?, c.arg("create")?, c.arg("options")?).await
});
d.add("vcs_create_branch", |c| async move {
vcs_create_branch(&c.host, &c.window, c.arg("repo")?, c.arg("name")?, c.arg("start")?, c.arg("checkout")?).await
vcs_create_branch(
&c.host,
&c.window,
c.arg("repo")?,
c.arg("name")?,
c.arg("start")?,
c.arg("checkout")?,
c.arg("overwrite")?,
)
.await
});
d.add(
"vcs_undo_commit",
+1 -1
View File
@@ -151,7 +151,7 @@ async fn change_commit_branch_stash_and_merge_a_conflict() {
assert!(ops::vcs::vcs_line_hunks(&f.host, WINDOW, lib_path.clone()).await.unwrap().is_empty());
// A branch changes the line its way.
ops::vcs::vcs_create_branch(&f.host, WINDOW, None, "feature".into(), None, true).await.unwrap();
ops::vcs::vcs_create_branch(&f.host, WINDOW, None, "feature".into(), None, true, None).await.unwrap();
let theirs = "pub fn format_price(cents: u64) -> String { // feature";
f.edit_line(lib, theirs).await;
f.save(lib).await;
+12 -1
View File
@@ -1087,8 +1087,19 @@ impl Repo {
/// Creates branch `name` at `start` (default HEAD), optionally switching to it. A
/// remote-tracking start point (`origin/x`) becomes the new branch's upstream.
pub fn create_branch(&self, name: &str, start: Option<&str>, checkout: bool) -> IdeResult<()> {
self.create_branch_with_overwrite(name, start, checkout, false)
}
/// Creates a branch or moves its tip when overwrite is explicitly enabled.
pub fn create_branch_with_overwrite(
&self,
name: &str,
start: Option<&str>,
checkout: bool,
overwrite: bool,
) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::create_branch(self, name, start, checkout)
write::create_branch(self, name, start, checkout, overwrite)
}
/// Undo Commit: resets the current branch to the parent of `commit` (which must be
/// HEAD and not a merge) keeping its changes staged; undoing the initial commit
+13 -2
View File
@@ -641,7 +641,13 @@ fn stash_count(r: &Repo) -> IdeResult<usize> {
}
/// Creates `name` at `start` (default HEAD); a remote-tracking start point is tracked.
pub(crate) fn create_branch(r: &Repo, name: &str, start: Option<&str>, checkout: bool) -> IdeResult<()> {
pub(crate) fn create_branch(
r: &Repo,
name: &str,
start: Option<&str>,
checkout: bool,
overwrite: bool,
) -> IdeResult<()> {
let name = name.trim();
let valid = !name.is_empty()
&& !name.starts_with('-')
@@ -658,7 +664,12 @@ pub(crate) fn create_branch(r: &Repo, name: &str, start: Option<&str>, checkout:
let repo = r.gix()?;
resolve(&repo, s)?;
}
let mut args = if checkout { vec!["switch", "-c", name] } else { vec!["branch", "--", name] };
let mut args = match (checkout, overwrite) {
(true, false) => vec!["switch", "-c", name],
(true, true) => vec!["switch", "-C", name],
(false, false) => vec!["branch", "--", name],
(false, true) => vec!["branch", "--force", "--", name],
};
args.extend(start);
run(r.root(), &args, None).map(|_| ())
}
+30
View File
@@ -950,6 +950,36 @@ fn branch_create_checkout_merge_rebase_delete() {
assert!(!repo.branches().unwrap().iter().any(|b| b.name == "unmerged"));
}
#[test]
fn branch_overwrite_preserves_the_ref_if_checkout_fails() {
let fx = Fixture::new();
fx.write("a.txt", "base\n");
let base = fx.commit_all("base");
fx.write("a.txt", "main\n");
let tip = fx.commit_all("main");
let repo = fx.repo();
repo.create_branch("topic", None, false).unwrap();
repo.create_branch_with_overwrite("topic", Some(&base), false, true).unwrap();
assert_eq!(fx.git(&["rev-parse", "topic"]), base);
assert_eq!(fx.git(&["rev-parse", "HEAD"]), tip);
repo.create_branch_with_overwrite("topic", Some(&tip), false, true).unwrap();
fx.write("a.txt", "dirty\n");
assert!(repo.create_branch_with_overwrite("topic", Some(&base), true, true).is_err());
assert_eq!(fx.git(&["rev-parse", "topic"]), tip);
assert_eq!(repo.branch_info().unwrap().head.as_deref(), Some("main"));
assert_eq!(fx.read("a.txt"), "dirty\n");
fx.write("a.txt", "main\n");
repo.create_branch_with_overwrite("topic", Some(&base), true, true).unwrap();
assert_eq!(repo.branch_info().unwrap().head.as_deref(), Some("topic"));
assert_eq!(fx.git(&["rev-parse", "HEAD"]), base);
repo.create_branch_with_overwrite("topic", Some(&tip), true, true).unwrap();
assert_eq!(fx.git(&["rev-parse", "HEAD"]), tip);
}
#[test]
fn new_branch_from_remote_branch_tracks_it() {
let (fx, _bare) = Fixture::with_origin();
+90
View File
@@ -0,0 +1,90 @@
import { expect, ipcCalls, stubDesktop, test } from "./fixtures";
test("opens a commit's branch menu and acts in its linked repository", async ({ workbench: page }) => {
await stubDesktop(page);
await page.evaluate(async () => {
const primary = "/dev/acme-api";
const linked = "/dev/acme-web";
const tip = "1".repeat(40);
const h = window.__ipc.handlers;
h.vcs_roots = () =>
[
{ root: primary, head: "main" },
{ root: linked, head: "release" },
].map((repo) => ({ ...repo, operation: null, headCommit: tip }));
h.vcs_branch_info = ({ repo }) => ({
head: repo === linked ? "release" : "main",
headCommit: tip,
upstream: null,
ahead: 0,
behind: 0,
operation: null,
});
h.vcs_branches = ({ repo }) =>
[repo === linked ? "origin/dev" : "origin/other"].map((name) => ({
name,
commit: tip,
remote: true,
current: false,
upstream: null,
ahead: 0,
behind: 0,
lastCommitMs: 0,
}));
h.vcs_log = ({ repo }) => {
const summary = repo === linked ? "Linked repository commit" : "Primary repository commit";
return [
{
id: tip,
shortId: tip.slice(0, 8),
parents: [],
refs: [repo === linked ? "origin/dev" : "origin/other"],
author: "A",
email: "[email protected]",
timeMs: 0,
summary,
message: summary,
},
];
};
h.vcs_log_refs = () => ({});
h.vcs_log_details_batch = ({ revisions }) =>
(revisions as string[]).map(() => ({
message: "Linked repository commit",
containingBranches: [],
signature: "N",
signer: "",
key: "",
fingerprint: "",
}));
h.vcs_commit_files = () => [];
h.vcs_config_get = () => null;
h.vcs_tags = () => [];
h.vcs_status = () => [];
h.vcs_create_branch = () => null;
const vcsModule = "/src/features/vcs/store.ts";
const { useVcs, refreshNow } = await import(/* @vite-ignore */ vcsModule);
useVcs.setState({ available: true, root: primary, repo: primary });
await refreshNow();
const contextModule = "/src/platform/context.ts";
const { setContext } = await import(/* @vite-ignore */ contextModule);
setContext("vcsAvailable", true);
const actionsModule = "/src/platform/actions.ts";
const { executeAction } = await import(/* @vite-ignore */ actionsModule);
await executeAction("vcs.showLog");
});
await page.getByRole("option", { name: /Linked repository commit/ }).click({ button: "right" });
const menu = page.getByRole("menu", { name: "Context menu" });
await expect(menu.getByRole("menuitem", { name: "Branch 'origin/other'" })).toHaveCount(0);
await menu.getByRole("menuitem", { name: "Branch 'origin/dev'" }).hover();
await expect(page.getByRole("menuitem", { name: "Pull into 'release' Using Rebase" })).toBeVisible();
const newBranch = page.getByRole("menuitem", { name: "New Branch from 'origin/dev'…" });
await newBranch.click();
const dialog = page.getByRole("dialog", { name: "Create New Branch from origin/dev" });
await dialog.getByRole("textbox", { name: "New branch name:" }).fill("test-branch");
await dialog.getByRole("button", { name: "Create", exact: true }).click();
await expect
.poll(() => ipcCalls(page, "vcs_create_branch"))
.toMatchObject([{ repo: "/dev/acme-web", name: "test-branch", start: "origin/dev", checkout: true }]);
});
+63 -23
View File
@@ -7,30 +7,47 @@ import { expect, ipcCalls, stubDesktop, test } from "./fixtures";
const ROOT = "/dev/acme-api";
async function openNewBranchDialog(page: Page) {
async function openNewBranchDialog(page: Page, from?: string) {
await stubDesktop(page);
await page.evaluate(async (root) => {
const h = window.__ipc.handlers;
h.vcs_roots = () => [{ root, operation: null, head: "main", headCommit: "1111111" }];
h.vcs_branch_info = () => ({
head: "main",
headCommit: "1111111",
upstream: null,
ahead: 0,
behind: 0,
operation: null,
});
h.vcs_status = () => [];
h.vcs_create_branch = () => null;
const vcsModule = "/src/features/vcs/store.ts";
const { useVcs, refreshNow } = await import(/* @vite-ignore */ vcsModule);
useVcs.setState({ available: true, root, repo: root, branchesLoaded: false, stashesLoaded: false });
await refreshNow();
const actionsModule = "/src/platform/actions.ts";
const { executeAction } = await import(/* @vite-ignore */ actionsModule);
void executeAction("vcs.newBranch");
}, ROOT);
const dialog = page.getByRole("dialog", { name: "Create New Branch" });
await page.evaluate(
async ({ root, from }) => {
const h = window.__ipc.handlers;
h.vcs_roots = () => [{ root, operation: null, head: "main", headCommit: "1111111" }];
h.vcs_branch_info = () => ({
head: "main",
headCommit: "1111111",
upstream: null,
ahead: 0,
behind: 0,
operation: null,
});
h.vcs_status = () => [];
h.vcs_branches = () => [
{
name: "main",
current: true,
remote: false,
upstream: null,
ahead: 0,
behind: 0,
lastCommitMs: null,
commit: "1111111",
},
];
h.vcs_create_branch = () => null;
const vcsModule = "/src/features/vcs/store.ts";
const { useVcs, refreshNow, loadBranches } = await import(/* @vite-ignore */ vcsModule);
useVcs.setState({ available: true, root, repo: root, branchesLoaded: false, stashesLoaded: false });
await refreshNow();
await loadBranches();
const actionsModule = "/src/platform/actions.ts";
const { executeAction } = await import(/* @vite-ignore */ actionsModule);
if (from) void executeAction("vcs.log.newBranch", { id: from, summary: "Selected commit", repo: root });
else void executeAction("vcs.newBranch");
},
{ root: ROOT, from },
);
const dialog = page.getByRole("dialog", { name: from ? `Create New Branch from ${from}` : "Create New Branch" });
const input = dialog.getByRole("textbox", { name: "New branch name:" });
await expect(input).toBeVisible();
return { dialog, input };
@@ -48,6 +65,29 @@ test("a typed space becomes a separator and Create stays enabled", async ({ work
expect(call).toMatchObject({ name: "fix-login-redirect" });
});
test("overwrites a local branch only after explicitly opting in", async ({ workbench: page }) => {
const from = "2".repeat(40);
const { dialog, input } = await openNewBranchDialog(page, from);
await input.fill("main");
const create = dialog.getByRole("button", { name: "Create", exact: true });
await expect(create).toBeDisabled();
const overwrite = dialog.getByRole("checkbox", { name: "Overwrite existing branch" });
await expect(overwrite).toBeVisible();
await expect(overwrite).not.toBeChecked();
await overwrite.check();
await expect(create).toBeEnabled();
await input.fill("new-branch");
await expect(overwrite).toHaveCount(0);
await input.fill("main");
await expect(overwrite).not.toBeChecked();
await expect(create).toBeDisabled();
await overwrite.check();
await create.click();
await expect
.poll(() => ipcCalls(page, "vcs_create_branch"))
.toEqual([{ repo: ROOT, name: "main", start: from, checkout: true, overwrite: true }]);
});
test("a space typed in the middle keeps the caret after it", async ({ workbench: page }) => {
const { input } = await openNewBranchDialog(page);
await input.fill("ab");
+2 -1
View File
@@ -268,8 +268,9 @@ pub async fn vcs_create_branch(
name: String,
start: Option<String>,
checkout: bool,
overwrite: Option<bool>,
) -> IdeResult<()> {
ide_host::ops::vcs::vcs_create_branch(&state.host, window.label(), repo, name, start, checkout).await
ide_host::ops::vcs::vcs_create_branch(&state.host, window.label(), repo, name, start, checkout, overwrite).await
}
/// Undo Commit: moves HEAD back to the commit's parent, keeping its changes staged.
+5
View File
@@ -18,6 +18,8 @@ mod logging;
mod macos;
mod menu;
mod plugin_web;
#[cfg(target_os = "macos")]
mod relaunch;
mod remote_dev;
mod remote_wsl;
@@ -248,6 +250,9 @@ pub fn run() {
}
// Cleanup must finish before update/restart replaces this process.
if app.try_state::<WindowsState>().is_some_and(|w| w.restart_requested()) {
#[cfg(target_os = "macos")]
relaunch::restart(&app.env());
#[cfg(not(target_os = "macos"))]
tauri::process::restart(&app.env());
}
}
+88
View File
@@ -0,0 +1,88 @@
//! Restarting the app on macOS once this process has exited.
//!
//! `tauri::process::restart` launches the new instance while this one is still running.
//! After an update has replaced the app bundle, the Dock then gives the new instance a
//! tile of its own instead of the pinned one. A detached shell waits for this process
//! to exit and only then opens the bundle.
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
const WAIT_THEN_OPEN: &str = r#"pid=$1; opener=$2; app=$3; shift 3; while kill -0 "$pid" 2>/dev/null; do sleep 0.1; done; exec "$opener" -n "$app" --args "$@""#;
/// Restarts the app; never returns.
pub fn restart(env: &tauri::Env) -> ! {
if let Some(bundle) = tauri::process::current_binary(env).ok().and_then(|bin| app_bundle(&bin)) {
let args = env.args_os.iter().skip(1).cloned().collect::<Vec<_>>();
match wait_then_open(std::process::id(), Path::new("/usr/bin/open"), &bundle, &args).spawn() {
Ok(_) => std::process::exit(0),
Err(e) => tracing::error!("failed to schedule the restart: {e}"),
}
}
tauri::process::restart(env)
}
/// The `.app` bundle holding `binary`, when it sits at `<bundle>/Contents/MacOS/<name>`.
fn app_bundle(binary: &Path) -> Option<PathBuf> {
let macos = binary.parent()?;
let contents = macos.parent()?;
if macos.file_name()? != "MacOS" || contents.file_name()? != "Contents" {
return None;
}
contents.parent().map(Path::to_path_buf)
}
fn wait_then_open(pid: u32, opener: &Path, bundle: &Path, args: &[OsString]) -> Command {
use std::os::unix::process::CommandExt;
let mut command = Command::new("/bin/sh");
command
.arg("-c")
.arg(WAIT_THEN_OPEN)
.arg("openidea-restart")
.arg(pid.to_string())
.arg(opener)
.arg(bundle)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.process_group(0);
command
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{Duration, Instant};
#[test]
fn finds_the_bundle_of_its_binary() {
assert_eq!(
app_bundle(Path::new("/Applications/OpenIDEA.app/Contents/MacOS/openidea")),
Some(PathBuf::from("/Applications/OpenIDEA.app"))
);
assert_eq!(app_bundle(Path::new("/repo/target/debug/openidea")), None);
assert_eq!(app_bundle(Path::new("/x/MacOS/openidea")), None);
}
#[test]
fn opens_the_bundle_only_after_the_process_exits() {
let dir = tempfile::tempdir().unwrap();
let log = dir.path().join("opened");
let opener = dir.path().join("opener");
std::fs::write(&opener, format!("#!/bin/sh\nprintf '%s|' \"$@\" > '{}'\n", log.display())).unwrap();
std::fs::set_permissions(&opener, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap();
let mut running = Command::new("/bin/sleep").arg("1").spawn().unwrap();
let pid = running.id();
let reaper = std::thread::spawn(move || running.wait());
let args = [OsString::from("--flag"), OsString::from("a b")];
let started = Instant::now();
wait_then_open(pid, &opener, Path::new("/Apps/X.app"), &args).status().unwrap();
assert!(started.elapsed() >= Duration::from_millis(700), "opened before the process exited");
assert!(reaper.join().unwrap().unwrap().success());
assert_eq!(std::fs::read_to_string(&log).unwrap(), "-n|/Apps/X.app|--args|--flag|a b|");
}
}
+1 -1
View File
@@ -534,7 +534,7 @@ export const commands = {
/** "Restore" of the tag-deleted notification. */
vcsRestoreTag: (repo: string | null, deleted: DeletedTag) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_restore_tag", { repo, deleted })),
/** New Branch: creates `name` at `start` (HEAD when absent), optionally checking it out. */
vcsCreateBranch: (repo: string | null, name: string, start: string | null, checkout: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_create_branch", { repo, name, start, checkout })),
vcsCreateBranch: (repo: string | null, name: string, start: string | null, checkout: boolean, overwrite: boolean | null) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_create_branch", { repo, name, start, checkout, overwrite })),
/**
* Undo Commit: moves HEAD back to the commit's parent, keeping its changes staged.
* Returns the undone commit's message.
+21
View File
@@ -119,6 +119,27 @@ describe("Typo inspection", () => {
expect(spellingIntentions(view)[0]!.label).toBe("Typo: Rename to…");
});
it("in a read-only document saves to dictionaries but changes nothing in place", async () => {
vi.useFakeTimers();
useProject.setState({ project: { root: "/p" } as never, settings: {} as never });
const view = new EditorView({
state: EditorState.create({
doc: "// mesage here",
extensions: [
lspDoc.of({ doc: 7, path: "/p/a.rs", language: "rust", uiMode: null, readOnly: true }),
spellingExtension(),
],
}),
});
views.push(view);
await settle();
expect(spellCheck).toHaveBeenCalled();
view.dispatch({ selection: { anchor: 5 } });
expect(spellingIntentions(view).map((i) => i.label)).toEqual(["Save 'mesage' to dictionary"]);
await spellingIntentions(view)[0]!.run();
expect(settingsSet).toHaveBeenCalledWith("project", "spelling.projectWords", JSON.stringify(["mesage"]));
});
it("checks only what the Typo options include, and re-checks when they change", async () => {
vi.useFakeTimers();
const options = (o: Record<string, boolean>) =>
+19 -10
View File
@@ -134,11 +134,15 @@ async function changeTo(view: EditorView, typo: PlacedTypo) {
});
}
/** Typo quick fixes at the caret (⌥↵), or none. */
/**
* Typo quick fixes at the caret (⌥↵), or none. Dictionary saves write
* settings, so they are offered in read-only documents too (library sources,
* stubs); only the in-place correction needs a writable file.
*/
export function spellingIntentions(view: EditorView): ListPopupItem[] {
const doc = docOf(view);
const sel = view.state.selection.main;
if (!doc || doc.readOnly || !sel.empty || !spellingEnabled()) return [];
if (!doc || !sel.empty || !spellingEnabled()) return [];
const typo = typoAt(view.state, sel.head);
if (!typo) return [];
const hasProject = !!useProject.getState().project;
@@ -162,15 +166,20 @@ export function spellingIntentions(view: EditorView): ListPopupItem[] {
{ kind: "separator" },
...intentionControls({ id: SPELLING_INSPECTION, name: "Typo" }),
];
const change: ListPopupItem[] = doc.readOnly
? []
: [
{
id: "spelling.change",
label: typo.context === "code" ? "Typo: Rename to…" : "Typo: Change to…",
icon: "bulb-fix",
emphasis: true,
run: () => changeTo(view, typo),
submenu: saveSubmenu,
},
];
return [
{
id: "spelling.change",
label: typo.context === "code" ? "Typo: Rename to…" : "Typo: Change to…",
icon: "bulb-fix",
emphasis: true,
run: () => changeTo(view, typo),
submenu: saveSubmenu,
},
...change,
{
id: "spelling.save",
label: tx`Save '${typo.word}' to dictionary`,
+20 -8
View File
@@ -1121,7 +1121,9 @@ export function commitMenu(c: Commit, selection: Commit[] = [c]): MenuItem[] {
{ label: "Browse Repository at Revision", run: () => browseRepositoryAt(c) },
];
const many = selection.length > 1 && selection.some((x) => commitKey(x) === commitKey(c));
const head = (commitRepo(c) ? useVcs.getState().heads[commitRepo(c)!]?.head : useVcs.getState().branch?.head) ?? null;
const repo = commitRepo(c);
const vcs = useVcs.getState();
const head = (repo ? vcs.heads[repo]?.head : vcs.branch?.head) ?? null;
const { outgoing } = useGitLog.getState();
const pushed = (x: Commit) =>
commitRepo(x) === undefined ? !!outgoing && !outgoing.has(x.id) : isPushed(commitKey(x));
@@ -1148,6 +1150,22 @@ export function commitMenu(c: Commit, selection: Commit[] = [c]): MenuItem[] {
((commitRepo(c) === undefined ? !!outgoing : useGitLog.getState().tracked.has(commitRepo(c) ?? ""))
? !!outgoing?.has(commitKey(c))
: true);
const branches = ((c as RepoCommit).refBranches ?? []).filter((branch) => !branch.current);
const branchActions: MenuItem[] = branches.map((branch) => {
const menu = branchMenu(branch, head);
return {
label: tx`Branch '${branch.name}'`,
submenu: repo ? inRepo(repo, menu) : menu,
};
});
if (!branchActions.length)
branchActions.push({
kind: "action",
action: "vcs.log.rebaseOntoCommit",
arg: c,
label: tx`Rebase '${head ?? "HEAD"}' onto Selected Commit`,
disabled: !head,
});
return [
{ kind: "action", action: "vcs.log.copyRevision", arg: c },
{
@@ -1173,13 +1191,7 @@ export function commitMenu(c: Commit, selection: Commit[] = [c]): MenuItem[] {
{ kind: "action", action: "vcs.log.interactiveRebase", arg: c, disabled: pushedCommit },
{ kind: "action", action: "vcs.log.pushUpToHere", arg: c, disabled: !canPushHere },
{ kind: "separator" },
{
kind: "action",
action: "vcs.log.rebaseOntoCommit",
arg: c,
label: tx`Rebase '${head ?? "HEAD"}' onto Selected Commit`,
disabled: !head,
},
...(branchActions.length > 2 ? [{ label: "Branches", submenu: branchActions }] : branchActions),
{ kind: "action", action: "vcs.log.newBranch", arg: c },
{ kind: "action", action: "vcs.log.newTag", arg: c },
{ kind: "separator" },
+7 -3
View File
@@ -31,7 +31,7 @@ import { showCompareBranches } from "./CompareBranches";
import { showDiffWithWorkingTree } from "./operations";
import type { CommonBranch } from "./repoBranches";
import { runRemote, saveAll } from "./ops";
import { focusRepo, useVcs } from "./store";
import { currentRepo, focusRepo, useVcs } from "./store";
import { tx } from "@/platform/i18n";
/** `git pull <remote> <branch>` into the current branch (`origin/feat/x` → origin, feat/x). */
@@ -173,6 +173,10 @@ export function commonBranchMenu(b: CommonBranch, roots: readonly string[], done
/** `items` acting on repository `root`: it is selected (with its branches loaded) before each entry runs. */
export function inRepo(root: string, items: MenuItem[]): MenuItem[] {
const runInRepo = (run: () => unknown) => () =>
void focusRepo(root).then(() => {
if (currentRepo() === root) return run();
});
return items.map((item): MenuItem => {
if (item.kind === "separator" || item.kind === "header") return item;
if (item.kind === "action")
@@ -180,12 +184,12 @@ export function inRepo(root: string, items: MenuItem[]): MenuItem[] {
label: item.label ?? item.action,
icon: item.icon,
disabled: item.disabled,
run: () => void focusRepo(root).then(() => executeAction(item.action, item.arg)),
run: runInRepo(() => executeAction(item.action, item.arg)),
};
const run = item.run;
return {
...item,
run: run && (() => void focusRepo(root).then(run)),
run: run && runInRepo(run),
submenu: item.submenu && inRepo(root, item.submenu),
};
});
+25 -16
View File
@@ -35,15 +35,18 @@ function rememberBranch(name: string | null | undefined) {
}
/** Valid-enough git ref name check (mirrors `git check-ref-format` basics). */
export function validateBranchName(name: string): string | null {
export function validateBranchName(
name: string,
overwrite = false,
branches: readonly Branch[] = useVcs.getState().branches,
): string | null {
const n = name.trim();
if (!n) return "Branch name must not be empty";
if (/[\s~^:?*[\\\p{Cc}]/u.test(n)) return "Branch name contains invalid characters";
if (n.startsWith("-") || n.startsWith("/") || n.endsWith("/") || n.endsWith(".") || n.endsWith(".lock"))
return "Invalid branch name";
if (n.includes("..") || n.includes("//") || n.includes("@{") || n === "@") return "Invalid branch name";
const exists = useVcs.getState().branches.some((b) => !b.remote && b.name === n);
if (exists) return `Branch '${n}' already exists`;
if (!overwrite && branches.some((b) => !b.remote && b.name === n)) return tx`Branch '${n}' already exists`;
return null;
}
@@ -154,16 +157,18 @@ export async function checkoutBranch(name: string) {
}
/** New Branch: creates a branch from HEAD (or `from`) and, by default, checks it out. */
export async function newBranch(from?: string) {
const repo = currentRepo();
export async function newBranch(from?: string, repo = currentRepo()) {
const state = useVcs.getState();
const selected = repo === state.repo;
const previous = selected ? state.branch?.head : repo ? state.heads[repo]?.head : null;
const branches =
selected && state.branchesLoaded
? state.branches
: await call(commands.vcsBranches(repo)).catch(() => (selected ? state.branches : []));
// IDEA opens the dialog with a suggestion instead of an empty field: a
// remote's name without the prefix, the source ref as `-copy`, or the
// current branch for a plain New Branch from HEAD.
const suggestion = from
? from.includes("/")
? shortRemoteName(from)
: `${from}-copy`
: (useVcs.getState().branch?.head ?? "");
const suggestion = from ? (from.includes("/") ? shortRemoteName(from) : `${from}-copy`) : (previous ?? "");
const r = await promptText({
title: from ? tx`Create New Branch from ${from}` : "Create New Branch",
label: "New branch name:",
@@ -172,15 +177,15 @@ export async function newBranch(from?: string) {
mono: true,
okLabel: "Create",
sanitize: cleanUpBranchNameOnTyping,
validate: validateBranchName,
validate: (name, overwrite) => validateBranchName(name, overwrite, branches),
overwriteBranch: (name) => branches.some((b) => !b.remote && b.name === name.trim()),
validateInitial: true,
checkbox: { label: "Checkout branch", initial: true },
});
if (!r) return;
const checkout = r.checked;
const previous = useVcs.getState().branch?.head;
if (checkout) await saveAll();
const create = () => call(commands.vcsCreateBranch(repo, r.value, from ?? null, checkout));
const create = () => call(commands.vcsCreateBranch(repo, r.value, from ?? null, checkout, r.overwrite ?? false));
const ok = await runOp(tx`Create branch ${r.value}`, create, {
success: checkout ? `Created and checked out ${r.value}` : `Created branch ${r.value}`,
});
@@ -525,9 +530,13 @@ export async function newBranchInRepos(roots: readonly string[], from?: string)
await eachRepo(
roots,
(root) =>
runOp(tx`Create branch ${name}`, () => call(commands.vcsCreateBranch(root, name, from ?? null, r.checked)), {
refresh: false,
}).then((done) => (done === undefined ? undefined : true)),
runOp(
tx`Create branch ${name}`,
() => call(commands.vcsCreateBranch(root, name, from ?? null, r.checked, false)),
{
refresh: false,
},
).then((done) => (done === undefined ? undefined : true)),
r.checked ? `Created and checked out ${name}` : `Created branch ${name}`,
{
undo: tx`delete branch ${name}`,
+11 -4
View File
@@ -42,18 +42,20 @@ export interface PromptOptions {
okLabel?: string;
mono?: boolean;
/** Returns an error message, or null when valid. */
validate?: (value: string) => string | null;
validate?: (value: string, overwrite: boolean) => string | null;
/** Rewrites the field as the user types or pastes (e.g. spaces in a branch name). */
sanitize?: (value: string) => string;
/** Show the prefilled value's problem (e.g. the branch exists) before it is edited. */
validateInitial?: boolean;
/** Optional checkbox below the field. */
checkbox?: { label: string; initial: boolean };
overwriteBranch?: (value: string) => boolean;
}
export interface PromptResult {
value: string;
checked: boolean;
overwrite?: boolean;
}
export function promptText(opts: PromptOptions): Promise<PromptResult | null> {
@@ -75,11 +77,13 @@ function PromptDialog(props: PromptOptions & { onDone: (r: PromptResult | null)
const { ref, clean } = useSanitizedField(sanitize);
const [value, setValue] = useState(initial);
const [checked, setChecked] = useState(checkbox?.initial ?? false);
const [overwrite, setOverwrite] = useState(false);
const [touched, setTouched] = useState(!!props.validateInitial && initial !== "");
const error = validate ? validate(value) : value.trim() ? null : "Value must not be empty";
const canOverwrite = props.overwriteBranch?.(value) ?? false;
const error = validate ? validate(value, canOverwrite && overwrite) : value.trim() ? null : "Value must not be empty";
const submit = () => {
setTouched(true);
if (!error) onDone({ value: value.trim(), checked });
if (!error) onDone({ value: value.trim(), checked, ...(canOverwrite ? { overwrite } : {}) });
};
return (
<Dialog
@@ -114,11 +118,14 @@ function PromptDialog(props: PromptOptions & { onDone: (r: PromptResult | null)
invalid={touched && !!error}
onChange={(e) => {
setTouched(true);
setValue(clean(e));
const next = clean(e);
if (next.trim() !== value.trim()) setOverwrite(false);
setValue(next);
}}
/>
{touched && error && value && <div className={styles.error}>{error}</div>}
{checkbox && <Checkbox checked={checked} onChange={setChecked} label={checkbox.label} />}
{canOverwrite && <Checkbox checked={overwrite} onChange={setOverwrite} label="Overwrite existing branch" />}
</form>
</Dialog>
);
+1 -1
View File
@@ -445,7 +445,7 @@ export function registerHistoryActions(): () => void {
category: "Git Log",
icon: "branch",
when: VCS,
run: withCommit((c) => newBranch(c.id)),
run: withCommit((c) => newBranch(c.id, commitRepo(c) ?? currentRepo())),
},
{
id: "vcs.log.newTag",
+11 -1
View File
@@ -973,13 +973,23 @@ function loadPage(replace: boolean, id: string, before = false): Promise<void> {
} else page = await fetchPage(null);
} else page = await fetchPage(null);
}
const repoBranches = repo ? (refs[repo] ?? []) : [];
const remoteNames = repo ? repoBranches.filter((b) => b.remote).map((b) => b.name) : undefined;
const branchesByTip = new Map<string, Branch[]>();
for (const branch of repoBranches) {
if (!branch.commit) continue;
const atTip = branchesByTip.get(branch.commit);
if (atTip) atTip.push(branch);
else branchesByTip.set(branch.commit, [branch]);
}
return page.map((c) => ({
...c,
repo,
user,
userName,
readOnly: tab?.readOnly === true,
remoteNames: repo ? refs[repo]?.filter((b) => b.remote).map((b) => b.name) : undefined,
remoteNames,
refBranches: branchesByTip.get(c.id)?.filter((branch) => c.refs.includes(branch.name)),
}));
}),
);
+1
View File
@@ -8,6 +8,7 @@ export type RepoCommit = Commit & {
user?: string | null;
userName?: string | null;
remoteNames?: string[];
refBranches?: Branch[];
readOnly?: boolean;
};
export const commitRepo = (c: Commit | undefined): string | null | undefined => (c as RepoCommit | undefined)?.repo;
+4 -1
View File
@@ -1,4 +1,6 @@
{
"Overwrite existing branch": "Overwrite existing branch",
"Branch '{n}' already exists": "Branch '{n}' already exists",
"Cannot copy reference": "Cannot copy reference",
"Decompiled assembly (read-only).": "Decompiled assembly (read-only).",
"Connect through:": "Connect through:",
@@ -62,5 +64,6 @@
"Regenerate Response": "Regenerate Response",
"Branch Conversation from Here": "Branch Conversation from Here",
"Couldn't regenerate the response": "Couldn't regenerate the response",
"Reasoning": "Reasoning"
"Reasoning": "Reasoning",
"Branch '{name}'": "Branch '{name}'"
}
+3
View File
@@ -448,6 +448,8 @@
"Checkout Tag or Revision": "签出标签或修订",
"Checkout Tag or Revision…": "签出标签或修订…",
"Checkout branch": "签出分支",
"Overwrite existing branch": "覆盖现有分支",
"Branch '{n}' already exists": "分支 '{n}' 已存在",
"Checks out the selected pull request, a number given as argument, or asks for one.": "签出所选拉取请求或参数指定编号的拉取请求,否则询问编号。",
"Cherry-Pick": "拣选",
"Children": "子项",
@@ -4267,6 +4269,7 @@
"Branch {name}": "分支 {name}",
"Tag {name}": "标签 {name}",
"Branch {branch} of {repository}": "{repository} 的分支 {branch}",
"Branch '{name}'": "分支 '{name}'",
"Branches of {root}": "{root} 的分支",
"Include {name} in commit": "在提交中包含 {name}",
"Compare {current} with {branch}": "比较 {current} 与 {branch}",