Compare commits

...
4 Commits
Author SHA1 Message Date
arookieofc c90d3a7a8e fix(java): run jdtls on a new-enough JDK behind Scoop shims and project JAVA_HOME (#2230)
* fix(java): run jdtls on a new-enough JDK behind Scoop shims and project JAVA_HOME

Two ways jdtls ended up on a JDK too old for its bundles (#2227):

- jdtls_min_java resolved the command's own path to derive the install
  root, but a Scoop shim (shims\jdtls.exe) hides the release layout, so
  the bundles' real requirement fell back to JDTLS_MIN_JAVA (21) while a
  1.64 install needs JavaSE 25. Extract the resolve/follow-shim flow
  from launch_directly into jdtls::script_for and share it.
- configure_jdtls only wrote JAVA_HOME when the config had none, so a
  project JAVA_HOME (JDK 8 for compilation) won over the chosen
  server_jdk. Probe the configured value: a home too old or incomplete
  for the server is replaced by server_jdk or any usable JDK; an
  unresolvable explicit value stays (the user's call).

* test(java): compare JDK homes against canonical paths

jdk_at reports the canonicalized home (macOS /var -> /private/var,
Windows 8.3 short names), which configure_jdtls now writes to JAVA_HOME.
Have test_jdk canonicalize too, and assert the runtimes/JAVA_HOME values
against the Jdk.home fields instead of raw fixtures.path() joins, which
also differ on Windows when the join arg uses forward slashes.

* test(jdtls): compare JAVA_HOME against the canonical jdk home

test_jdk now canonicalizes home, so the duplicate-versions assertion must
compare against server.home rather than the raw tempdir join, which on
Windows carries 8.3 short names.
2026-11-09 11:08:28 +08:00
l0ng-ai 115d789091 perf(agents): make switching to a long conversation tab cheap (#2252)
Folded conversation sections render their content only when opened (or while Find in Conversation is open), and off-screen rows skip rendering once laid out, so showing a kept-alive agent tab no longer restyles the whole history.
2026-11-09 11:05:53 +08:00
Kamal ElFadlandelfadl fb92a82101 feat(debugger): support clickable file and URL links in debug console (#2245)
Co-authored-by: elfadl <[email protected]>
2026-11-09 10:56:44 +08:00
arookieofc 7829bee55c fix(vcs): edit the full commit message, not only the subject (#2242)
A paged Git log only carries each commit's summary line; the Edit Commit
Message dialog therefore opened with the body missing, and OK rewrote the
commit to the subject alone.

Load the full message through loadLogDetails when the commit's
messageTruncated flag is set, the same path Copy Commit Message already
uses, so the dialog edits subject and body together.

Fixes #2238
2026-11-09 10:48:39 +08:00
10 changed files with 566 additions and 174 deletions

No files matched your search

+102 -35
View File
@@ -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
View File
@@ -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 {
+5
View File
@@ -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 > * + * {
+159 -45
View File
@@ -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>
);
+31 -1
View File
@@ -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();
});
});
+84 -76
View File
@@ -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;
+15
View File
@@ -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]", () => {
+6 -2
View File
@@ -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)),