Compare commits

..
Author SHA1 Message Date
l0ng-ai bc42133a36 fix(debugger): keep the selected tab when a step finishes
Every pause brought Threads & Variables to the front, so stepping from the
Console tab switched away from the output after each Step Over. Only a stop
at a breakpoint, exception or pause request does that now; finishing a step
keeps the tab the user is on.

Refs #2494
2026-11-10 23:22:32 +08:00
15 changed files with 83 additions and 321 deletions

No files matched your search

-1
View File
@@ -18,7 +18,6 @@ semver = "1"
blake3.workspace = true
zip.workspace = true
base64.workspace = true
dunce.workspace = true
ed25519-dalek = "3"
[dev-dependencies]
+2 -18
View File
@@ -441,13 +441,11 @@ pub fn resolve_program(name: &str, path_var: Option<&OsStr>) -> Result<PathBuf,
/// `p` with its parent canonicalized but the file name as found: PATH
/// entries like `/var` → `/private/var` (macOS) or `~` links collapse to
/// their real directory, while a linked program (`cargo` → rustup) keeps
/// its link name so the shim still dispatches on argv[0]. `dunce` keeps the
/// path in its usual form on Windows: a `\\?\`-prefixed pin would never
/// match the path the program was actually started with.
/// its link name so the shim still dispatches on argv[0].
fn canonical_dir(p: &Path) -> PathBuf {
let Some(dir) = p.parent() else { return p.to_path_buf() };
let Some(name) = p.file_name() else { return p.to_path_buf() };
dunce::canonicalize(dir).map(|d| d.join(name)).unwrap_or_else(|_| p.to_path_buf())
std::fs::canonicalize(dir).map(|d| d.join(name)).unwrap_or_else(|_| p.to_path_buf())
}
fn is_executable(p: &Path) -> bool {
@@ -500,20 +498,6 @@ mod tests {
list.iter().map(|s| s.parse().unwrap()).collect()
}
#[test]
#[cfg(windows)]
fn pinned_program_path_has_no_verbatim_prefix() {
// std::fs::canonicalize returns \\?\-prefixed paths on Windows; a pin
// stored in that form would never match the plain path the program is
// started with. dunce keeps the usual form.
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("cargo.exe"), b"").unwrap();
let pinned = resolve_program("cargo.exe", Some(dir.path().as_os_str())).unwrap();
let shown = pinned.to_string_lossy();
assert!(!shown.starts_with(r"\\?\"), "verbatim prefix leaked into the pin: {shown}");
assert!(pinned.is_absolute());
}
fn req<'a>(version: &'a str, hash: &'a str, required: &[&str], optional: &[&str]) -> Request<'a> {
Request {
subject: Subject::Installed("p".into()),
-10
View File
@@ -52,8 +52,6 @@ fn search_dirs(file: Option<&Path>, root: Option<&Path>) -> Vec<PathBuf> {
}
fn parse_env(text: &str) -> IdeResult<EnvMap> {
// Editors on Windows (Notepad) save UTF-8 with a byte order mark, which serde_json rejects.
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
let value: serde_json::Value =
serde_json::from_str(text).map_err(|e| IdeError::invalid(format!("invalid environment file: {e}")))?;
let mut out = EnvMap::new();
@@ -264,12 +262,4 @@ mod tests {
let envs = Environments::load(Some(&other.path().join("s.http")), Some(root)).unwrap();
assert_eq!(envs.names(), vec!["dev"]);
}
#[test]
fn reads_env_files_saved_with_a_byte_order_mark() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join(PUBLIC_FILE), "\u{feff}{\"dev\":{\"host\":\"h\"}}").unwrap();
let envs = Environments::load(Some(&tmp.path().join("a.http")), Some(tmp.path())).unwrap();
assert_eq!(envs.names(), vec!["dev"]);
}
}
+62 -134
View File
@@ -101,22 +101,6 @@ fn new_id() -> String {
uuid::Uuid::new_v4().to_string()
}
/// Runs a credential-store operation off the async runtime.
///
/// The Linux Secret Service client is blocking (`zbus`) and starts a runtime of
/// its own; calling it while a runtime already drives the current thread panics
/// with "Cannot start a runtime from within a runtime". The store may also wait
/// on an OS prompt, so run it on a plain thread when called from async code.
fn off_runtime<R: Send>(f: impl FnOnce() -> R + Send) -> R {
if tokio::runtime::Handle::try_current().is_err() {
return f();
}
std::thread::scope(|scope| match scope.spawn(f).join() {
Ok(value) => value,
Err(panic) => std::panic::resume_unwind(panic),
})
}
impl SecretFile {
pub fn new(path: PathBuf) -> Self {
Self::with_legacy(path, |v| serde_json::from_value(v).ok())
@@ -131,95 +115,85 @@ impl SecretFile {
}
pub fn get(&self, key: &str) -> Option<String> {
off_runtime(|| {
let _g = LOCK.lock();
match self.load() {
Contents::Index(mut index) if index.keys.contains(key) => {
match self.vault.get(&service(&index.id), key) {
Ok(secret) => Some(secret),
Err(StoreError::NoEntry) => {
// Removed outside the app: stop listing it.
index.keys.remove(key);
self.write_logged(&index);
None
}
Err(e) => {
tracing::warn!("saved passwords: {key}: {e}");
None
}
}
let _g = LOCK.lock();
match self.load() {
Contents::Index(mut index) if index.keys.contains(key) => match self.vault.get(&service(&index.id), key) {
Ok(secret) => Some(secret),
Err(StoreError::NoEntry) => {
// Removed outside the app: stop listing it.
index.keys.remove(key);
self.write_logged(&index);
None
}
Contents::Index(_) => None,
Contents::Plaintext(mut old, _) => old.remove(key),
}
})
Err(e) => {
tracing::warn!("saved passwords: {key}: {e}");
None
}
},
Contents::Index(_) => None,
Contents::Plaintext(mut old, _) => old.remove(key),
}
}
pub fn set(&self, key: &str, secret: &str) -> IdeResult<()> {
off_runtime(|| {
let _g = LOCK.lock();
let mut index = match self.load() {
Contents::Index(index) => index,
Contents::Plaintext(_, why) => return Err(unavailable(&why)),
};
let service = service(&index.id);
self.vault.set(&service, key, secret).map_err(|e| unavailable(&e.to_string()))?;
if index.keys.insert(key.to_owned())
&& let Err(e) = self.write(&index)
{
let _ = self.vault.delete(&service, key);
return Err(e);
}
Ok(())
})
let _g = LOCK.lock();
let mut index = match self.load() {
Contents::Index(index) => index,
Contents::Plaintext(_, why) => return Err(unavailable(&why)),
};
let service = service(&index.id);
self.vault.set(&service, key, secret).map_err(|e| unavailable(&e.to_string()))?;
if index.keys.insert(key.to_owned())
&& let Err(e) = self.write(&index)
{
let _ = self.vault.delete(&service, key);
return Err(e);
}
Ok(())
}
pub fn delete(&self, key: &str) {
off_runtime(|| {
let _g = LOCK.lock();
match self.load() {
Contents::Index(mut index) => {
if !index.keys.contains(key) {
return;
}
match self.vault.delete(&service(&index.id), key) {
Ok(()) | Err(StoreError::NoEntry) => {}
Err(e) => {
// Kept in the index so that the next delete tries again.
tracing::warn!("saved passwords: could not forget {key}: {e}");
return;
}
}
index.keys.remove(key);
self.write_logged(&index);
let _g = LOCK.lock();
match self.load() {
Contents::Index(mut index) => {
if !index.keys.contains(key) {
return;
}
Contents::Plaintext(mut old, _) => {
if old.remove(key).is_none() {
return;
}
// A move halfway may have copied it already.
if let Some(id) = self.pending_id()
&& let Err(e) = self.vault.delete(&service(&id), key)
&& !matches!(e, StoreError::NoEntry)
{
match self.vault.delete(&service(&index.id), key) {
Ok(()) | Err(StoreError::NoEntry) => {}
Err(e) => {
// Kept in the index so that the next delete tries again.
tracing::warn!("saved passwords: could not forget {key}: {e}");
return;
}
self.write_logged(&old);
}
index.keys.remove(key);
self.write_logged(&index);
}
})
Contents::Plaintext(mut old, _) => {
if old.remove(key).is_none() {
return;
}
// A move halfway may have copied it already.
if let Some(id) = self.pending_id()
&& let Err(e) = self.vault.delete(&service(&id), key)
&& !matches!(e, StoreError::NoEntry)
{
tracing::warn!("saved passwords: could not forget {key}: {e}");
return;
}
self.write_logged(&old);
}
}
}
/// Every stored key, sorted (the secrets themselves stay private).
pub fn keys(&self) -> Vec<String> {
off_runtime(|| {
let _g = LOCK.lock();
match self.load() {
Contents::Index(index) => index.keys.into_iter().collect(),
Contents::Plaintext(old, _) => old.into_keys().collect(),
}
})
let _g = LOCK.lock();
match self.load() {
Contents::Index(index) => index.keys.into_iter().collect(),
Contents::Plaintext(old, _) => old.into_keys().collect(),
}
}
fn load(&self) -> Contents {
@@ -584,50 +558,4 @@ mod tests {
assert_eq!(mode & 0o777, 0o600);
}
}
/// A credential store that behaves like the Linux Secret Service client:
/// its blocking API runs a tokio runtime.
#[derive(Default)]
struct NestedRuntimeVault {
entries: Mutex<HashMap<(String, String), String>>,
}
fn nested_runtime() {
tokio::runtime::Builder::new_multi_thread().build().expect("a nested runtime").block_on(async {});
}
impl Vault for NestedRuntimeVault {
fn available(&self) -> bool {
true
}
fn get(&self, service: &str, key: &str) -> StoreResult<String> {
nested_runtime();
self.entries.lock().get(&(service.to_owned(), key.to_owned())).cloned().ok_or(StoreError::NoEntry)
}
fn set(&self, service: &str, key: &str, secret: &str) -> StoreResult<()> {
nested_runtime();
self.entries.lock().insert((service.to_owned(), key.to_owned()), secret.to_owned());
Ok(())
}
fn delete(&self, service: &str, key: &str) -> StoreResult<()> {
nested_runtime();
self.entries.lock().remove(&(service.to_owned(), key.to_owned())).map(|_| ()).ok_or(StoreError::NoEntry)
}
}
/// Saving a password from a Tauri command runs on a tokio runtime; the
/// Secret Service's blocking client starts a runtime of its own and panics
/// when both share a thread ("Cannot start a runtime from within a runtime").
#[tokio::test(flavor = "multi_thread")]
async fn credential_store_access_leaves_the_async_runtime_first() {
let tmp = tempfile::tempdir().unwrap();
let vault = Arc::new(NestedRuntimeVault::default());
let file =
SecretFile::with_vault(tmp.path().join("db-passwords.json"), |v| serde_json::from_value(v).ok(), vault);
file.set("s1", "hunter2").unwrap();
assert_eq!(file.get("s1").as_deref(), Some("hunter2"));
assert_eq!(file.keys(), ["s1"]);
file.delete("s1");
assert_eq!(file.get("s1"), None);
}
}
+3 -2
View File
@@ -167,8 +167,9 @@ export function indentExtension(indent: EffectiveIndent, format: EffectiveIndent
/**
* Indentation explicit reformat requests use: the Code Style without the file's
* detected indents (detection affects editing, reformatting uses the configured
* style, as in IDEA), or the indent chosen for the file in the status bar.
* Unset (editors outside a document model), formatting keeps the editor's indent.
* style, as in IDEA). Set only when the language has an explicit Tabs and Indents
* override — unset, formatting keeps the editor's indent, the status bar choice
* included.
*/
export const formatIndentFacet = Facet.define<EffectiveIndent | null>({
combine: (values) => [values.find((v) => v !== null) ?? null],
-10
View File
@@ -91,16 +91,6 @@ describe("DocumentManager + slots", () => {
expect(formatOptionsOf(slot.currentState())).toEqual({ tabSize: 1, insertSpaces: false });
});
it("reformats with the Code Style indent also without a language override (#2467)", async () => {
const settings = { editor: { codeStyle: { languages: {}, detectIndents: true, editorconfig: false } } };
const { manager } = setup({ "/p/A.java": "class A {\n int x;\n}\n" }, settings);
const model = await manager.acquire("/p/A.java");
const slot = new EditorSlot(model, "t1", host);
// Editing follows the 2-space indent detected in the file; Reformat Code uses Java's 4.
expect(slot.currentState().facet(indentUnit)).toBe(" ");
expect(formatOptionsOf(slot.currentState())).toEqual({ tabSize: 4, insertSpaces: true });
});
it("detects the indents again after Reformat Code rewrote them", async () => {
const settings = {
editor: {
+8 -6
View File
@@ -414,14 +414,16 @@ export class DocModel {
}
/**
* Indentation explicit reformat requests use (`formatIndentFacet`): the Code Style
* (EditorConfig included) without the file's detected indents, as in IDEA, where
* detection only affects editing. Otherwise a file indented differently from the
* Code Style reformats to its own indent and other files to the Code Style's.
* An indent chosen for this file in the status bar still applies.
* Indentation explicit reformat requests use (`formatIndentFacet`): with an
* explicit per-language Tabs and Indents override, the Code Style without the
* file's detected indents — the override must reach the formatter, or the
* language server keeps its own style. Without an override formatting keeps
* the editor's indent.
*/
formatIndent() {
return this.indentOverride ?? effectiveIndent(this.settings(), this.info, this.editorconfig, null);
const lang = this.info ? prefsOf(this.settings()).codeStyle.languages[this.info.id] : undefined;
if (!lang || (lang.useTab === undefined && lang.tabSize === undefined && lang.indent === undefined)) return null;
return effectiveIndent(this.settings(), this.info, this.editorconfig, null);
}
/**
@@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest";
import { Text } from "@codemirror/state";
import { Tree } from "@lezer/common";
import { semanticRanges } from "../index";
describe("Kotlin semantic tokens", () => {
@@ -43,42 +42,4 @@ describe("Kotlin semantic tokens", () => {
)[0]?.className,
).toBe("cm-sem-property");
});
it("colours annotation names as annotations, not as the constructor calls the server reports", () => {
const lines = [
'@Column(name = "x")',
"@field:Inject @Outer.Inner",
"list.forEach { return@forEach }",
"[email protected]()",
];
const doc = Text.of(lines);
const types = ["method", "class", "label"];
const tok = (line: number, word: string, type: string) => [
line,
lines[line]!.lastIndexOf(word),
word.length,
types.indexOf(type),
0,
];
const data = [
tok(0, "Column", "method"),
tok(1, "Inject", "class"),
tok(1, "Outer", "class"),
tok(1, "Inner", "class"),
tok(2, "forEach", "label"),
tok(3, "Outer", "class"),
].flat();
const tokens = { tokenTypes: types, tokenModifiers: [], data };
const ranges = semanticRanges(doc, tokens, false, "kotlin", Tree.empty);
const classOf = (line: number, word: string) => {
const from = doc.line(line + 1).from + lines[line]!.lastIndexOf(word);
return ranges.find((r) => r.from === from)?.className;
};
expect(classOf(0, "Column")).toBe("cm-sem-annotation");
expect(classOf(1, "Inject")).toBe("cm-sem-annotation");
expect(classOf(1, "Outer")).toBe("cm-sem-annotation");
expect(classOf(1, "Inner")).toBe("cm-sem-annotation");
expect(classOf(2, "forEach")).toBe("cm-sem-constant");
expect(classOf(3, "Outer")).not.toBe("cm-sem-annotation");
});
});
+1 -43
View File
@@ -1,49 +1,7 @@
import type { Text } from "@codemirror/state";
import { registerLanguageSemantics, type LanguageSemantics } from "../languages";
const NAME = /[A-Za-z_][\w]*/y;
/**
* The names of annotation usages (`@Column`, `@field:Column`, `@Outer.Inner`), as `"from:to"` → `"annotation"`.
* An `@` right after a name is a label (`return@forEach`, `this@Outer`, `loop@`), not an annotation.
*/
function annotationNames(doc: Text): Map<string, string> {
const out = new Map<string, string>();
const text = doc.toString();
for (let at = text.indexOf("@"); at >= 0; at = text.indexOf("@", at + 1)) {
if (/[\w`]/.test(text[at - 1] ?? "")) continue;
let pos = at + 1;
const name = () => {
NAME.lastIndex = pos;
const m = NAME.exec(text);
return m ? { from: pos, to: pos + m[0].length } : null;
};
let first = name();
// A use-site target (`@field:`, `@get:`, `@file:`) precedes the annotation's name.
if (first && text[first.to] === ":" && text[first.to + 1] !== ":") {
pos = first.to + 1;
first = name();
}
for (let part = first; part;) {
out.set(`${part.from}:${part.to}`, "annotation");
if (text[part.to] !== ".") break;
pos = part.to + 1;
part = name();
}
}
return out;
}
const kotlinSemantics: LanguageSemantics = {
symbolKinds: (_tree, doc) => annotationNames(doc),
tokenClasses({ type, modifiers, kind, classes }) {
// JetBrains Kotlin LSP reports an annotation usage as a constructor call (`method`);
// IDEA colours the name as an annotation (Metadata), like its `@`.
if (kind === "annotation" && type !== "label") {
classes.length = 0;
classes.push("cm-sem-annotation");
return;
}
tokenClasses({ type, modifiers, classes }) {
if (type === "operator" && modifiers.includes("declaration")) classes.push("cm-sem-function");
// JetBrains Kotlin LSP uses `modification` for mutable declarations and references.
if ((type === "variable" || type === "property" || type === "parameter") && modifiers.includes("modification"))
+5 -6
View File
@@ -30,7 +30,6 @@ import {
javaTypeFromHover,
literalType,
replacesBareStatement,
trailingName,
variableDeclaration,
type DeclContext,
type DeclResult,
@@ -379,17 +378,17 @@ function usesSemicolons(doc: Text, statement: SyntaxNode): boolean {
/** Return / declared type of a Java call or name from the server's hover, if it answers. */
async function javaHoverType(p: Prepared, expr: SyntaxNode, text: string): Promise<string | null> {
const last = trailingName(text);
if (!last || !/method_invocation|identifier|field_access/.test(expr.kind)) return null;
const m = /([A-Za-z_$][\w$]*)\s*(\([\s\S]*\))?\s*$/.exec(text);
if (!m || !/method_invocation|identifier|field_access/.test(expr.kind)) return null;
const { from } = rangeToOffsets(p.doc, expr.range);
const pos = p.doc.lineAt(from + last.index);
const at = { line: pos.number - 1, col: from + last.index - pos.from };
const pos = p.doc.lineAt(from + m.index);
const at = { line: pos.number - 1, col: from + m.index - pos.from };
try {
const hover = await Promise.race([
call(commands.lspHover(nextRequestId(), p.ctx.doc, at)),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 1500)),
]);
return hover ? javaTypeFromHover(hover.contents.value, last.name) : null;
return hover ? javaTypeFromHover(hover.contents.value, m[1]!) : null;
} catch {
return null;
}
-10
View File
@@ -5,7 +5,6 @@ import {
isLiteral,
javaConstructedType,
javaTypeFromHover,
trailingName,
literalType,
replacesBareStatement,
variableDeclaration,
@@ -153,15 +152,6 @@ describe("templates", () => {
expect(isLiteral("kotlin", "identifier", "true")).toBe(true);
});
it("finds the name a chained expression ends in, for the hover that types it", () => {
expect(trailingName("demo.getList().get(0)")).toEqual({ name: "get", index: 15 });
expect(trailingName("a.b(c(d)).e")).toEqual({ name: "e", index: 10 });
expect(trailingName("names()")).toEqual({ name: "names", index: 0 });
expect(trailingName("list.get(i) ")).toEqual({ name: "get", index: 5 });
expect(trailingName("(a + b)")).toBeNull();
expect(trailingName("x)")).toBeNull();
});
it("reads a Java type from a jdtls hover", () => {
expect(javaTypeFromHover("```java\nString java.lang.String.trim()\n```", "trim")).toBe("String");
expect(javaTypeFromHover("```java\njava.util.List<java.lang.String> a.B.names()\n```", "names")).toBe(
-21
View File
@@ -229,27 +229,6 @@ export function javaConstructedType(text: string): string | null {
return obj[1]!.replace(/\s+/g, "");
}
/**
* The name an expression ends in — the last call's method (`a.b().c(0)` → `c`) or the last
* name (`a.b` → `b`) — with its offset in `text`. Null when it ends in something else.
*/
export function trailingName(text: string): { name: string; index: number } | null {
let end = text.trimEnd().length;
if (text[end - 1] === ")") {
// Back to the `(` that opens the trailing argument list.
let depth = 0;
let i = end - 1;
for (; i >= 0; i--) {
if (text[i] === ")") depth++;
else if (text[i] === "(" && --depth === 0) break;
}
if (i < 0) return null;
end = text.slice(0, i).trimEnd().length;
}
const m = /[A-Za-z_$][\w$]*$/.exec(text.slice(0, end));
return m ? { name: m[0], index: m.index } : null;
}
/**
* Type of a Java expression from a jdtls-style hover (```java\nString java.lang.String.trim()\n```
* or `String name`), for a call / name ending in `name`. Null when unclear.
+1 -3
View File
@@ -17,7 +17,6 @@ import {
} from "@/ui";
import { openLocation } from "./ipc";
import {
closeHierarchy,
ROOT_ID,
directionLabel,
hierarchyTitle,
@@ -65,10 +64,9 @@ export function HierarchyTabs() {
variant="compact"
className={styles.tabs}
aria-label="Hierarchies"
items={[{ id: "current", label, closable: true }]}
items={[{ id: "current", label }]}
activeId="current"
onSelect={() => undefined}
onClose={closeHierarchy}
/>
);
}
+1 -12
View File
@@ -2,10 +2,9 @@ import { describe, expect, it, vi } from "vitest";
import type { HierarchyItem, OutlineItem, ReferencesIncomplete, SymbolKind } from "@/bindings";
const hint = vi.fn();
const hide = vi.hoisted(() => vi.fn());
vi.mock("@/workbench/contributions", async (orig) => ({
...(await orig<typeof import("@/workbench/contributions")>()),
toolWindowService: () => ({ show: () => undefined, hide }),
toolWindowService: () => ({ show: () => undefined }),
}));
const range = (l1: number, c1: number, l2: number, c2: number) => ({
@@ -168,13 +167,3 @@ describe("hierarchy", () => {
}
});
});
describe("closing the hierarchy tab", () => {
it("clears the tree and hides the tool window", async () => {
const { closeHierarchy, useHierarchy } = await import("./hierarchy");
useHierarchy.setState({ root: { name: "Number", path: "/p/Number.java", line: 0, col: 0 } as never });
closeHierarchy();
expect(useHierarchy.getState().root).toBeNull();
expect(hide).toHaveBeenCalledWith("hierarchy");
});
});
-6
View File
@@ -73,12 +73,6 @@ export function resetHierarchy() {
}));
}
/** Closes the hierarchy's content tab; with no content left the tool window hides. */
export function closeHierarchy() {
resetHierarchy();
toolWindowService().hide("hierarchy");
}
/** The empty-result line of the hierarchy tree. */
export function noneFoundLabel(mode: HierarchyMode, direction: HierarchyDirection): string {
if (mode === "call") return direction === "incoming" ? tx`— no callers found` : tx`— no callees found`;