mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-09 17:20:38 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e7304a750 | ||
|
|
5d86688130 | ||
|
|
97ff542cdd |
No files matched your search
@@ -746,6 +746,10 @@ impl DebugManager {
|
||||
{
|
||||
spec.env.push(("JAVA_HOME".into(), home));
|
||||
}
|
||||
// Delve looks up Go before applying the debuggee's launch environment.
|
||||
if config.adapter == "delve" {
|
||||
spec.env.extend(config.env.iter().map(|(key, value)| (key.clone(), value.clone())));
|
||||
}
|
||||
if config.adapter == "rdbg" {
|
||||
rdbg_command_line(&mut spec, &config, &cwd)?;
|
||||
}
|
||||
|
||||
@@ -1040,6 +1040,39 @@ async fn connects_to_running_debug_server_over_tcp() {
|
||||
assert!(events.lock().iter().any(|e| matches!(e, IdeEvent::Debug { event: DebugEvent::Started { .. }, .. })));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delve_adapter_inherits_the_selected_go_environment() {
|
||||
struct CaptureLauncher {
|
||||
inner: FakeLauncher,
|
||||
env: Arc<Mutex<Vec<(String, String)>>>,
|
||||
}
|
||||
impl AdapterLauncher for CaptureLauncher {
|
||||
fn launch(&self, spec: AdapterSpec, reuse: Option<u16>) -> LaunchFuture {
|
||||
*self.env.lock() = spec.env.clone();
|
||||
self.inner.launch(spec, reuse)
|
||||
}
|
||||
}
|
||||
let env = Arc::new(Mutex::new(Vec::new()));
|
||||
let launcher =
|
||||
CaptureLauncher { inner: FakeLauncher { log: Arc::default(), script: Script::default() }, env: env.clone() };
|
||||
let manager = DebugManager::with_launcher(Arc::new(LanguageRegistry::from_configs(Vec::new())), Arc::new(launcher));
|
||||
let (owner, _) = recording_owner("main");
|
||||
let config = LaunchConfig {
|
||||
name: "Go".into(),
|
||||
adapter: "delve".into(),
|
||||
request: "launch".into(),
|
||||
program: Some("/project".into()),
|
||||
args: vec![],
|
||||
cwd: None,
|
||||
env: BTreeMap::from([("PATH".into(), "/chosen/go/bin".into()), ("GOROOT".into(), "/chosen/go".into())]),
|
||||
extra_json: Some(json!({"mode":"debug"}).to_string()),
|
||||
};
|
||||
let id = manager.start(config, owner).await.unwrap();
|
||||
assert!(env.lock().contains(&("PATH".into(), "/chosen/go/bin".into())));
|
||||
assert!(env.lock().contains(&("GOROOT".into(), "/chosen/go".into())));
|
||||
manager.stop(id).await.unwrap();
|
||||
}
|
||||
|
||||
/// Go Remote / Python remote: the adapter runs on another machine (`dlv --headless`,
|
||||
/// `debugpy --listen`); `debugServer: "host:port"` connects there instead of spawning one.
|
||||
#[tokio::test]
|
||||
|
||||
@@ -64,6 +64,7 @@ async fn debug_start_inner(
|
||||
let settings = settings.clone();
|
||||
config = host
|
||||
.blocking(move || {
|
||||
ide_sdk::go::prepare_debug_compiler(&mut config.env, &config.extra_json)?;
|
||||
ide_sdk::go::configure_environment(&root, &settings, &mut config.env);
|
||||
ide_sdk::go::apply_to_debug(
|
||||
&mut config.env,
|
||||
|
||||
+188
-1
@@ -131,6 +131,117 @@ fn delve_build_flags(args: &[String], files: bool) -> Value {
|
||||
)
|
||||
}
|
||||
|
||||
/// An explicitly selected Go executable must also be the compiler Delve finds on PATH.
|
||||
pub fn prepare_debug_compiler(
|
||||
env: &mut std::collections::BTreeMap<String, String>,
|
||||
extra_json: &Option<String>,
|
||||
) -> ide_core::IdeResult<()> {
|
||||
#[cfg(windows)]
|
||||
for key in ["PATH", "GOROOT"] {
|
||||
if let Some(previous) = env.keys().find(|name| name.eq_ignore_ascii_case(key) && name.as_str() != key).cloned()
|
||||
{
|
||||
let value = env.remove(&previous).unwrap();
|
||||
env.entry(key.into()).or_insert(value);
|
||||
}
|
||||
}
|
||||
let extra: Value = extra_json.as_deref().and_then(|value| serde_json::from_str(value).ok()).unwrap_or_default();
|
||||
let Some(executable) = extra["__goExecutable"].as_str().filter(|value| *value != "go" && *value != "go.exe") else {
|
||||
return Ok(());
|
||||
};
|
||||
let path = env.get("PATH").map(std::ffi::OsString::from).unwrap_or_else(crate::exec::search_path);
|
||||
let executable = ide_core::process::find_executable(executable, &path)
|
||||
.ok_or_else(|| ide_core::IdeError::unavailable(format!("Go executable not found: {executable}")))?;
|
||||
if !executable.file_name().is_some_and(|name| {
|
||||
if cfg!(windows) {
|
||||
let name = name.to_string_lossy();
|
||||
name.eq_ignore_ascii_case("go") || name.eq_ignore_ascii_case("go.exe")
|
||||
} else {
|
||||
name == "go"
|
||||
}
|
||||
}) {
|
||||
return Err(ide_core::IdeError::invalid("Delve requires a Go SDK executable named go or go.exe"));
|
||||
}
|
||||
if !env.contains_key("GOROOT") {
|
||||
let mut command = std::process::Command::new(&executable);
|
||||
command.args(["env", "GOROOT"]).envs(env.iter());
|
||||
let goroot = crate::exec::query_command(command, std::time::Duration::from_secs(10))
|
||||
.ok_or_else(|| ide_core::IdeError::unavailable("Cannot query the selected Go SDK"))?;
|
||||
env.insert("GOROOT".into(), goroot);
|
||||
}
|
||||
let mut paths = vec![executable.parent().unwrap().to_path_buf()];
|
||||
paths.extend(std::env::split_paths(&path));
|
||||
let path = std::env::join_paths(paths).map_err(|error| ide_core::IdeError::invalid(error.to_string()))?;
|
||||
env.insert("PATH".into(), path.to_string_lossy().into_owned());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_debug_target(
|
||||
program: &mut Option<String>,
|
||||
env: &std::collections::BTreeMap<String, String>,
|
||||
extra: &mut Value,
|
||||
flags: &mut Vec<String>,
|
||||
) -> ide_core::IdeResult<()> {
|
||||
let Some(targets) = extra.get("__goTargets") else { return Ok(()) };
|
||||
let targets: Vec<String> =
|
||||
serde_json::from_value(targets.clone()).map_err(|error| ide_core::IdeError::invalid(error.to_string()))?;
|
||||
let dir = extra["__goBuildDir"]
|
||||
.as_str()
|
||||
.map(std::path::Path::new)
|
||||
.ok_or_else(|| ide_core::IdeError::invalid("Go debug build directory is missing"))?;
|
||||
if targets.iter().any(|target| target.ends_with(".go")) {
|
||||
if !targets.iter().all(|target| target.ends_with(".go")) {
|
||||
return Err(ide_core::IdeError::invalid("Select Go source files or one package for debugging"));
|
||||
}
|
||||
let files: Vec<_> =
|
||||
targets.iter().map(|target| dunce::canonicalize(dir.join(target))).collect::<Result<_, _>>()?;
|
||||
let first = files.first().ok_or_else(|| ide_core::IdeError::invalid("Go debug target is missing"))?;
|
||||
if files.iter().any(|file| file.parent() != first.parent()) {
|
||||
return Err(ide_core::IdeError::invalid("Go source files must be in the same directory"));
|
||||
}
|
||||
*program = Some(first.to_string_lossy().into_owned());
|
||||
flags.extend(files.iter().skip(1).map(|file| file.to_string_lossy().into_owned()));
|
||||
extra["__goSourceArgs"] = json!(true);
|
||||
return Ok(());
|
||||
}
|
||||
let path = env.get("PATH").map(std::ffi::OsString::from).unwrap_or_else(crate::exec::search_path);
|
||||
let go = ide_core::process::find_executable("go", &path)
|
||||
.ok_or_else(|| ide_core::IdeError::unavailable("Go executable not found"))?;
|
||||
let mut command = std::process::Command::new(go);
|
||||
command.args(["list", "-find", "-f", "{{.Dir}}"]).current_dir(dir).envs(env.iter());
|
||||
let mut args = flags.iter();
|
||||
while let Some(flag) = args.next() {
|
||||
let name = flag.split('=').next().unwrap_or(flag);
|
||||
if ["-tags", "-mod", "-modfile", "-overlay", "-compiler"].contains(&name) {
|
||||
command.arg(flag);
|
||||
if name == flag
|
||||
&& let Some(value) = args.next()
|
||||
{
|
||||
command.arg(value);
|
||||
}
|
||||
} else if ["-race", "-msan", "-asan"].contains(&name) {
|
||||
command.arg(flag);
|
||||
}
|
||||
}
|
||||
command.args(&targets);
|
||||
let output = crate::exec::query_command(command, std::time::Duration::from_secs(10)).ok_or_else(|| {
|
||||
ide_core::IdeError::invalid(format!(
|
||||
"Cannot resolve Go debug target: {}. Check its module path and build flags.",
|
||||
targets.join(" ")
|
||||
))
|
||||
})?;
|
||||
let mut dirs: Vec<_> = output.lines().filter(|line| !line.trim().is_empty()).collect();
|
||||
dirs.sort();
|
||||
dirs.dedup();
|
||||
if dirs.len() != 1 {
|
||||
return Err(ide_core::IdeError::invalid(format!(
|
||||
"Go debugging requires one package; this target selects {}. Select a package or test instead.",
|
||||
dirs.len()
|
||||
)));
|
||||
}
|
||||
*program = Some(dirs[0].to_owned());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn apply_to_debug(
|
||||
env: &mut std::collections::BTreeMap<String, String>,
|
||||
program: &mut Option<String>,
|
||||
@@ -170,6 +281,7 @@ pub fn apply_to_debug(
|
||||
.collect::<Vec<_>>();
|
||||
merge_flags(&mut args, &flags);
|
||||
}
|
||||
resolve_debug_target(program, env, &mut extra, &mut args)?;
|
||||
if let Some(files) = extra.get("__goFiles").and_then(Value::as_array) {
|
||||
if extra["mode"] == "test" {
|
||||
if let Some(file) = files.first().and_then(Value::as_str).map(std::path::Path::new) {
|
||||
@@ -190,7 +302,10 @@ pub fn apply_to_debug(
|
||||
}
|
||||
// Delve appends its -o after array build flags; positional source files
|
||||
// require the string form so all compiler flags precede the file list.
|
||||
extra["buildFlags"] = delve_build_flags(&args, extra.get("__goFiles").is_some() && extra["mode"] != "test");
|
||||
extra["buildFlags"] = delve_build_flags(
|
||||
&args,
|
||||
(extra.get("__goFiles").is_some() && extra["mode"] != "test") || extra["__goSourceArgs"] == true,
|
||||
);
|
||||
}
|
||||
if let Some(output) = extra.get("output").and_then(Value::as_str).and_then(|p| std::path::Path::new(p).parent()) {
|
||||
std::fs::create_dir_all(output)?;
|
||||
@@ -199,6 +314,10 @@ pub fn apply_to_debug(
|
||||
map.remove("__goUseTags");
|
||||
map.remove("__goToolArgs");
|
||||
map.remove("__goFiles");
|
||||
map.remove("__goExecutable");
|
||||
map.remove("__goTargets");
|
||||
map.remove("__goBuildDir");
|
||||
map.remove("__goSourceArgs");
|
||||
}
|
||||
*extra_json = Some(extra.to_string());
|
||||
Ok(())
|
||||
@@ -209,6 +328,74 @@ mod command_tests {
|
||||
use super::*;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn debug_targets_resolve_imports_and_reject_multiple_packages() {
|
||||
if crate::exec::which("go").is_none() {
|
||||
return;
|
||||
}
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
std::fs::write(root.join("go.mod"), "module example.com/debugtargets\ngo 1.22\n").unwrap();
|
||||
for name in ["a", "b"] {
|
||||
std::fs::create_dir(root.join(name)).unwrap();
|
||||
std::fs::write(root.join(name).join("lib.go"), "package p\nfunc Value() {}\n").unwrap();
|
||||
}
|
||||
let mut env = BTreeMap::from([("GOWORK".into(), "off".into())]);
|
||||
let launch = |target: &str| {
|
||||
Some(json!({"mode":"test", "__goToolArgs":[], "__goTargets":[target], "__goBuildDir":root}).to_string())
|
||||
};
|
||||
let mut program = None;
|
||||
let mut extra = launch("example.com/debugtargets/a");
|
||||
apply_to_debug(&mut env, &mut program, &mut Vec::new(), &mut extra, &GoBuildTags::default()).unwrap();
|
||||
assert_eq!(program.as_deref(), root.join("a").to_str());
|
||||
let value: Value = serde_json::from_str(extra.as_deref().unwrap()).unwrap();
|
||||
assert!(value.get("__goTargets").is_none());
|
||||
let error =
|
||||
apply_to_debug(&mut env, &mut program, &mut Vec::new(), &mut launch("./..."), &GoBuildTags::default())
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("requires one package"), "{error}");
|
||||
std::fs::write(root.join("main.go"), "package main\nfunc main(){helper()}\n").unwrap();
|
||||
std::fs::write(root.join("helper.go"), "package main\nfunc helper(){}\n").unwrap();
|
||||
let mut extra = Some(
|
||||
json!({"mode":"debug","__goBuildDir":root,"__goTargets":["main.go","helper.go"],"__goToolArgs":[]})
|
||||
.to_string(),
|
||||
);
|
||||
apply_to_debug(&mut env, &mut program, &mut Vec::new(), &mut extra, &GoBuildTags::default()).unwrap();
|
||||
assert_eq!(program.as_deref(), root.join("main.go").to_str());
|
||||
let value: Value = serde_json::from_str(extra.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
value["buildFlags"],
|
||||
format!("'{}'", root.join("helper.go").to_string_lossy().replace('\\', "\\\\").replace('\'', "\\'"))
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn compiler_selection_preserves_case_insensitive_windows_environment_keys() {
|
||||
let mut env =
|
||||
BTreeMap::from([("Path".into(), "explicit path".into()), ("GoRoot".into(), "explicit root".into())]);
|
||||
prepare_debug_compiler(&mut env, &None).unwrap();
|
||||
assert_eq!(env["PATH"], "explicit path");
|
||||
assert_eq!(env["GOROOT"], "explicit root");
|
||||
assert!(!env.contains_key("Path"));
|
||||
assert!(!env.contains_key("GoRoot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_selected_go_compiler_precedes_the_existing_path() {
|
||||
let Some(go) = crate::exec::which("go") else {
|
||||
return;
|
||||
};
|
||||
let mut env = BTreeMap::from([("PATH".into(), "/unrelated".into())]);
|
||||
let extra = Some(json!({"__goExecutable":go}).to_string());
|
||||
prepare_debug_compiler(&mut env, &extra).unwrap();
|
||||
assert_eq!(std::env::split_paths(&env["PATH"]).next().as_deref(), go.parent());
|
||||
assert!(!env["GOROOT"].is_empty());
|
||||
env.insert("GOROOT".into(), "explicit".into());
|
||||
prepare_debug_compiler(&mut env, &extra).unwrap();
|
||||
assert_eq!(env["GOROOT"], "explicit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detected_go_runs_inherit_tags_only_for_commands_that_accept_build_flags() {
|
||||
let tags = GoBuildTags { custom: "integration".into(), ..Default::default() };
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { expect, ipcCalls, stubDesktop, test } from "./fixtures";
|
||||
|
||||
test("shows Update failures from the branch menu even when VCS notifications are muted", async ({
|
||||
workbench: page,
|
||||
}) => {
|
||||
await stubDesktop(page);
|
||||
await page.evaluate(async () => {
|
||||
const root = "/dev/acme-api";
|
||||
const branch = { head: "main", upstream: "origin/main", behind: 68, ahead: 0, operation: null, headCommit: "c1" };
|
||||
const branches = [
|
||||
{
|
||||
name: "main",
|
||||
current: true,
|
||||
remote: false,
|
||||
upstream: "origin/main",
|
||||
behind: 68,
|
||||
ahead: 0,
|
||||
commit: "c1",
|
||||
lastCommitMs: 0,
|
||||
},
|
||||
];
|
||||
const h = window.__ipc.handlers;
|
||||
h.vcs_branch_info = () => branch;
|
||||
h.vcs_branches = () => branches;
|
||||
h.vcs_roots = () => [{ root, head: "main", headCommit: "c1", operation: null }];
|
||||
h.vcs_status = () => [];
|
||||
h.vcs_log = () => [];
|
||||
h.vcs_tags = () => [];
|
||||
h.vcs_config_get = () => null;
|
||||
h.vcs_merge_base = () => "c1";
|
||||
h.vcs_remote = () => {
|
||||
throw {
|
||||
kind: "invalid",
|
||||
message:
|
||||
"The following untracked working tree files would be overwritten by merge: e2e/settingsFormLayout.spec.ts",
|
||||
};
|
||||
};
|
||||
const projectModule = "/src/platform/project.ts";
|
||||
const { useProject } = await import(/* @vite-ignore */ projectModule);
|
||||
useProject.setState({
|
||||
settings: {
|
||||
...useProject.getState().settings,
|
||||
notifications: { groups: { vcs: { display: "none", log: true } } },
|
||||
},
|
||||
});
|
||||
const storeModule = "/src/features/vcs/store.ts";
|
||||
const { useVcs } = await import(/* @vite-ignore */ storeModule);
|
||||
useVcs.setState({
|
||||
available: true,
|
||||
root,
|
||||
repo: root,
|
||||
roots: [root],
|
||||
branch,
|
||||
branches,
|
||||
branchesLoaded: true,
|
||||
});
|
||||
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");
|
||||
});
|
||||
const branches = page.getByRole("tree", { name: "Branches" });
|
||||
await branches.getByRole("treeitem", { name: /main current/ }).click({ button: "right" });
|
||||
await page.getByRole("menuitem", { name: "Update", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Update Project", exact: true });
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "OK", exact: true }).click();
|
||||
await expect(page.getByText("Update failed", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/untracked working tree files would be overwritten by merge/)).toBeVisible();
|
||||
expect(await ipcCalls(page, "vcs_remote")).toHaveLength(1);
|
||||
});
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
import { writeClipboardText } from "@/platform/clipboard";
|
||||
import { pasteEditorClipboard } from "./cm/clipboard";
|
||||
import type { ReactNode } from "react";
|
||||
import { EditorSelection } from "@codemirror/state";
|
||||
import { create } from "zustand";
|
||||
import { setContext } from "@/platform/context";
|
||||
@@ -211,6 +212,16 @@ export function tabIcon(input: EditorInput, kinds: EditorKinds): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** A kind's trailing marker (status dot …); `null` when it has none. */
|
||||
export function tabTrailing(input: EditorInput, kinds: EditorKinds): ReactNode {
|
||||
const kind = kinds.get(input.kind);
|
||||
try {
|
||||
return kind?.trailing?.(input) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class EditorController implements SlotHost {
|
||||
manager: DocumentManager;
|
||||
readonly slots = new Map<string, EditorSlot>();
|
||||
|
||||
@@ -46,7 +46,15 @@ import {
|
||||
type TabItem,
|
||||
type TabsDnd,
|
||||
} from "@/ui";
|
||||
import { editorController, isTextInput, tabIcon, tabTitle, useTabs, type VirtualInputData } from "../controller";
|
||||
import {
|
||||
editorController,
|
||||
isTextInput,
|
||||
tabIcon,
|
||||
tabTitle,
|
||||
tabTrailing,
|
||||
useTabs,
|
||||
type VirtualInputData,
|
||||
} from "../controller";
|
||||
import { useDocMeta } from "../model/document";
|
||||
import { inputDirty } from "../model/customDocuments";
|
||||
import type { LayoutNode, SplitEdge, Tab } from "../model/tabs";
|
||||
@@ -193,6 +201,8 @@ function PaneView({ paneId }: { paneId: string }) {
|
||||
: names.filter((n) => n === name).length > 1 && t.input.path
|
||||
? basename(dirname(t.input.path))
|
||||
: null;
|
||||
const kindMark = tabTrailing(t.input, kinds);
|
||||
const pin = t.pinned ? <Icon name="pin" size={11} className={styles.pin} label="Pinned" /> : null;
|
||||
return {
|
||||
id: t.id,
|
||||
label: (
|
||||
@@ -213,7 +223,13 @@ function PaneView({ paneId }: { paneId: string }) {
|
||||
color: colors[i] || undefined,
|
||||
tint: tints[i] || undefined,
|
||||
tooltip: virtualTooltip(t.input) ?? (showFullPathOnHover ? (t.input.path ?? name) : undefined),
|
||||
trailing: t.pinned ? <Icon name="pin" size={11} className={styles.pin} label="Pinned" /> : undefined,
|
||||
trailing:
|
||||
kindMark || pin ? (
|
||||
<>
|
||||
{kindMark}
|
||||
{pin}
|
||||
</>
|
||||
) : undefined,
|
||||
};
|
||||
});
|
||||
}, [
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/** The tab strip's agent status indicator: spinner while the task is busy, dot once done (#2233). */
|
||||
import { Badge, Spinner, tooltipAttrs } from "@/ui";
|
||||
import { isBusy, statusLabel } from "./model";
|
||||
import { useAgents } from "./store";
|
||||
|
||||
export function TaskStatusMark({ task }: { task: string }) {
|
||||
const status = useAgents((s) => s.tasks.get(task)?.status);
|
||||
if (!status) return null;
|
||||
const busy = isBusy(status);
|
||||
const label = statusLabel(status);
|
||||
return (
|
||||
<span {...tooltipAttrs(label, undefined, true)} style={{ display: "inline-flex", alignItems: "center" }}>
|
||||
{busy ? (
|
||||
<Spinner size={10} variant="spokes" label={label} />
|
||||
) : (
|
||||
<Badge
|
||||
dot
|
||||
tone={status === "ready" ? "success" : status === "failed" || status === "limited" ? "danger" : "neutral"}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
* Agents tool window lists tasks by what they need from the user, a task opens as an
|
||||
* editor tab (conversation + changed files), and New Task is an editor page.
|
||||
*/
|
||||
import { createElement } from "react";
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
@@ -22,6 +23,7 @@ import { HOME_KIND, REGISTRY_PAGE, SESSION_KIND, TASK_DRAG, TOOL_WINDOW } from "
|
||||
import { sendPaths, sendSelection } from "./references";
|
||||
import { focusedSession } from "./sessionFocus";
|
||||
import { connect, disconnect, openHome, useAgents } from "./store";
|
||||
import { TaskStatusMark } from "./TaskStatusMark";
|
||||
import { taskActions } from "./taskActions";
|
||||
import { replyActions } from "./replyActions";
|
||||
|
||||
@@ -88,6 +90,8 @@ export function activate(): () => void {
|
||||
return useAgents.getState().tasks.get(data.task)?.title ?? data.title ?? "Agent Task";
|
||||
},
|
||||
icon: () => "toolwindow-agents",
|
||||
// Busy / done marker in the tab's trailing slot (#2233).
|
||||
trailing: (input) => createElement(TaskStatusMark, { task: (input.data as { task: string }).task }),
|
||||
// Tabs of tasks deleted meanwhile are closed when the tasks load (`connect`).
|
||||
restorable: true,
|
||||
// Rebuilding a long conversation on every switch back is slow.
|
||||
|
||||
@@ -297,7 +297,7 @@ function onEvent(e: AgentEvent) {
|
||||
case "task": {
|
||||
const prev = s.tasks.get(e.task.id);
|
||||
useAgents.setState({ tasks: new Map(s.tasks).set(e.task.id, e.task) });
|
||||
if (prev && prev.title !== e.task.title) notifyTabTitlesChanged();
|
||||
if (prev && (prev.title !== e.task.title || prev.status !== e.task.status)) notifyTabTitlesChanged();
|
||||
if (e.task.status === "archived") setUnread(e.task.id, false);
|
||||
else if (attentionChange(prev?.status, e.task.status)) markUnread(e.task.id);
|
||||
notifyChange(prev, e.task);
|
||||
|
||||
@@ -253,7 +253,13 @@ describe("run config → launch config", () => {
|
||||
it("delve: debug mode for run, test mode for test", () => {
|
||||
const run = launchConfigFor(rc({ kind: "go", command: "go", args: ["run", "."], debugAdapter: "delve" }), "/p")!;
|
||||
expect(run.program).toBe("/p");
|
||||
expect(extra(run)).toEqual({ mode: "debug", __goToolArgs: [], __goUseTags: true });
|
||||
expect(extra(run)).toMatchObject({
|
||||
mode: "debug",
|
||||
__goTargets: ["."],
|
||||
__goExecutable: "go",
|
||||
__goToolArgs: [],
|
||||
__goUseTags: true,
|
||||
});
|
||||
const sub = launchConfigFor(
|
||||
rc({ kind: "go", command: "go", args: ["run", "./cmd/api"], debugAdapter: "delve" }),
|
||||
"/p",
|
||||
@@ -263,8 +269,8 @@ describe("run config → launch config", () => {
|
||||
rc({ kind: "go", command: "go", args: ["test", "./..."], debugAdapter: "delve" }),
|
||||
"/p",
|
||||
)!;
|
||||
expect(extra(test)).toEqual({ mode: "test", __goToolArgs: [], __goUseTags: true });
|
||||
expect(test.program).toBe("/p");
|
||||
expect(extra(test)).toMatchObject({ mode: "test", __goTargets: ["./..."], __goToolArgs: [], __goUseTags: true });
|
||||
expect(test.program).toBe("/p/...");
|
||||
// A gutter test / subtest: the package, and `-run` for the test binary.
|
||||
const one = launchConfigFor(
|
||||
rc({
|
||||
@@ -419,6 +425,50 @@ it("netcoredbg launches the evaluated DLL with resolved profile arguments, env a
|
||||
expect(launchConfigFor({ ...c, params: {} }, "/repo")).toBeNull();
|
||||
});
|
||||
|
||||
describe("Go debug target resolution inputs", () => {
|
||||
it("preserves import paths and multiple source files for Go to resolve", () => {
|
||||
const imported = launchConfigFor(
|
||||
rc({ kind: "go", command: "/sdk/bin/go", args: ["test", "example.com/app/calc"], debugAdapter: "delve" }),
|
||||
"/p",
|
||||
)!;
|
||||
expect(imported.program).toBe("example.com/app/calc");
|
||||
expect(extra(imported)).toMatchObject({
|
||||
__goTargets: ["example.com/app/calc"],
|
||||
__goExecutable: "/sdk/bin/go",
|
||||
__goBuildDir: "/p",
|
||||
});
|
||||
const files = launchConfigFor(
|
||||
rc({ kind: "go", command: "go", args: ["run", "main.go", "helper.go", "hello"], debugAdapter: "delve" }),
|
||||
"/p",
|
||||
)!;
|
||||
expect(files.args).toEqual(["hello"]);
|
||||
expect(extra(files).__goTargets).toEqual(["main.go", "helper.go"]);
|
||||
const packages = launchConfigFor(
|
||||
rc({ kind: "go", command: "go", args: ["build", "./a", "./b"], debugAdapter: "delve" }),
|
||||
"/p",
|
||||
)!;
|
||||
expect(extra(packages).__goTargets).toEqual(["./a", "./b"]);
|
||||
});
|
||||
|
||||
it("keeps a typed recursive package target and the module's selected compiler", () => {
|
||||
const launch = launchConfigFor(
|
||||
rc({
|
||||
kind: "go",
|
||||
command: "./sdk/bin/go",
|
||||
args: ["test", "./..."],
|
||||
debugAdapter: "delve",
|
||||
params: { goKind: "test", scope: "package", target: "./...", module: "backend" },
|
||||
}),
|
||||
"/p",
|
||||
)!;
|
||||
expect(extra(launch)).toMatchObject({
|
||||
__goTargets: ["./..."],
|
||||
__goBuildDir: "/p/backend",
|
||||
__goExecutable: "/p/backend/sdk/bin/go",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("raw and typed Go debug configurations inherit custom tags unless explicitly disabled", () => {
|
||||
for (const typed of [false, true]) {
|
||||
for (const useTags of [undefined, "true", "false"]) {
|
||||
|
||||
@@ -16,7 +16,7 @@ function join(base: string, p: string) {
|
||||
|
||||
/** A command path with a directory part resolved against `cwd`; bare names stay for PATH lookup. */
|
||||
function resolveCommand(cmd: string, cwd: string) {
|
||||
return cmd.includes("/") ? join(cwd, cmd) : cmd;
|
||||
return /[\\/]/.test(cmd) ? join(cwd, cmd) : cmd;
|
||||
}
|
||||
|
||||
function splitAtDashDash(args: readonly string[]): [string[], string[]] {
|
||||
@@ -225,6 +225,9 @@ function delveLaunch(rc: RunConfig, cwd: string, root: string): LaunchConfig {
|
||||
dlvCwd: module,
|
||||
...(p.scope === "file" ? { __goFiles: target.split("|").map((f) => join(module, f.trim())) } : {}),
|
||||
__goToolArgs: toolArgs,
|
||||
...(p.scope === "file" ? {} : { __goTargets: [target] }),
|
||||
__goBuildDir: module,
|
||||
__goExecutable: resolveCommand(rc.command, module),
|
||||
__goUseTags: p.useTags !== "false",
|
||||
...(p.outputDir ? { output: join(root, `${p.outputDir}/go-debug`) } : {}),
|
||||
});
|
||||
@@ -261,9 +264,11 @@ function delveLaunch(rc: RunConfig, cwd: string, root: string): LaunchConfig {
|
||||
}
|
||||
const selection =
|
||||
test &&
|
||||
/^-(run|skip|bench|benchtime|count|timeout|parallel|cpu|list|fuzz|fuzztime|fuzzminimizetime)(?:=(.*))?$/.exec(a);
|
||||
if (test && /^-(short|failfast|benchmem|fullpath|v)(?:=|$)/.test(a)) {
|
||||
testFlags.push(a.replace(/^-/, "-test."));
|
||||
/^-(?:test\.)?(run|skip|bench|benchtime|count|timeout|parallel|cpu|list|fuzz|fuzztime|fuzzminimizetime)(?:=(.*))?$/.exec(
|
||||
a,
|
||||
);
|
||||
if (test && /^-(?:test\.)?(short|failfast|benchmem|fullpath|v)(?:=|$)/.test(a)) {
|
||||
testFlags.push(a.startsWith("-test.") ? a : a.replace(/^-/, "-test."));
|
||||
} else if (selection) {
|
||||
const value = selection[2] ?? rest[++i];
|
||||
if (value !== undefined) testFlags.push(`-test.${selection[1]}`, value);
|
||||
@@ -271,18 +276,25 @@ function delveLaunch(rc: RunConfig, cwd: string, root: string): LaunchConfig {
|
||||
if (test && (a === "-json" || a === "-c")) continue;
|
||||
flags.push(a);
|
||||
if (!a.includes("=") && !boolFlags.has(a) && rest[i + 1] !== undefined) flags.push(rest[++i]!);
|
||||
} else if (!test && targets.length) {
|
||||
} else if (
|
||||
sub === "run" &&
|
||||
targets.length &&
|
||||
!(a.endsWith(".go") && targets.every((target) => target.endsWith(".go")))
|
||||
) {
|
||||
programArgs = rest.slice(i);
|
||||
break;
|
||||
} else targets.push(a);
|
||||
}
|
||||
const target = targets[0] ?? ".";
|
||||
out.program = target.endsWith("/...") ? cwd : join(cwd, target);
|
||||
out.program = !target.startsWith(".") && !isAbsolute(target) && !target.endsWith(".go") ? target : join(cwd, target);
|
||||
out.args = [...testFlags, ...programArgs];
|
||||
out.extraJson = JSON.stringify({
|
||||
mode: test ? "test" : "debug",
|
||||
...(flags.length ? { buildFlags: formatArgs(flags) } : {}),
|
||||
__goToolArgs: flags,
|
||||
__goTargets: targets.length ? targets : ["."],
|
||||
__goBuildDir: cwd,
|
||||
__goExecutable: resolveCommand(rc.command, cwd),
|
||||
__goUseTags: rc.params?.useTags !== "false",
|
||||
});
|
||||
return out;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ToolWindowHeader, useOwnToolWindowHeader } from "@/workbench/toolwindow
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import { commands, isTauri } from "@/platform/ipc";
|
||||
import {
|
||||
Badge,
|
||||
dropZoneAt,
|
||||
IconButton,
|
||||
Input,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
Popup,
|
||||
registerDropTarget,
|
||||
showContextMenu,
|
||||
Spinner,
|
||||
SplitGroup,
|
||||
SplitHandle,
|
||||
SplitPanel,
|
||||
@@ -356,8 +358,19 @@ function GroupTabs({ group }: { group: string }) {
|
||||
const focusRaf = useRef(0);
|
||||
|
||||
const items: TabItem[] = tabs.map((t) => {
|
||||
const status = sessions[t.session]?.status;
|
||||
const session = sessions[t.session];
|
||||
const status = session?.status;
|
||||
const dead = status === "exited" || status === "error";
|
||||
// Running / done marker (#2233): a command at work spins, a finished one
|
||||
// shows a filled dot, an idle shell at the prompt shows none.
|
||||
const busy = !!session?.commandRunning || status === "starting";
|
||||
const mark = busy ? (
|
||||
<Spinner size={10} variant="spokes" label="Running" />
|
||||
) : dead ? (
|
||||
<Badge dot tone={status === "error" ? "danger" : "neutral"}>
|
||||
{status === "error" ? "Failed" : "Exited"}
|
||||
</Badge>
|
||||
) : null;
|
||||
return {
|
||||
id: t.id,
|
||||
label: tabTitle(t, sessions),
|
||||
@@ -365,6 +378,7 @@ function GroupTabs({ group }: { group: string }) {
|
||||
italic: dead,
|
||||
color: dead ? "var(--fg-subtle)" : undefined,
|
||||
tooltip: tabTooltip(t, sessions),
|
||||
trailing: mark ?? undefined,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { BranchInfo, RemoteOp } from "@/bindings";
|
||||
import type { BranchInfo } from "@/bindings";
|
||||
|
||||
let branch: Partial<BranchInfo> = {};
|
||||
/** What a pull brings in: the head moves to `c2`, with these changed files and commits. */
|
||||
let pulled = { files: 0, commits: 0 };
|
||||
let failure: { kind: string; message: string } | null = null;
|
||||
let remoteGate: Promise<void> | null = null;
|
||||
|
||||
vi.mock("@tauri-apps/api/core", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@tauri-apps/api/core")>()),
|
||||
@@ -25,12 +23,9 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
data: { head: "main", headCommit: "c1", upstream: null, ahead: 0, behind: 0, operation: null, ...branch },
|
||||
}),
|
||||
vcsRemote: async () => {
|
||||
await remoteGate;
|
||||
if (failure) return { status: "error", error: failure };
|
||||
if (pulled.commits) branch = { ...branch, headCommit: "c2" };
|
||||
return { status: "ok", data: null };
|
||||
},
|
||||
vcsOperation: async () => (failure ? { status: "error", error: failure } : { status: "ok", data: null }),
|
||||
vcsDiffRevisions: async () => ({ status: "ok", data: Array.from({ length: pulled.files }, (_, i) => ({ i })) }),
|
||||
vcsLog: async () => ({ status: "ok", data: Array.from({ length: pulled.commits }, (_, i) => ({ i })) }),
|
||||
cancelRequest: async () => ({ status: "ok", data: null }),
|
||||
@@ -40,10 +35,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
|
||||
import { _resetI18nForTests, setLocale, txTemplate } from "@/platform/i18n";
|
||||
import { provideNotificationService } from "@/workbench/contributions";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { expireNotification, notify, useNotifications } from "@/workbench/notificationStore";
|
||||
import { runGitOperation, runOp, runRemote } from "../ops";
|
||||
import { useVcs } from "../store";
|
||||
import { runRemote } from "../ops";
|
||||
|
||||
type Note = { title: string; level: string };
|
||||
let notes: Note[] = [];
|
||||
@@ -56,11 +48,6 @@ beforeEach(() => {
|
||||
afterEach(() => {
|
||||
branch = {};
|
||||
pulled = { files: 0, commits: 0 };
|
||||
failure = null;
|
||||
remoteGate = null;
|
||||
useVcs.setState({ remoteBusy: null });
|
||||
useNotifications.setState({ items: [] });
|
||||
useProject.setState({ settings: null });
|
||||
_resetI18nForTests();
|
||||
});
|
||||
|
||||
@@ -118,97 +105,3 @@ describe("Update Project result notification", () => {
|
||||
expect(txTemplate(await updateTitle())).toBe("已在 2 个提交中更新 3 个文件");
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
["VCS popups disabled", { groups: { vcs: { display: "none", log: true } } }],
|
||||
["all balloons disabled", { balloons: false }],
|
||||
["VCS popups and logging disabled", { groups: { vcs: { display: "none", log: false } } }],
|
||||
])("Git command feedback with %s", (_name, prefs) => {
|
||||
beforeEach(() => {
|
||||
useProject.setState({ settings: { notifications: prefs } as never });
|
||||
provideNotificationService({ notify, expire: expireNotification });
|
||||
});
|
||||
|
||||
it("shows the untracked-file error that prevented Update", async () => {
|
||||
failure = {
|
||||
kind: "invalid",
|
||||
message:
|
||||
"The following untracked working tree files would be overwritten by merge: e2e/settingsFormLayout.spec.ts",
|
||||
};
|
||||
expect(await runRemote({ op: "pull", rebase: false }, undefined, ["/p"])).toBe(false);
|
||||
expect(useNotifications.getState().items.find((n) => n.title === "Update failed")).toMatchObject({
|
||||
balloon: true,
|
||||
body: failure.message,
|
||||
});
|
||||
});
|
||||
|
||||
it.each<[RemoteOp, string]>([
|
||||
[{ op: "fetch" }, "Fetch successful"],
|
||||
[{ op: "push", force_with_lease: false, set_upstream: false, tags: null, no_verify: false }, "Push successful"],
|
||||
[{ op: "pull", rebase: false }, "All files are up-to-date"],
|
||||
])("shows progress and the result of $op", async (op, title) => {
|
||||
let finish!: () => void;
|
||||
remoteGate = new Promise((resolve) => (finish = resolve));
|
||||
const running = runRemote(op, undefined, ["/p"]);
|
||||
try {
|
||||
expect(useNotifications.getState().items.find((n) => n.key === "vcs.remote")).toMatchObject({
|
||||
balloon: true,
|
||||
sticky: true,
|
||||
});
|
||||
} finally {
|
||||
finish();
|
||||
}
|
||||
expect(await running).toBe(true);
|
||||
expect(useNotifications.getState().items.find((n) => n.title === title)?.balloon).toBe(true);
|
||||
});
|
||||
|
||||
it("shows the files and commits that Update brought in", async () => {
|
||||
pulled = { files: 3, commits: 2 };
|
||||
expect(await runRemote({ op: "pull", rebase: false }, undefined, ["/p"])).toBe(true);
|
||||
expect(useNotifications.getState().items.find((n) => n.title === "3 files updated in 2 commits")?.balloon).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("explains why another remote operation cannot start", async () => {
|
||||
useVcs.setState({ remoteBusy: "Fetching" });
|
||||
expect(await runRemote({ op: "fetch" })).toBe(false);
|
||||
expect(useNotifications.getState().items[0]).toMatchObject({
|
||||
title: "Another remote operation is in progress",
|
||||
balloon: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps cancelled and caller-handled failures quiet", async () => {
|
||||
failure = { kind: "cancelled", message: "cancelled" };
|
||||
expect(await runRemote({ op: "fetch" })).toBe(false);
|
||||
expect(useNotifications.getState().items.some((n) => n.balloon)).toBe(false);
|
||||
failure = { kind: "invalid", message: "rejected" };
|
||||
expect(await runRemote({ op: "fetch" }, undefined, ["/p"], () => true)).toBe(false);
|
||||
expect(useNotifications.getState().items.some((n) => n.balloon)).toBe(false);
|
||||
});
|
||||
|
||||
it("shows local command failures but respects quiet callers", async () => {
|
||||
const fail = () => Promise.reject(new Error("index locked"));
|
||||
await runOp("Commit", fail);
|
||||
expect(useNotifications.getState().items[0]).toMatchObject({ title: "Commit failed", balloon: true });
|
||||
useNotifications.setState({ items: [] });
|
||||
await runOp("Commit", fail, { quiet: true });
|
||||
expect(useNotifications.getState().items).toEqual([]);
|
||||
});
|
||||
|
||||
it("shows long local operation results", async () => {
|
||||
const op = { op: "cherryPick", commits: ["c1"] } as const;
|
||||
const options = { progress: "Cherry-picking", success: "Cherry-picked c1", failure: "Cherry-pick failed" };
|
||||
expect(await runGitOperation({ ...op, commits: [...op.commits] }, options)).toBe(true);
|
||||
expect(useNotifications.getState().items[0]).toMatchObject({ title: options.success, balloon: true });
|
||||
failure = { kind: "invalid", message: "invalid revision" };
|
||||
expect(await runGitOperation({ ...op, commits: [...op.commits] }, options)).toBe(false);
|
||||
expect(useNotifications.getState().items[0]).toMatchObject({ title: options.failure, balloon: true });
|
||||
});
|
||||
|
||||
it("still suppresses unsolicited VCS notifications", () => {
|
||||
notify({ group: "vcs", level: "info", title: "Background refresh" });
|
||||
expect(useNotifications.getState().items.some((n) => n.balloon)).toBe(false);
|
||||
});
|
||||
});
|
||||
+20
-18
@@ -9,7 +9,7 @@ import { executeAction } from "@/platform/actions";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { call, commands, isCancelled, nextRequestId } from "@/platform/ipc";
|
||||
import { attachDocument, editorsFor } from "@/editor/api";
|
||||
import { maybeEditorService, notifications, type NotificationService } from "@/workbench/contributions";
|
||||
import { maybeEditorService, notifications } from "@/workbench/contributions";
|
||||
import { cancelTask, upsertTaskSoon, useTasks } from "@/workbench/tasks";
|
||||
import { progressFraction } from "./model";
|
||||
import { beginGitWork, conflictedFiles, currentRepo, refreshNow, repoName, useVcs } from "./store";
|
||||
@@ -55,11 +55,6 @@ export function errorMessage(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
/** Feedback for an explicit Git command stays visible when background VCS notifications are muted. */
|
||||
function notify(n: Omit<Parameters<NotificationService["notify"]>[0], "group" | "userInitiated">) {
|
||||
notifications().notify({ ...n, group: "vcs", userInitiated: true });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local operations
|
||||
|
||||
@@ -85,7 +80,7 @@ export async function runOp<T>(title: string, fn: () => Promise<T>, opts: OpOpti
|
||||
const msg = typeof opts.success === "function" ? opts.success(value) : opts.success;
|
||||
if (msg) {
|
||||
logConsole("info", msg);
|
||||
notify({ level: "info", title: msg });
|
||||
notifications().notify({ group: "vcs", level: "info", title: msg });
|
||||
}
|
||||
return value;
|
||||
} catch (e) {
|
||||
@@ -93,7 +88,8 @@ export async function runOp<T>(title: string, fn: () => Promise<T>, opts: OpOpti
|
||||
logConsole("err", message);
|
||||
const handled = opts.onError ? await opts.onError(message) : false;
|
||||
if (!opts.quiet && !handled)
|
||||
notify({
|
||||
notifications().notify({
|
||||
group: "vcs",
|
||||
level: "error",
|
||||
title: tx`${title} failed`,
|
||||
body: message,
|
||||
@@ -164,7 +160,7 @@ export async function runRemote(
|
||||
handled?: (message: string) => boolean,
|
||||
): Promise<boolean> {
|
||||
if (useVcs.getState().remoteBusy) {
|
||||
notify({ level: "warning", title: "Another remote operation is in progress" });
|
||||
notifications().notify({ group: "vcs", level: "warning", title: "Another remote operation is in progress" });
|
||||
return false;
|
||||
}
|
||||
const [progressText, doneTitle] = REMOTE_TITLE[op.op];
|
||||
@@ -177,7 +173,8 @@ export async function runRemote(
|
||||
const task = (message: string | null, fraction: number | null, done: boolean) =>
|
||||
upsertTaskSoon({ id: taskId, title: progressTitle, message, fraction, done, cancel });
|
||||
task(detail ?? null, null, false);
|
||||
notify({
|
||||
notifications().notify({
|
||||
group: "vcs",
|
||||
level: "info",
|
||||
key: "vcs.remote",
|
||||
title: `${progressTitle}…`,
|
||||
@@ -234,7 +231,8 @@ export async function runRemote(
|
||||
else {
|
||||
// One repository's push names what went out; a partial multi-repo push stays generic.
|
||||
const one = pushed.length === 1 ? pushed[0] : null;
|
||||
notify({
|
||||
notifications().notify({
|
||||
group: "vcs",
|
||||
level: "info",
|
||||
key: "vcs.remote",
|
||||
title:
|
||||
@@ -258,7 +256,8 @@ export async function runRemote(
|
||||
notifications().expire?.("vcs.remote");
|
||||
void executeAction("vcs.resolveConflicts");
|
||||
} else
|
||||
notify({
|
||||
notifications().notify({
|
||||
group: "vcs",
|
||||
level: "error",
|
||||
key: "vcs.remote",
|
||||
title: `${doneTitle} failed`,
|
||||
@@ -320,7 +319,7 @@ const pushedTitle = (n: number, to: string) =>
|
||||
/** IDEA's Update Project result: "N files updated in M commits" with View Commits. */
|
||||
async function notifyUpdated(updated: readonly UpdatedRange[]) {
|
||||
if (!updated.length) {
|
||||
notify({ level: "info", key: "vcs.remote", title: "All files are up-to-date" });
|
||||
notifications().notify({ group: "vcs", level: "info", key: "vcs.remote", title: "All files are up-to-date" });
|
||||
return;
|
||||
}
|
||||
let files = 0;
|
||||
@@ -345,7 +344,8 @@ async function notifyUpdated(updated: readonly UpdatedRange[]) {
|
||||
commits += log.length;
|
||||
}),
|
||||
);
|
||||
notify({
|
||||
notifications().notify({
|
||||
group: "vcs",
|
||||
level: "info",
|
||||
key: "vcs.remote",
|
||||
title: `${filesText(files)} updated in ${commitsText(commits)}`,
|
||||
@@ -389,7 +389,7 @@ export async function runGitOperation(
|
||||
repo = currentRepo(),
|
||||
): Promise<boolean> {
|
||||
if (useVcs.getState().remoteBusy) {
|
||||
notify({ level: "warning", title: "Another Git operation is in progress" });
|
||||
notifications().notify({ group: "vcs", level: "warning", title: "Another Git operation is in progress" });
|
||||
return false;
|
||||
}
|
||||
await saveAll();
|
||||
@@ -402,7 +402,8 @@ export async function runGitOperation(
|
||||
const task = (message: string | null, done: boolean) =>
|
||||
upsertTaskSoon({ id: taskId, title: progress, message, fraction: done ? 1 : null, done, cancel });
|
||||
task(null, false);
|
||||
notify({
|
||||
notifications().notify({
|
||||
group: "vcs",
|
||||
level: "info",
|
||||
key: "vcs.operation",
|
||||
title: `${progress}…`,
|
||||
@@ -424,7 +425,7 @@ export async function runGitOperation(
|
||||
}
|
||||
logConsole("info", success);
|
||||
const actions = opts.successActions ? await opts.successActions() : undefined;
|
||||
notify({ level: "info", key: "vcs.operation", title: success, actions });
|
||||
notifications().notify({ group: "vcs", level: "info", key: "vcs.operation", title: success, actions });
|
||||
return true;
|
||||
} catch (e) {
|
||||
const cancelled = isCancelled(e);
|
||||
@@ -434,7 +435,8 @@ export async function runGitOperation(
|
||||
await refreshNow();
|
||||
const handled = cancelled || (opts.onError ? await opts.onError(message) : false);
|
||||
if (!handled)
|
||||
notify({
|
||||
notifications().notify({
|
||||
group: "vcs",
|
||||
level: "error",
|
||||
key: "vcs.operation",
|
||||
title: opts.failure,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { installRepeatedDoubleClick } from "./platform/doubleClick";
|
||||
import { installErrorLogging, logError } from "./platform/errorLog";
|
||||
import { guessOs } from "./platform/platformInfo";
|
||||
import { installReorderedTapClick } from "./platform/reordered-tap-click";
|
||||
import { installWebKitClipboardGuard } from "./platform/webkitClipboard";
|
||||
import { installPageSwipeTracking } from "./platform/pageSwipe";
|
||||
import { installImeKeyGuard } from "./platform/ime";
|
||||
import { installKeyboardDispatcher } from "./platform/keymap";
|
||||
@@ -38,6 +39,7 @@ installRepeatedDoubleClick();
|
||||
// second press like the release before it, which the shim would take for a reordered tap and
|
||||
// click twice.
|
||||
if (guessOs() === "macos" && !/Chrome|Firefox/.test(navigator.userAgent)) installReorderedTapClick();
|
||||
if (guessOs() === "linux" && isTauri()) installWebKitClipboardGuard();
|
||||
installWindowDrag();
|
||||
installPointerFocus();
|
||||
installImeKeyGuard();
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { EditorSelection, EditorState, StateEffect } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { installWebKitClipboardGuard } from "./webkitClipboard";
|
||||
|
||||
const views: EditorView[] = [];
|
||||
let dispose: () => void;
|
||||
beforeEach(() => {
|
||||
dispose = installWebKitClipboardGuard();
|
||||
});
|
||||
afterEach(() => {
|
||||
dispose();
|
||||
views.splice(0).forEach((view) => view.destroy());
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
function editor(anchor = 5, head = anchor) {
|
||||
const view = new EditorView({
|
||||
doc: "first\nsecond\nthird",
|
||||
selection: EditorSelection.single(anchor, head),
|
||||
parent: document.body,
|
||||
});
|
||||
views.push(view);
|
||||
view.focus();
|
||||
return view;
|
||||
}
|
||||
|
||||
function key(target: HTMLElement, type = "keydown", init: KeyboardEventInit = { key: "x", ctrlKey: true }) {
|
||||
const event = new KeyboardEvent(type, { bubbles: true, cancelable: true, ...init });
|
||||
target.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
function clipboard(target: HTMLElement, type = "cut") {
|
||||
const data = new Map<string, string>();
|
||||
const event = new Event(type, { bubbles: true, cancelable: true });
|
||||
Object.assign(event, { clipboardData: { clearData: () => data.clear(), setData: data.set.bind(data) } });
|
||||
target.dispatchEvent(event);
|
||||
return data.get("text/plain");
|
||||
}
|
||||
|
||||
describe("WebKitGTK clipboard events", () => {
|
||||
it.each([
|
||||
{ key: "x", ctrlKey: true },
|
||||
{ key: "Delete", shiftKey: true },
|
||||
])("cuts only the caret line for one $key keydown and two cut events (#962)", (shortcut) => {
|
||||
const view = editor();
|
||||
key(view.contentDOM, "keydown", shortcut);
|
||||
expect(clipboard(view.contentDOM)).toBe("first");
|
||||
expect(clipboard(view.contentDOM)).toBeUndefined();
|
||||
expect(view.state.doc.toString()).toBe("second\nthird");
|
||||
});
|
||||
|
||||
it.each([0, 2, 5, 6, 12, 18])("protects a caret at offset %i", (position) => {
|
||||
const view = editor(position);
|
||||
const line = view.state.doc.lineAt(position);
|
||||
const expected = view.state.doc.toString().slice(0, line.from) + view.state.doc.toString().slice(line.to + 1);
|
||||
key(view.contentDOM);
|
||||
expect(clipboard(view.contentDOM)).toBe(line.text);
|
||||
clipboard(view.contentDOM);
|
||||
expect(view.state.doc.toString()).toBe(expected);
|
||||
});
|
||||
|
||||
it("preserves a selected-text cut and the following line", () => {
|
||||
const view = editor(0, 5);
|
||||
key(view.contentDOM);
|
||||
expect(clipboard(view.contentDOM)).toBe("first");
|
||||
clipboard(view.contentDOM);
|
||||
expect(view.state.doc.toString()).toBe("\nsecond\nthird");
|
||||
});
|
||||
|
||||
it("cuts multiple caret lines once", () => {
|
||||
const view = editor();
|
||||
view.dispatch({ effects: StateEffect.appendConfig.of(EditorState.allowMultipleSelections.of(true)) });
|
||||
view.dispatch({ selection: EditorSelection.create([EditorSelection.cursor(5), EditorSelection.cursor(12)]) });
|
||||
key(view.contentDOM);
|
||||
expect(clipboard(view.contentDOM)).toBe("first\nsecond");
|
||||
clipboard(view.contentDOM);
|
||||
expect(view.state.doc.toString()).toBe("third");
|
||||
});
|
||||
|
||||
it("keeps a read-only document unchanged", () => {
|
||||
const view = editor();
|
||||
view.dispatch({ effects: StateEffect.appendConfig.of(EditorState.readOnly.of(true)) });
|
||||
key(view.contentDOM);
|
||||
expect(clipboard(view.contentDOM)).toBe("first");
|
||||
clipboard(view.contentDOM);
|
||||
expect(view.state.doc.toString()).toBe("first\nsecond\nthird");
|
||||
});
|
||||
|
||||
it("suppresses duplicate copies without changing the document", () => {
|
||||
const view = editor();
|
||||
key(view.contentDOM, "keydown", { key: "c", ctrlKey: true });
|
||||
expect(clipboard(view.contentDOM, "copy")).toBe("first");
|
||||
expect(clipboard(view.contentDOM, "copy")).toBeUndefined();
|
||||
expect(view.state.doc.toString()).toBe("first\nsecond\nthird");
|
||||
});
|
||||
|
||||
it.each(["cut", "copy"])("cancels the keypress fallback after the first %s", (type) => {
|
||||
const view = editor();
|
||||
const down = key(view.contentDOM);
|
||||
expect(down.defaultPrevented).toBe(false);
|
||||
clipboard(view.contentDOM, type);
|
||||
expect(down.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it("allows successive key presses and auto-repeat", () => {
|
||||
const view = editor();
|
||||
for (const repeat of [false, true]) {
|
||||
key(view.contentDOM, "keydown", { key: "x", ctrlKey: true, repeat });
|
||||
expect(clipboard(view.contentDOM)).toBe(repeat ? "second" : "first");
|
||||
clipboard(view.contentDOM);
|
||||
}
|
||||
expect(view.state.doc.toString()).toBe("third");
|
||||
});
|
||||
|
||||
it.each(["keyup", "pointerdown", "blur"])("allows menu cuts after %s", (type) => {
|
||||
const view = editor();
|
||||
key(view.contentDOM);
|
||||
clipboard(view.contentDOM);
|
||||
if (type === "blur") window.dispatchEvent(new Event(type));
|
||||
else view.contentDOM.dispatchEvent(new Event(type, { bubbles: true }));
|
||||
expect(clipboard(view.contentDOM)).toBe("second");
|
||||
});
|
||||
|
||||
it("does not suppress repeated menu cuts without a keydown", () => {
|
||||
const view = editor();
|
||||
expect(clipboard(view.contentDOM)).toBe("first");
|
||||
expect(clipboard(view.contentDOM)).toBe("second");
|
||||
});
|
||||
|
||||
it("allows multiple explicit cuts from an action that handled its key", () => {
|
||||
const view = editor();
|
||||
view.contentDOM.addEventListener("keydown", (event) => event.preventDefault());
|
||||
key(view.contentDOM);
|
||||
expect(clipboard(view.contentDOM)).toBe("first");
|
||||
expect(clipboard(view.contentDOM)).toBe("second");
|
||||
});
|
||||
|
||||
it("does not carry a key press into another editor", () => {
|
||||
const first = editor();
|
||||
const second = editor();
|
||||
key(first.contentDOM);
|
||||
clipboard(first.contentDOM);
|
||||
expect(clipboard(second.contentDOM)).toBe("first");
|
||||
expect(clipboard(second.contentDOM)).toBe("second");
|
||||
});
|
||||
|
||||
it("does not intercept plain inputs or unrelated clipboard events", () => {
|
||||
const input = document.body.appendChild(document.createElement("input"));
|
||||
let cuts = 0;
|
||||
input.addEventListener("cut", () => cuts++);
|
||||
key(input);
|
||||
clipboard(input);
|
||||
clipboard(input);
|
||||
expect(cuts).toBe(2);
|
||||
const view = editor();
|
||||
let pastes = 0;
|
||||
view.contentDOM.addEventListener("paste", () => pastes++);
|
||||
key(view.contentDOM);
|
||||
for (let i = 0; i < 2; i++) view.contentDOM.dispatchEvent(new Event("paste", { bubbles: true }));
|
||||
expect(pastes).toBe(2);
|
||||
});
|
||||
|
||||
it("removes the guard on disposal", () => {
|
||||
const view = editor();
|
||||
key(view.contentDOM);
|
||||
clipboard(view.contentDOM);
|
||||
dispose();
|
||||
expect(clipboard(view.contentDOM)).toBe("second");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/** WebKitGTK 2.54 can dispatch copy/cut twice for one keydown with a collapsed selection. */
|
||||
export function installWebKitClipboardGuard(): () => void {
|
||||
let pending: { editor: Element; keydown: KeyboardEvent; seen: Set<string> } | null = null;
|
||||
const reset = () => {
|
||||
pending = null;
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const editor = event.target instanceof Element ? event.target.closest(".cm-content") : null;
|
||||
pending = editor ? { editor, keydown: event, seen: new Set() } : null;
|
||||
};
|
||||
const onClipboard = (event: Event) => {
|
||||
if (!pending || !(event.target instanceof Element) || event.target.closest(".cm-content") !== pending.editor)
|
||||
return;
|
||||
// Actions that already handled the key may intentionally run several clipboard commands (macros).
|
||||
if (pending.seen.size === 0 && pending.keydown.defaultPrevented) return;
|
||||
if (pending.seen.has(event.type)) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
} else {
|
||||
pending.seen.add(event.type);
|
||||
// The native command can report failure after CodeMirror handles it. Stop its keypress fallback too.
|
||||
pending.keydown.preventDefault();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown, true);
|
||||
window.addEventListener("keyup", reset, true);
|
||||
window.addEventListener("pointerdown", reset, true);
|
||||
window.addEventListener("blur", reset, true);
|
||||
window.addEventListener("copy", onClipboard, true);
|
||||
window.addEventListener("cut", onClipboard, true);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown, true);
|
||||
window.removeEventListener("keyup", reset, true);
|
||||
window.removeEventListener("pointerdown", reset, true);
|
||||
window.removeEventListener("blur", reset, true);
|
||||
window.removeEventListener("copy", onClipboard, true);
|
||||
window.removeEventListener("cut", onClipboard, true);
|
||||
};
|
||||
}
|
||||
@@ -117,6 +117,8 @@ export interface EditorKindContribution {
|
||||
component: ComponentType<{ input: EditorInput }>;
|
||||
title: (input: EditorInput) => string;
|
||||
icon?: (input: EditorInput) => string;
|
||||
/** Small marker rendered in the tab's trailing slot (a status dot for live state). */
|
||||
trailing?: (input: EditorInput) => ReactNode;
|
||||
/**
|
||||
* Its tabs are saved with the editor session and reopened with the project (their
|
||||
* `data` must be plain JSON). The editor closes tabs whose target is gone itself.
|
||||
|
||||
Reference in new issue
Block a user