mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
Merge remote-tracking branch 'origin/main' into fix/agent-chat-transcript
# Conflicts: # src/platform/locales/text/en.json
This commit is contained in:
commit
021e1165a7
22 files changed
+442
-75
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(|_| ())
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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`,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)),
|
||||
}));
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}'"
|
||||
}
|
||||
@@ -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}",
|
||||
|
||||
Reference in new issue
Block a user