mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 17:20:15 -10:00
Compare commits
4
Commits
perf/tab-switch
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c90d3a7a8e | ||
|
|
115d789091 | ||
|
|
fb92a82101 | ||
|
|
7829bee55c |
No files matched your search
+102
-35
@@ -279,11 +279,8 @@ pub fn jdtls_runtime(jdks: &[Jdk], min_java: u32) -> Option<&Jdk> {
|
||||
/// for 21, but the Eclipse platform it ships moves on (1.63 needs 25), and on
|
||||
/// an older JDK the server dies resolving its bundles.
|
||||
pub fn jdtls_min_java(cfg: &LanguageServerConfig) -> u32 {
|
||||
let search = cfg.env.get("PATH").map(std::ffi::OsString::from).or_else(|| std::env::var_os("PATH"));
|
||||
let required = search
|
||||
.and_then(|s| ide_run::which::find_executable(&cfg.command, &s))
|
||||
.and_then(|exe| dunce::canonicalize(exe).ok())
|
||||
.and_then(|exe| Some(exe.parent()?.parent()?.to_path_buf()))
|
||||
let required = crate::jdtls::script_for(cfg)
|
||||
.and_then(|script| Some(script.parent()?.parent()?.to_path_buf()))
|
||||
.and_then(|home| bundles_min_java(&home));
|
||||
required.map_or(JDTLS_MIN_JAVA, |v| v.max(JDTLS_MIN_JAVA))
|
||||
}
|
||||
@@ -506,21 +503,31 @@ pub fn configure_jdtls(
|
||||
}
|
||||
}
|
||||
let jdks = all.as_slice();
|
||||
if cfg.launch_error.is_none() && !cfg.env.contains_key("JAVA_HOME") {
|
||||
let runtime = match server_jdk {
|
||||
Some(jdk) => server_jdk_error(jdk, min_java).map_or(Ok(jdk), Err),
|
||||
None => jdtls_runtime(jdks, min_java).ok_or_else(|| missing_runtime_message(jdks, min_java)),
|
||||
};
|
||||
match runtime {
|
||||
Ok(jdk) => {
|
||||
cfg.env.insert("JAVA_HOME".to_owned(), jdk.home.to_string_lossy().into_owned());
|
||||
if cfg.launch_error.is_none() {
|
||||
// A `JAVA_HOME` the config inherits (e.g. the project's JDK 8 for
|
||||
// compilation) must not run jdtls: a chosen `server_jdk`, else any
|
||||
// usable JDK, replaces it when it probes as too old or incomplete for
|
||||
// the server (issue #2227). A `JAVA_HOME` that cannot be probed is the
|
||||
// user's call and is left untouched.
|
||||
let env_jdk = cfg.env.get("JAVA_HOME").and_then(|home| jdk_at(Path::new(home)));
|
||||
if cfg.env.contains_key("JAVA_HOME") && env_jdk.is_none() {
|
||||
// Unresolvable explicit value: keep it, jdtls reports what it finds.
|
||||
} else {
|
||||
let existing = env_jdk.filter(|jdk| server_jdk_error(jdk, min_java).is_none());
|
||||
let runtime = if let Some(jdk) = server_jdk {
|
||||
server_jdk_error(jdk, min_java).map_or(Ok(jdk), Err)
|
||||
} else if let Some(jdk) = existing.as_ref() {
|
||||
Ok(jdk)
|
||||
} else {
|
||||
jdtls_runtime(jdks, min_java).ok_or_else(|| missing_runtime_message(jdks, min_java))
|
||||
};
|
||||
match runtime {
|
||||
Ok(jdk) => {
|
||||
cfg.env.insert("JAVA_HOME".to_owned(), jdk.home.to_string_lossy().into_owned());
|
||||
}
|
||||
Err(e) => cfg.launch_error = Some(e),
|
||||
}
|
||||
Err(e) => cfg.launch_error = Some(e),
|
||||
}
|
||||
} else if cfg.launch_error.is_none()
|
||||
&& let Some(jdk) = cfg.env.get("JAVA_HOME").and_then(|home| jdk_at(Path::new(home)))
|
||||
{
|
||||
cfg.launch_error = server_jdk_error(&jdk, min_java);
|
||||
}
|
||||
let mut runtimes = runtimes(&project_jdks);
|
||||
if let Some(project) = project_jdk.or_else(|| pick_jdk(&project_jdks, project_java_version(server_root))) {
|
||||
@@ -715,6 +722,10 @@ mod tests {
|
||||
.unwrap();
|
||||
write_test_java(&home);
|
||||
std::fs::write(home.join("bin").join(crate::exe("javac")), "").unwrap();
|
||||
// `jdk_at` reports the canonical home; `configure_jdtls` writes it to
|
||||
// JAVA_HOME, so tests compare against the same real path (macOS
|
||||
// /var→/private/var, Windows 8.3 short names).
|
||||
let home = dunce::canonicalize(home).unwrap();
|
||||
Jdk { major, home }
|
||||
}
|
||||
|
||||
@@ -833,7 +844,7 @@ mod tests {
|
||||
"name": "JavaSE-21", "path": pick_jdk(&installed, None).unwrap().home, "default": true
|
||||
}])
|
||||
);
|
||||
assert_eq!(cfg.env["JAVA_HOME"], fixtures.path().join("custom-server-runtime").to_string_lossy());
|
||||
assert_eq!(cfg.env["JAVA_HOME"], server.home.to_string_lossy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1007,15 +1018,11 @@ mod tests {
|
||||
);
|
||||
let opts = serde_json::to_value(cfg.initialization_options.clone().unwrap()).unwrap();
|
||||
let rt = &opts["settings"]["java"]["configuration"]["runtimes"];
|
||||
assert_eq!(rt[0], json!({ "name": "JavaSE-1.8", "path": fixtures.path().join("jdk8") }));
|
||||
assert_eq!(rt[1], json!({ "name": "JavaSE-21", "path": fixtures.path().join("jdk21a") }));
|
||||
assert_eq!(rt[2], json!({ "name": "JavaSE-25", "path": fixtures.path().join("jdk25"), "default": true }));
|
||||
assert_eq!(rt[0], json!({ "name": "JavaSE-1.8", "path": jdks[3].home }));
|
||||
assert_eq!(rt[1], json!({ "name": "JavaSE-21", "path": jdks[1].home }));
|
||||
assert_eq!(rt[2], json!({ "name": "JavaSE-25", "path": jdks[0].home, "default": true }));
|
||||
assert_eq!(opts["bundles"], json!(["/tools/java-debug/bin/com.microsoft.java.debug.plugin.jar"]));
|
||||
assert_eq!(
|
||||
cfg.env["JAVA_HOME"],
|
||||
fixtures.path().join("jdk25").to_string_lossy(),
|
||||
"first JDK new enough to run jdtls"
|
||||
);
|
||||
assert_eq!(cfg.env["JAVA_HOME"], jdks[0].home.to_string_lossy(), "first JDK new enough to run jdtls");
|
||||
assert_eq!(cfg.launch_error, None);
|
||||
|
||||
// Deterministic (reconfigure compares configs) and idempotent.
|
||||
@@ -1049,8 +1056,8 @@ mod tests {
|
||||
);
|
||||
let opts = serde_json::to_value(cfg.initialization_options.clone().unwrap()).unwrap();
|
||||
let rt = &opts["settings"]["java"]["configuration"]["runtimes"];
|
||||
assert_eq!(rt[1], json!({ "name": "JavaSE-21", "path": fixtures.path().join("jdk21b"), "default": true }));
|
||||
assert_eq!(cfg.env["JAVA_HOME"], fixtures.path().join("jdk21b").to_string_lossy());
|
||||
assert_eq!(rt[1], json!({ "name": "JavaSE-21", "path": project.home, "default": true }));
|
||||
assert_eq!(cfg.env["JAVA_HOME"], project.home.to_string_lossy());
|
||||
|
||||
let mut other: LanguageServerConfig = toml::from_str("name = \"pyright\"\ncommand = \"x\"").unwrap();
|
||||
configure_jdtls(
|
||||
@@ -1222,11 +1229,71 @@ mod tests {
|
||||
assert!(err.contains("Download JDK 25"), "{err}");
|
||||
|
||||
// Once a JDK 25 has been downloaded, Auto runs jdtls on it.
|
||||
let downloaded = [installed.as_slice(), &[test_jdk(fixtures.path(), 25, "/tools/jdks/temurin-25")]].concat();
|
||||
let temurin = test_jdk(fixtures.path(), 25, "/tools/jdks/temurin-25");
|
||||
let downloaded = [installed.as_slice(), std::slice::from_ref(&temurin)].concat();
|
||||
let mut cfg = jdtls();
|
||||
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &downloaded, None, None, 25, &[], None);
|
||||
assert_eq!(cfg.launch_error, None);
|
||||
assert_eq!(cfg.env["JAVA_HOME"], fixtures.path().join("tools/jdks/temurin-25").to_string_lossy());
|
||||
assert_eq!(cfg.env["JAVA_HOME"], temurin.home.to_string_lossy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_2227_a_project_java_home_too_old_for_the_server_is_replaced() {
|
||||
// A project's JAVA_HOME (JDK 8 for compilation) must not run jdtls when
|
||||
// the chosen server_jdk — or any usable JDK — is new enough.
|
||||
let fixtures = tempfile::tempdir().unwrap();
|
||||
let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() };
|
||||
let project = test_jdk(fixtures.path(), 8, "/jdk8");
|
||||
let server = test_jdk(fixtures.path(), 25, "/jdk25");
|
||||
|
||||
// The chosen server JDK overrides the project's JAVA_HOME.
|
||||
let mut cfg = jdtls();
|
||||
cfg.env.insert("JAVA_HOME".into(), project.home.to_string_lossy().into_owned());
|
||||
configure_jdtls(
|
||||
&mut cfg,
|
||||
Path::new("/p"),
|
||||
Path::new("/data"),
|
||||
std::slice::from_ref(&project),
|
||||
None,
|
||||
Some(&server),
|
||||
25,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
assert_eq!(cfg.launch_error, None);
|
||||
assert_eq!(cfg.env["JAVA_HOME"], server.home.to_string_lossy());
|
||||
|
||||
// Auto picks any usable installed JDK over the stale JAVA_HOME.
|
||||
let mut cfg = jdtls();
|
||||
cfg.env.insert("JAVA_HOME".into(), project.home.to_string_lossy().into_owned());
|
||||
let installed = [project.clone(), server.clone()];
|
||||
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &installed, None, None, 25, &[], None);
|
||||
assert_eq!(cfg.launch_error, None);
|
||||
assert_eq!(cfg.env["JAVA_HOME"], server.home.to_string_lossy());
|
||||
|
||||
// A JAVA_HOME new enough for the server stays.
|
||||
let mut cfg = jdtls();
|
||||
cfg.env.insert("JAVA_HOME".into(), server.home.to_string_lossy().into_owned());
|
||||
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &installed, None, None, 25, &[], None);
|
||||
assert_eq!(cfg.launch_error, None);
|
||||
assert_eq!(cfg.env["JAVA_HOME"], server.home.to_string_lossy());
|
||||
|
||||
// Nothing usable to replace it with: the too-old JAVA_HOME loses to the
|
||||
// same missing-runtime error a missing JAVA_HOME gives.
|
||||
let mut cfg = jdtls();
|
||||
cfg.env.insert("JAVA_HOME".into(), project.home.to_string_lossy().into_owned());
|
||||
configure_jdtls(
|
||||
&mut cfg,
|
||||
Path::new("/p"),
|
||||
Path::new("/data"),
|
||||
std::slice::from_ref(&project),
|
||||
None,
|
||||
None,
|
||||
25,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
assert!(cfg.launch_error.unwrap().contains("JDK 25 or newer"), "auto must say what to install");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1288,23 +1355,23 @@ mod tests {
|
||||
let cfg = run(Some(&jdk(21, "/jdk21")), JDTLS_MIN_JAVA);
|
||||
assert_eq!(
|
||||
(cfg.env["JAVA_HOME"].clone(), cfg.launch_error),
|
||||
(fixtures.path().join("jdk21").to_string_lossy().into_owned(), None)
|
||||
(jdk(21, "/jdk21").home.to_string_lossy().into_owned(), None)
|
||||
);
|
||||
let opts = serde_json::to_value(cfg.initialization_options.unwrap()).unwrap();
|
||||
let rt = &opts["settings"]["java"]["configuration"]["runtimes"];
|
||||
assert_eq!(
|
||||
rt[0],
|
||||
json!({ "name": "JavaSE-17", "path": fixtures.path().join("jdk17"), "default": true }),
|
||||
json!({ "name": "JavaSE-17", "path": project.home, "default": true }),
|
||||
"still compiles on 17"
|
||||
);
|
||||
|
||||
// Not set: the first installed JDK new enough, as before.
|
||||
assert_eq!(run(None, JDTLS_MIN_JAVA).env["JAVA_HOME"], fixtures.path().join("jdk25").to_string_lossy());
|
||||
assert_eq!(run(None, JDTLS_MIN_JAVA).env["JAVA_HOME"], jdk(25, "/jdk25").home.to_string_lossy());
|
||||
|
||||
// A chosen JDK outside the scanned locations.
|
||||
let own = jdk(25, "/opt/own-jdk");
|
||||
let cfg = run(Some(&own), 25);
|
||||
assert_eq!(cfg.env["JAVA_HOME"], fixtures.path().join("opt/own-jdk").to_string_lossy());
|
||||
assert_eq!(cfg.env["JAVA_HOME"], own.home.to_string_lossy());
|
||||
|
||||
// Too old for this jdtls: the user's choice is not overridden, it is explained.
|
||||
let old = jdk(21, "/jdk21");
|
||||
|
||||
+24
-15
@@ -228,6 +228,29 @@ pub fn java_args(install: &JdtlsInstall, java_major: u32, script_args: &[String]
|
||||
args
|
||||
}
|
||||
|
||||
/// The release script a `jdtls` command on `PATH` resolves to: itself when it
|
||||
/// already is one, or the script its Scoop shim launches. `None` for a command
|
||||
/// that is neither a release script nor a jdtls Scoop shim.
|
||||
///
|
||||
/// Shared with [`crate::java::jdtls_min_java`], which needs the same install
|
||||
/// root to read the bundles' required Java version.
|
||||
pub(crate) fn script_for(cfg: &LanguageServerConfig) -> Option<PathBuf> {
|
||||
if cfg.name != "jdtls" {
|
||||
return None;
|
||||
}
|
||||
let search = cfg.env.get("PATH").map(std::ffi::OsString::from).or_else(|| std::env::var_os("PATH"));
|
||||
let script = search.and_then(|s| ide_run::which::find_executable(&cfg.command, &s))?;
|
||||
let resolved = dunce::canonicalize(script).ok()?;
|
||||
let name = resolved.file_name().map(|n| n.to_string_lossy().to_ascii_lowercase());
|
||||
match name.as_deref() {
|
||||
Some(n) if SCRIPTS.contains(&n) => Some(resolved),
|
||||
// A scoop shim (`shims\jdtls.exe` from PATHEXT) hides the release
|
||||
// layout behind itself (issue #794); follow its `.shim` to the script.
|
||||
Some("jdtls.exe") if cfg!(windows) => shim_target(&resolved),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Scoop shims either point at the release script or run Python with the
|
||||
/// script in `args`. Paths may be quoted and contain spaces and backslashes.
|
||||
fn shim_target(shim_exe: &Path) -> Option<PathBuf> {
|
||||
@@ -263,21 +286,7 @@ pub fn launch_directly(cfg: &mut LanguageServerConfig) {
|
||||
if cfg.name != "jdtls" || cfg.launch_error.is_some() {
|
||||
return;
|
||||
}
|
||||
let search = cfg.env.get("PATH").map(std::ffi::OsString::from).or_else(|| std::env::var_os("PATH"));
|
||||
let Some(script) = search.and_then(|s| ide_run::which::find_executable(&cfg.command, &s)) else { return };
|
||||
let Ok(resolved) = dunce::canonicalize(&script) else { return };
|
||||
let name = resolved.file_name().map(|n| n.to_string_lossy().to_ascii_lowercase());
|
||||
let script = match name.as_deref() {
|
||||
Some(n) if SCRIPTS.contains(&n) => resolved,
|
||||
// A scoop shim (`shims\jdtls.exe` from PATHEXT) hides the release layout
|
||||
// behind itself, so the launch ran the shim's own command line and the
|
||||
// configured JDK never applied (issue #794).
|
||||
Some("jdtls.exe") if cfg!(windows) => match shim_target(&resolved) {
|
||||
Some(target) => target,
|
||||
None => return,
|
||||
},
|
||||
_ => return,
|
||||
};
|
||||
let Some(script) = script_for(cfg) else { return };
|
||||
let Some(install) =
|
||||
jdtls_home(&script).and_then(|h| find_install(&h, std::env::consts::OS, std::env::consts::ARCH))
|
||||
else {
|
||||
|
||||
@@ -118,6 +118,11 @@
|
||||
}
|
||||
.conversationRow {
|
||||
min-width: 0;
|
||||
contain-intrinsic-size: auto 120px;
|
||||
}
|
||||
/* Laid out once, an off-screen row skips rendering at its remembered size (`useOffscreenRows`). */
|
||||
.conversationRow[data-measured] {
|
||||
content-visibility: auto;
|
||||
}
|
||||
/* Keep turns distinct and the work within a turn close to its author. */
|
||||
.stream > * + * {
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
/** An agent task as an editor tab: the conversation, a composer and the task's changed files. */
|
||||
import {
|
||||
createContext,
|
||||
Fragment,
|
||||
memo,
|
||||
startTransition,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import clsx from "clsx";
|
||||
@@ -189,16 +192,18 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) {
|
||||
docked={docked}
|
||||
/>
|
||||
{view && <SubagentBar task={task} path={path} cards={cards} onNavigate={setPath} />}
|
||||
<Stream
|
||||
key={view ?? ""}
|
||||
task={task}
|
||||
entries={viewEntries ?? null}
|
||||
subagent={view ? (cards.at(-1) ?? null) : null}
|
||||
onOpenSubagent={openSubagent}
|
||||
finding={finding && shown}
|
||||
findFocus={findFocus}
|
||||
onCloseFind={() => setFinding(false)}
|
||||
/>
|
||||
<FoldedContent.Provider value={finding && shown}>
|
||||
<Stream
|
||||
key={view ?? ""}
|
||||
task={task}
|
||||
entries={viewEntries ?? null}
|
||||
subagent={view ? (cards.at(-1) ?? null) : null}
|
||||
onOpenSubagent={openSubagent}
|
||||
finding={finding && shown}
|
||||
findFocus={findFocus}
|
||||
onCloseFind={() => setFinding(false)}
|
||||
/>
|
||||
</FoldedContent.Provider>
|
||||
{view ? (
|
||||
<div className={styles.readOnly}>
|
||||
<T>Subagents take their instructions from the agent that started them.</T>
|
||||
@@ -457,6 +462,65 @@ function SubagentBar({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets the rows of a long conversation skip rendering while off screen (`data-measured`, see
|
||||
* the CSS), so showing the tab again or restyling it costs what is on screen, not the whole
|
||||
* history. A row skips only once it was laid out: it keeps its real height, and scrolling up
|
||||
* doesn't jump (WebKit has no scroll anchoring). A new width lays them out again once it
|
||||
* settles; Find in Conversation (`enabled` false) needs every row laid out.
|
||||
*/
|
||||
function useOffscreenRows(ref: RefObject<HTMLElement | null>, visible: boolean, enabled: boolean) {
|
||||
// The width the marked rows were laid out at.
|
||||
const width = useRef(0);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const unmark = () => {
|
||||
for (const row of el.querySelectorAll(`:scope > .${styles.conversationRow}[data-measured]`)) {
|
||||
row.removeAttribute("data-measured");
|
||||
}
|
||||
};
|
||||
if (!enabled) return unmark();
|
||||
if (!visible) return;
|
||||
let frame = 0;
|
||||
let timer: number | undefined;
|
||||
const mark = () => {
|
||||
cancelAnimationFrame(frame);
|
||||
// The next frame lays the new rows out (and remembers their size); mark them after it.
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = requestAnimationFrame(() => {
|
||||
for (const row of el.querySelectorAll(`:scope > .${styles.conversationRow}:not([data-measured])`)) {
|
||||
row.setAttribute("data-measured", "");
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
const remeasure = () => {
|
||||
if (el.clientWidth === width.current) return;
|
||||
width.current = el.clientWidth;
|
||||
unmark();
|
||||
};
|
||||
remeasure();
|
||||
mark();
|
||||
const rows = new MutationObserver(mark);
|
||||
rows.observe(el, { childList: true });
|
||||
const resize = new ResizeObserver(() => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => {
|
||||
remeasure();
|
||||
mark();
|
||||
}, 200);
|
||||
});
|
||||
resize.observe(el);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
window.clearTimeout(timer);
|
||||
rows.disconnect();
|
||||
resize.disconnect();
|
||||
};
|
||||
}, [ref, visible, enabled]);
|
||||
}
|
||||
|
||||
interface StreamProps {
|
||||
task: AgentTask;
|
||||
entries: AgentEntry[] | null;
|
||||
@@ -540,6 +604,7 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
|
||||
}, [start]);
|
||||
// Hidden behind another tab, it has no layout to scroll; shown again, it follows the end again.
|
||||
const visible = useEditorShown();
|
||||
useOffscreenRows(ref, visible, !finding);
|
||||
/** A diagram that drew after the last streamed chunk grows the conversation: keep following. */
|
||||
const onDiagramDrawn = useCallback(() => {
|
||||
const el = ref.current;
|
||||
@@ -1015,19 +1080,23 @@ const Entry = memo(function Entry({
|
||||
case "thought":
|
||||
return (
|
||||
<div className={styles.process}>
|
||||
<details className={styles.thought}>
|
||||
<summary>
|
||||
{working ? (
|
||||
<Spinner size={12} />
|
||||
) : (
|
||||
<Icon name="chevron-right" size={12} className={styles.thoughtChevron} />
|
||||
)}
|
||||
<T>{working ? "Thinking" : "Reasoning"}</T>
|
||||
</summary>
|
||||
<Fold
|
||||
className={styles.thought}
|
||||
summary={
|
||||
<summary>
|
||||
{working ? (
|
||||
<Spinner size={12} />
|
||||
) : (
|
||||
<Icon name="chevron-right" size={12} className={styles.thoughtChevron} />
|
||||
)}
|
||||
<T>{working ? "Thinking" : "Reasoning"}</T>
|
||||
</summary>
|
||||
}
|
||||
>
|
||||
<div className={styles.thoughtBody}>
|
||||
<Markdown text={entry.text} cwd={task.cwd} onDiagramDrawn={onDiagramDrawn} />
|
||||
</div>
|
||||
</details>
|
||||
</Fold>
|
||||
</div>
|
||||
);
|
||||
case "tool":
|
||||
@@ -1383,14 +1452,18 @@ const ProcessRun = memo(function ProcessRun({
|
||||
tools.length > 0 ? `${tools.length} ${tx(tools.length === 1 ? "tool call" : "tool calls")}` : null,
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<details className={clsx(styles.processRun, styles.process)}>
|
||||
<summary className={styles.processSummary}>
|
||||
<Icon name="chevron-right" size={12} className={styles.processChevron} />
|
||||
{working && <Spinner size={12} />}
|
||||
<span>{parts.join(" · ")}</span>
|
||||
{added > 0 && <span className={styles.added}>+{added}</span>}
|
||||
{removed > 0 && <span className={styles.removed}>−{removed}</span>}
|
||||
</summary>
|
||||
<Fold
|
||||
className={clsx(styles.processRun, styles.process)}
|
||||
summary={
|
||||
<summary className={styles.processSummary}>
|
||||
<Icon name="chevron-right" size={12} className={styles.processChevron} />
|
||||
{working && <Spinner size={12} />}
|
||||
<span>{parts.join(" · ")}</span>
|
||||
{added > 0 && <span className={styles.added}>+{added}</span>}
|
||||
{removed > 0 && <span className={styles.removed}>−{removed}</span>}
|
||||
</summary>
|
||||
}
|
||||
>
|
||||
<div className={styles.processBody}>
|
||||
{row.entries.map(({ index, entry }) =>
|
||||
entry.type === "tool" ? (
|
||||
@@ -1400,10 +1473,38 @@ const ProcessRun = memo(function ProcessRun({
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
</Fold>
|
||||
);
|
||||
});
|
||||
|
||||
/** Set while Find in Conversation is open: folded sections render their content, so it is found. */
|
||||
const FoldedContent = createContext(false);
|
||||
|
||||
/**
|
||||
* A collapsed section that renders its content only once opened: most of a long conversation
|
||||
* stays folded, and keeping it out of the DOM keeps showing and restyling the tab cheap.
|
||||
*/
|
||||
function Fold({
|
||||
className,
|
||||
open,
|
||||
summary,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
open?: boolean;
|
||||
summary: ReactElement;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const always = useContext(FoldedContent);
|
||||
const [expanded, setExpanded] = useState(!!open);
|
||||
return (
|
||||
<details className={className} open={open || undefined} onToggle={(e) => setExpanded(e.currentTarget.open)}>
|
||||
{summary}
|
||||
{(expanded || open || always) && children}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
const ToolEntry = memo(function ToolEntry({
|
||||
task,
|
||||
entry,
|
||||
@@ -1443,13 +1544,18 @@ const ToolEntry = memo(function ToolEntry({
|
||||
);
|
||||
return (
|
||||
<div className={styles.process}>
|
||||
<details className={styles.toolDetails} open={liveTool(entry) || undefined}>
|
||||
<summary className={styles.tool}>
|
||||
<Icon name="chevron-right" size={12} className={styles.toolChevron} />
|
||||
{head}
|
||||
</summary>
|
||||
<Fold
|
||||
className={styles.toolDetails}
|
||||
open={liveTool(entry)}
|
||||
summary={
|
||||
<summary className={styles.tool}>
|
||||
<Icon name="chevron-right" size={12} className={styles.toolChevron} />
|
||||
{head}
|
||||
</summary>
|
||||
}
|
||||
>
|
||||
<pre className={styles.output}>{entry.output}</pre>
|
||||
</details>
|
||||
</Fold>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1532,22 +1638,30 @@ function SubagentCard({
|
||||
</div>
|
||||
)}
|
||||
{entry.prompt && (
|
||||
<details className={styles.subagentSection}>
|
||||
<summary>
|
||||
<T>Task</T>
|
||||
</summary>
|
||||
<Fold
|
||||
className={styles.subagentSection}
|
||||
summary={
|
||||
<summary>
|
||||
<T>Task</T>
|
||||
</summary>
|
||||
}
|
||||
>
|
||||
<div className={styles.thoughtText}>{entry.prompt}</div>
|
||||
</details>
|
||||
</Fold>
|
||||
)}
|
||||
{entry.reply && (
|
||||
<details className={styles.subagentSection}>
|
||||
<summary>
|
||||
<T>Result</T>
|
||||
</summary>
|
||||
<Fold
|
||||
className={styles.subagentSection}
|
||||
summary={
|
||||
<summary>
|
||||
<T>Result</T>
|
||||
</summary>
|
||||
}
|
||||
>
|
||||
<div className={styles.subagentReply}>
|
||||
<AgentReply text={entry.reply} cwd={task.cwd} task={task} onDiagramDrawn={onDiagramDrawn} />
|
||||
</div>
|
||||
</details>
|
||||
</Fold>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { entriesKey, SESSION_KIND, sessionKey, type SubagentEntry } from "./mode
|
||||
import { disconnect, useAgents } from "./store";
|
||||
import { taskActions } from "./taskActions";
|
||||
import { replyActions } from "./replyActions";
|
||||
import { focusedSession } from "./sessionFocus";
|
||||
import styles from "./Session.module.css";
|
||||
import tagStyles from "@/ui/InlineTag.module.css";
|
||||
|
||||
@@ -243,8 +244,37 @@ describe("process folding", () => {
|
||||
useAgents.setState({ entries: new Map(backend.entries) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const summary = await screen.findByText("Reasoning · 2 tool calls");
|
||||
const fold = summary.closest("details")!;
|
||||
expect(fold.open).toBe(false);
|
||||
// Folded content is rendered once opened, a level at a time.
|
||||
const open = (details: HTMLDetailsElement) => {
|
||||
details.open = true;
|
||||
fireEvent(details, new Event("toggle"));
|
||||
};
|
||||
expect(screen.queryByText("Reasoning")).toBeNull();
|
||||
open(fold);
|
||||
const thought = (await screen.findAllByText("Reasoning"))[0]!.closest("details")!;
|
||||
expect(screen.queryByText("think one")).toBeNull();
|
||||
open(thought);
|
||||
expect(await screen.findByText("think one")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("folded content", () => {
|
||||
it("is rendered for Find in Conversation, so its text is found", async () => {
|
||||
backend.entries.set("A", [
|
||||
tool("one", { output: "needle in a tool output" }),
|
||||
tool("two"),
|
||||
{ type: "agent", text: "Answer A" },
|
||||
]);
|
||||
useAgents.setState({ entries: new Map(backend.entries) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const summary = await screen.findByText("2 tool calls");
|
||||
expect(screen.queryByText("needle in a tool output")).toBeNull();
|
||||
fireEvent.focus(summary);
|
||||
act(() => focusedSession()!.openFind());
|
||||
expect(await screen.findByText("needle in a tool output")).toBeInTheDocument();
|
||||
expect(summary.closest("details")!.open).toBe(false);
|
||||
expect(screen.getByText("think one")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import { adoptSession, appendConsole, useDebug } from "../state";
|
||||
import { DebugConsole } from "../ui/DebugConsole";
|
||||
|
||||
const nav = vi.hoisted(() => ({
|
||||
openTarget: vi.fn(async () => {}),
|
||||
openUrl: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/run/navigate", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/features/run/navigate")>("@/features/run/navigate");
|
||||
return {
|
||||
...actual,
|
||||
openTarget: nav.openTarget,
|
||||
openUrl: nav.openUrl,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@tanstack/react-virtual", () => ({
|
||||
useVirtualizer: ({ count }: { count: number }) => ({
|
||||
getTotalSize: () => count * 18,
|
||||
getVirtualItems: () =>
|
||||
Array.from({ length: count }, (_, index) => ({ index, start: index * 18, size: 18, key: index })),
|
||||
scrollToIndex: () => {},
|
||||
measureElement: () => {},
|
||||
measure: () => {},
|
||||
}),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
usePlatform.setState({ os: "macos" });
|
||||
useDebug.setState({ sessions: {}, console: {}, active: null });
|
||||
nav.openTarget.mockClear();
|
||||
nav.openUrl.mockClear();
|
||||
window.getSelection()?.removeAllRanges();
|
||||
});
|
||||
|
||||
describe("Debug console links", () => {
|
||||
it("renders clickable file and URL links in output", () => {
|
||||
const session = adoptSession(1);
|
||||
session.launch = {
|
||||
name: "test",
|
||||
adapter: "delve",
|
||||
request: "launch",
|
||||
program: "main.go",
|
||||
args: [],
|
||||
cwd: "/repo/demo-app",
|
||||
env: {},
|
||||
extraJson: null,
|
||||
};
|
||||
|
||||
appendConsole(1, "stdout", " /Users/dev/demo-app/pkg/api/server.go:42:\n");
|
||||
appendConsole(1, "stdout", "2026-01-01T12:00:00.000+0000 DEBUG pkg/store/db.go:15 failed\n");
|
||||
appendConsole(1, "stdout", "Listening on http://localhost:8080/api\n");
|
||||
appendConsole(1, "system", "Process finished with exit code 0\n");
|
||||
|
||||
render(<DebugConsole session={session} />);
|
||||
|
||||
const links = screen.getAllByRole("link");
|
||||
expect(links).toHaveLength(3);
|
||||
|
||||
expect(links[0]?.textContent).toBe("/Users/dev/demo-app/pkg/api/server.go:42");
|
||||
expect(links[1]?.textContent).toBe("pkg/store/db.go:15");
|
||||
expect(links[2]?.textContent).toBe("http://localhost:8080/api");
|
||||
});
|
||||
|
||||
it("navigates to target file on click with resolved cwd", () => {
|
||||
const session = adoptSession(1);
|
||||
session.launch = {
|
||||
name: "test",
|
||||
adapter: "delve",
|
||||
request: "launch",
|
||||
program: "main.go",
|
||||
args: [],
|
||||
cwd: "/repo/demo-app",
|
||||
env: {},
|
||||
extraJson: null,
|
||||
};
|
||||
|
||||
appendConsole(1, "stdout", "2026-01-01T12:00:00.000+0000 DEBUG pkg/store/db.go:15 failed\n");
|
||||
|
||||
render(<DebugConsole session={session} />);
|
||||
|
||||
const link = screen.getByRole("link", { name: "pkg/store/db.go:15" });
|
||||
fireEvent.click(link);
|
||||
|
||||
expect(nav.openTarget).toHaveBeenCalledTimes(1);
|
||||
expect(nav.openTarget).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: "pkg/store/db.go",
|
||||
line: 15,
|
||||
}),
|
||||
"/repo/demo-app",
|
||||
);
|
||||
});
|
||||
|
||||
it("opens URL on click", () => {
|
||||
const session = adoptSession(1);
|
||||
appendConsole(1, "stdout", "See docs at https://example.com/guide\n");
|
||||
|
||||
render(<DebugConsole session={session} />);
|
||||
|
||||
const link = screen.getByRole("link", { name: "https://example.com/guide" });
|
||||
fireEvent.click(link);
|
||||
|
||||
expect(nav.openUrl).toHaveBeenCalledTimes(1);
|
||||
expect(nav.openUrl).toHaveBeenCalledWith("https://example.com/guide");
|
||||
});
|
||||
|
||||
it("does not trigger navigation when text is selected", () => {
|
||||
const session = adoptSession(1);
|
||||
appendConsole(1, "stdout", " /Users/dev/demo-app/pkg/api/server.go:42:\n");
|
||||
|
||||
render(<DebugConsole session={session} />);
|
||||
|
||||
// Mock active selection
|
||||
vi.spyOn(window, "getSelection").mockReturnValue({
|
||||
toString: () => "server.go",
|
||||
} as unknown as Selection);
|
||||
|
||||
const link = screen.getByRole("link", { name: "/Users/dev/demo-app/pkg/api/server.go:42" });
|
||||
fireEvent.click(link);
|
||||
|
||||
expect(nav.openTarget).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,13 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent } from "react";
|
||||
import {
|
||||
memo,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
type MouseEvent,
|
||||
} from "react";
|
||||
import clsx from "clsx";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { copySelectionOnKeyDown, copyText } from "@/ui/selectableText";
|
||||
@@ -6,16 +15,10 @@ import { EmptyState, IconButton, showContextMenu, T, type MenuItem } from "@/ui"
|
||||
import { formatBinding, hasPrimaryModifier, hostBinding, registerKeyClaim } from "@/platform/keymap";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { useWorkflowSettings } from "@/platform/workflowSettings";
|
||||
import { spanStyle, type Span, type SpanStyle } from "@/ui/ansi";
|
||||
import { spanStyle } from "@/ui/ansi";
|
||||
import ansi from "@/ui/ansi.module.css";
|
||||
import { ConsoleFindBar } from "@/ui/ConsoleFindBar";
|
||||
import {
|
||||
DEFAULT_FIND_OPTIONS,
|
||||
findInLines,
|
||||
type ConsoleMatch,
|
||||
type FindOptions,
|
||||
type FindResult,
|
||||
} from "@/ui/consoleFind";
|
||||
import { DEFAULT_FIND_OPTIONS, findInLines, type FindOptions, type FindResult } from "@/ui/consoleFind";
|
||||
import {
|
||||
appendConsole,
|
||||
clearConsole,
|
||||
@@ -24,10 +27,14 @@ import {
|
||||
readsProgramInput,
|
||||
sendProgramInput,
|
||||
useDebug,
|
||||
type ConsoleLine,
|
||||
type DebugSession,
|
||||
} from "../state";
|
||||
import { pushHistory, readHistory, setConsoleHistoryBrowser } from "./expressionHistory";
|
||||
import { useExpressionCompletion } from "./ExpressionCompletion";
|
||||
import { findOutputLinks, type OutputLink } from "@/features/run/links";
|
||||
import { openTarget, openUrl, runDirectory } from "@/features/run/navigate";
|
||||
import { segmentLine, type SegmentMark } from "@/features/run/segments";
|
||||
import styles from "./DebugToolWindow.module.css";
|
||||
|
||||
const EMPTY_FIND: FindResult = { matches: [], overflow: false, invalid: false };
|
||||
@@ -35,69 +42,64 @@ const EMPTY_FIND: FindResult = { matches: [], overflow: false, invalid: false };
|
||||
const MARK_COLOR = "var(--bg-search-match)";
|
||||
const CURRENT_MARK_COLOR = "var(--bg-search-match-current)";
|
||||
|
||||
interface Segment {
|
||||
text: string;
|
||||
style: SpanStyle;
|
||||
/** The piece is inside a find match. */
|
||||
mark: boolean;
|
||||
/** …and inside the current one. */
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
/** Splits a line's styled spans at find-match boundaries (the Run console's `segmentLine`, without links). */
|
||||
function findSegments(spans: readonly Span[], marks: readonly ConsoleMatch[], active?: ConsoleMatch): Segment[] {
|
||||
const out: Segment[] = [];
|
||||
let pos = 0;
|
||||
let mi = 0;
|
||||
for (const span of spans) {
|
||||
const { text, ...style } = span;
|
||||
let offset = 0;
|
||||
while (offset < text.length) {
|
||||
const abs = pos + offset;
|
||||
while (mi < marks.length && marks[mi]!.end <= abs) mi++;
|
||||
const mark = marks[mi];
|
||||
if (mark && mark.start <= abs) {
|
||||
const take = Math.min(mark.end - abs, text.length - offset);
|
||||
out.push({ text: text.slice(offset, offset + take), style, mark: true, current: mark === active });
|
||||
offset += take;
|
||||
} else if (mark && mark.start < pos + text.length) {
|
||||
const take = Math.min(mark.start - abs, text.length - offset);
|
||||
out.push({ text: text.slice(offset, offset + take), style, mark: false, current: false });
|
||||
offset += take;
|
||||
} else {
|
||||
out.push({ text: text.slice(offset), style, mark: false, current: false });
|
||||
offset = text.length;
|
||||
}
|
||||
}
|
||||
pos += text.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A match keeps the span's colours but takes the search background (as IDEA does). */
|
||||
function segmentNode(seg: Segment, key: number) {
|
||||
if (!seg.mark)
|
||||
const DebugLine = memo(
|
||||
function DebugLine({
|
||||
line,
|
||||
marks,
|
||||
cwd,
|
||||
}: {
|
||||
line: ConsoleLine;
|
||||
marks?: readonly SegmentMark[];
|
||||
length: number;
|
||||
cwd: string;
|
||||
}) {
|
||||
const segments = segmentLine(line.spans, line.category === "system" ? [] : findOutputLinks(line.text), marks);
|
||||
const activate = (e: MouseEvent, link: OutputLink) => {
|
||||
e.preventDefault();
|
||||
if (window.getSelection()?.toString()) return;
|
||||
if (link.kind === "url") void openUrl(link.url);
|
||||
else void openTarget(link.target, cwd);
|
||||
};
|
||||
if (segments.length === 0) return <>{"\u200B"}</>;
|
||||
return (
|
||||
<span key={key} style={spanStyle(seg.style)}>
|
||||
{seg.text}
|
||||
</span>
|
||||
<>
|
||||
{segments.map((seg, i) => {
|
||||
const style = seg.mark
|
||||
? {
|
||||
...spanStyle(seg.style),
|
||||
backgroundColor: seg.current ? CURRENT_MARK_COLOR : MARK_COLOR,
|
||||
color: "var(--fg-search-match)",
|
||||
}
|
||||
: spanStyle(seg.style);
|
||||
const marked = seg.mark ? { "data-find-match": "", "data-find-current": seg.current ? "" : undefined } : {};
|
||||
return seg.link ? (
|
||||
<a
|
||||
key={i}
|
||||
href={seg.link.kind === "url" ? seg.link.url : undefined}
|
||||
className={styles.link}
|
||||
style={style}
|
||||
onClick={(e) => activate(e, seg.link!)}
|
||||
role="link"
|
||||
tabIndex={-1}
|
||||
{...marked}
|
||||
>
|
||||
{seg.text}
|
||||
</a>
|
||||
) : seg.mark ? (
|
||||
<mark key={i} className={styles.findMatch} style={style} {...marked}>
|
||||
{seg.text}
|
||||
</mark>
|
||||
) : (
|
||||
<span key={i} style={style}>
|
||||
{seg.text}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<mark
|
||||
key={key}
|
||||
className={styles.findMatch}
|
||||
style={{
|
||||
...spanStyle(seg.style),
|
||||
backgroundColor: seg.current ? CURRENT_MARK_COLOR : MARK_COLOR,
|
||||
color: "var(--fg-search-match)",
|
||||
}}
|
||||
data-find-match=""
|
||||
data-find-current={seg.current ? "" : undefined}
|
||||
>
|
||||
{seg.text}
|
||||
</mark>
|
||||
);
|
||||
}
|
||||
},
|
||||
(a, b) => a.line === b.line && a.length === b.length && a.cwd === b.cwd && a.marks === b.marks,
|
||||
);
|
||||
|
||||
export function DebugServiceConsole({ sessionId }: { sessionId: number }) {
|
||||
const session = useDebug((s) => s.sessions[sessionId]);
|
||||
@@ -160,6 +162,11 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
|
||||
});
|
||||
}, [lines, virtualizer, follow, softWrap]);
|
||||
|
||||
const cwd = useMemo(
|
||||
() => runDirectory(session.launch?.cwd ?? session.runConfig?.cwd),
|
||||
[session.launch?.cwd, session.runConfig?.cwd],
|
||||
);
|
||||
|
||||
// Find (⌘F): search the whole buffer, not just the rendered rows.
|
||||
const found = useMemo(
|
||||
() => (findOpen ? findInLines(lines ?? [], query, findOptions) : EMPTY_FIND),
|
||||
@@ -168,14 +175,15 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
|
||||
const activeIndex = found.matches.length ? Math.min(Math.max(activeMatchIndex, 0), found.matches.length - 1) : -1;
|
||||
const current = activeIndex >= 0 ? found.matches[activeIndex] : undefined;
|
||||
const marksByLine = useMemo(() => {
|
||||
const byLine = new Map<number, ConsoleMatch[]>();
|
||||
found.matches.forEach((match) => {
|
||||
const byLine = new Map<number, SegmentMark[]>();
|
||||
found.matches.forEach((match, i) => {
|
||||
const mark: SegmentMark = { start: match.start, end: match.end, current: i === activeIndex };
|
||||
const marks = byLine.get(match.line);
|
||||
if (marks) marks.push(match);
|
||||
else byLine.set(match.line, [match]);
|
||||
if (marks) marks.push(mark);
|
||||
else byLine.set(match.line, [mark]);
|
||||
});
|
||||
return byLine;
|
||||
}, [found]);
|
||||
}, [found, activeIndex]);
|
||||
|
||||
// The line at the top of the viewport: typing searches from there, as IDEA does.
|
||||
const topLine = () => {
|
||||
@@ -462,7 +470,7 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
|
||||
className={clsx(styles.out, styles[`cat-${l.category}`])}
|
||||
style={{ transform: `translateY(${item.start}px)` }}
|
||||
>
|
||||
{findSegments(l.spans, marksByLine.get(item.index) ?? [], current).map(segmentNode)}
|
||||
<DebugLine line={l} marks={marksByLine.get(item.index)} length={l.text.length} cwd={cwd} />
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -273,6 +273,18 @@
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--link);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: color-mix(in srgb, var(--link) 45%, transparent);
|
||||
text-underline-offset: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.link:hover {
|
||||
color: var(--link-hover);
|
||||
text-decoration-color: currentColor;
|
||||
}
|
||||
|
||||
/* Soft-Wrap off: long lines scroll horizontally. */
|
||||
.noWrap {
|
||||
white-space: pre;
|
||||
|
||||
@@ -66,6 +66,21 @@ describe("findOutputLinks", () => {
|
||||
col: 2,
|
||||
});
|
||||
expect(files("src/App.tsx(14,3): error TS2322")[0]).toMatchObject({ path: "src/App.tsx", line: 14, col: 3 });
|
||||
// Go module cache paths with @version and stack frames
|
||||
expect(files(" /Users/user/go/pkg/mod/github.com/spf13/[email protected]/command.go:1071")[0]).toMatchObject({
|
||||
path: "/Users/user/go/pkg/mod/github.com/spf13/[email protected]/command.go",
|
||||
line: 1071,
|
||||
});
|
||||
// Go stack frames with trailing colon
|
||||
expect(files(" /Users/user/projects/demo/pkg/api/server.go:42:")[0]).toMatchObject({
|
||||
path: "/Users/user/projects/demo/pkg/api/server.go",
|
||||
line: 42,
|
||||
});
|
||||
// Relative paths in loggers (e.g. zap/zerolog/slog)
|
||||
expect(files("2026-01-01T12:00:00.000+0000\tDEBUG\tpkg/store/db.go:15\tfailed to connect")[0]).toMatchObject({
|
||||
path: "pkg/store/db.go",
|
||||
line: 15,
|
||||
});
|
||||
});
|
||||
|
||||
it("maven diagnostics [ERROR] path:[line,col]", () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { stripSlash } from "./model";
|
||||
import { openCommit } from "./commitUi";
|
||||
import { rememberSetting } from "./confirmations";
|
||||
import { confirmDialog, openDialog } from "./dialogs";
|
||||
import { loadLogDetails } from "./logDetails";
|
||||
import { conflictsInstead, inProgress } from "./operations";
|
||||
import { runGitOperation, runOp, saveAll } from "./ops";
|
||||
import {
|
||||
@@ -179,8 +180,11 @@ async function runRebase(
|
||||
export async function editCommitMessage(commit: Commit) {
|
||||
const range = await rangeFrom(commit, "edit commit message");
|
||||
if (!range) return;
|
||||
const message = await promptMessage(`Edit Commit Message (${short(commit.id)})`, commit.message);
|
||||
if (message === null || message.trim() === commit.message.trim()) return;
|
||||
// A paged log only carries the summary line: load the full message so the
|
||||
// dialog edits subject and body, not the subject alone (#2238).
|
||||
const initial = commit.messageTruncated ? (await loadLogDetails(commit)).message : commit.message;
|
||||
const message = await promptMessage(`Edit Commit Message (${short(commit.id)})`, initial);
|
||||
if (message === null || message.trim() === initial.trim()) return;
|
||||
// Written without a rebase: the index and the working tree (local changes) stay as they are.
|
||||
const change = await runOp(tx`Edit commit message of ${short(commit.id)}`, () =>
|
||||
call(commands.vcsReword(range.repo, commit.id, message)),
|
||||
|
||||
Reference in new issue
Block a user