дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-10 19:55:40 +02:00
Порівняти коміти
1
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
bc42133a36 |
No files matched your search
@@ -18,7 +18,6 @@ semver = "1"
|
||||
blake3.workspace = true
|
||||
zip.workspace = true
|
||||
base64.workspace = true
|
||||
dunce.workspace = true
|
||||
ed25519-dalek = "3"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -66,79 +66,6 @@ async function openHistory(page: Page) {
|
||||
return stream;
|
||||
}
|
||||
|
||||
for (const placement of ["editor", "toolWindow"] as const) {
|
||||
test(`lets small upward wheel steps leave the end while replies stream in the ${placement}`, async ({ page }) => {
|
||||
const stream = await openHistory(page);
|
||||
await page.evaluate(
|
||||
async ({ id, placement }) => {
|
||||
const path = "/src/features/agents/store.ts";
|
||||
const store = await import(path);
|
||||
const state = store.useAgents.getState();
|
||||
store.useAgents.setState({
|
||||
tasks: new Map(state.tasks).set(id, { ...state.tasks.get(id)!, status: "running" }),
|
||||
});
|
||||
if (placement === "toolWindow") store.dockTask(id);
|
||||
},
|
||||
{ id: task.id, placement },
|
||||
);
|
||||
// The working indicator sits in the tray below the conversation.
|
||||
await expect(
|
||||
page.getByRole("status").getByText("Working…", { exact: true }).filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
await expect(stream.locator(":scope > *")).toHaveCount(80 * 3);
|
||||
await expect.poll(() => stream.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop)).toBeLessThan(2);
|
||||
const timer = await page.evaluate(async (id) => {
|
||||
const path = "/src/features/agents/store.ts";
|
||||
const store = await import(path);
|
||||
const el = [...document.querySelectorAll<HTMLElement>("[class*='_stream_']")].find((el) => el.offsetHeight)!;
|
||||
el.addEventListener(
|
||||
"wheel",
|
||||
() => {
|
||||
el.dataset.streamWheelStart = String(el.scrollTop);
|
||||
},
|
||||
{ capture: true, once: true },
|
||||
);
|
||||
let chunks = 0;
|
||||
return window.setInterval(() => {
|
||||
const state = store.useAgents.getState();
|
||||
const entries = state.entries.get(id)!.slice();
|
||||
const last = entries.at(-1)!;
|
||||
if (last.type !== "agent") throw new Error("Expected a streaming reply");
|
||||
entries[entries.length - 1] = { ...last, text: `${last.text}\n\nStreaming chunk ${++chunks}` };
|
||||
store.useAgents.setState({ entries: new Map(state.entries).set(id, entries) });
|
||||
el.dataset.streamChunks = String(chunks);
|
||||
}, 40);
|
||||
}, task.id);
|
||||
const chunks = async () => Number(await stream.getAttribute("data-stream-chunks"));
|
||||
try {
|
||||
await stream.hover();
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const previous = await chunks();
|
||||
await page.mouse.wheel(0, -12);
|
||||
await expect.poll(chunks).toBeGreaterThan(previous);
|
||||
}
|
||||
const top = await stream.evaluate((el) => el.scrollTop);
|
||||
expect(Number(await stream.getAttribute("data-stream-wheel-start")) - top).toBeGreaterThan(80);
|
||||
const previous = await chunks();
|
||||
await expect.poll(chunks).toBeGreaterThan(previous + 3);
|
||||
expect(Math.abs((await stream.evaluate((el) => el.scrollTop)) - top)).toBeLessThanOrEqual(1);
|
||||
const jump = page.getByRole("button", { name: "Jump to Latest" });
|
||||
await expect(jump).toBeVisible();
|
||||
await jump.click();
|
||||
await expect
|
||||
.poll(() => stream.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop))
|
||||
.toBeLessThan(2);
|
||||
const resumed = await chunks();
|
||||
await expect.poll(chunks).toBeGreaterThan(resumed + 3);
|
||||
await expect
|
||||
.poll(() => stream.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop))
|
||||
.toBeLessThan(2);
|
||||
} finally {
|
||||
await page.evaluate((timer) => window.clearInterval(timer), timer);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const selected of [false, true]) {
|
||||
test(`keeps long-chat scroll geometry stable while jumping through history (selected: ${selected})`, async ({
|
||||
page,
|
||||
|
||||
@@ -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,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"))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -543,15 +543,6 @@ interface StreamProps {
|
||||
/** Entries rendered at a time when a conversation opens at its end. */
|
||||
const ENTRY_BATCH = 40;
|
||||
|
||||
function scrollsInside(target: EventTarget | null, root: HTMLElement): boolean {
|
||||
for (let el = target instanceof Element ? target : null; el && el !== root; el = el.parentElement) {
|
||||
if (el.scrollTop <= 0) continue;
|
||||
const overflow = getComputedStyle(el).overflowY;
|
||||
if (overflow === "auto" || overflow === "scroll") return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, findFocus, onCloseFind }: StreamProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useSelectionBoundary(ref);
|
||||
@@ -578,21 +569,6 @@ function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, f
|
||||
const markUserScroll = useCallback(() => {
|
||||
userScrolled.current = performance.now();
|
||||
}, []);
|
||||
const touchY = useRef<number | null>(null);
|
||||
const scrollingUp = useRef(false);
|
||||
// Release before the browser scrolls: a streamed chunk can arrive before its scroll event.
|
||||
const stopFollowing = useCallback(
|
||||
(target: EventTarget | null) => {
|
||||
const el = ref.current;
|
||||
if (!el || el.scrollHeight <= el.clientHeight || scrollsInside(target, el)) return;
|
||||
scrollingUp.current = true;
|
||||
if (!position.current.stick) return;
|
||||
position.current = { top: el.scrollTop, stick: false };
|
||||
positions.set(key, position.current);
|
||||
setAway(true);
|
||||
},
|
||||
[key, positions],
|
||||
);
|
||||
// Earlier entries are prepended above the view: remember where the first painted row is
|
||||
// so the content stays put and the scrollbar doesn't jump under the pointer.
|
||||
const anchor = useRef<{ el: Element; top: number } | null>(null);
|
||||
@@ -648,7 +624,7 @@ function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, f
|
||||
/** A diagram that drew after the last streamed chunk grows the conversation: keep following. */
|
||||
const onDiagramDrawn = useCallback(() => {
|
||||
const el = ref.current;
|
||||
if (el && position.current.stick && !dragging.current) el.scrollTop = el.scrollHeight;
|
||||
if (el && position.current.stick) el.scrollTop = el.scrollHeight;
|
||||
}, []);
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
@@ -656,7 +632,6 @@ function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, f
|
||||
const truncated = entries.length < previousLength.current;
|
||||
previousLength.current = entries.length;
|
||||
if ((truncated || entries.length === 0) && el.scrollHeight - el.scrollTop - el.clientHeight < 40) {
|
||||
scrollingUp.current = false;
|
||||
position.current = { top: el.scrollTop, stick: true };
|
||||
positions.set(key, position.current);
|
||||
setAway(false);
|
||||
@@ -665,9 +640,8 @@ function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, f
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || entries === null || !visible) return;
|
||||
if (position.current.stick) {
|
||||
if (!dragging.current) el.scrollTop = el.scrollHeight;
|
||||
} else if (!restored.current) el.scrollTop = position.current.top;
|
||||
if (position.current.stick) el.scrollTop = el.scrollHeight;
|
||||
else if (!restored.current) el.scrollTop = position.current.top;
|
||||
restored.current = true;
|
||||
});
|
||||
useLayoutEffect(() => {
|
||||
@@ -684,7 +658,7 @@ function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, f
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (position.current.stick && !dragging.current) el.scrollTop = el.scrollHeight;
|
||||
if (position.current.stick) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => {
|
||||
@@ -696,8 +670,7 @@ function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, f
|
||||
const jumpToLatest = useCallback(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
scrollingUp.current = false;
|
||||
position.current = { top: el.scrollTop, stick: true };
|
||||
position.current = { top: el.scrollHeight, stick: true };
|
||||
if (typeof el.scrollTo === "function") el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
||||
else el.scrollTop = el.scrollHeight;
|
||||
setAway(false);
|
||||
@@ -761,52 +734,28 @@ function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, f
|
||||
renderRow(row)
|
||||
);
|
||||
return (
|
||||
<div className={styles.streamArea}>
|
||||
<div
|
||||
className={styles.streamArea}
|
||||
onWheel={markUserScroll}
|
||||
onTouchMove={markUserScroll}
|
||||
onKeyDown={markUserScroll}
|
||||
onPointerDown={markUserScroll}
|
||||
>
|
||||
{finding && <FindBar streamRef={ref} focus={findFocus} onClose={onCloseFind} />}
|
||||
<div
|
||||
ref={ref}
|
||||
// The conversation is read and copied like a document; focusable so ⌘F reaches it.
|
||||
className={clsx(styles.stream, selectableText)}
|
||||
tabIndex={-1}
|
||||
onWheel={(e) => {
|
||||
markUserScroll();
|
||||
if (e.deltaY < 0 && !e.ctrlKey && !e.defaultPrevented) stopFollowing(e.target);
|
||||
else if (e.deltaY > 0 && !e.ctrlKey && !e.defaultPrevented) scrollingUp.current = false;
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
touchY.current = e.touches.length === 1 ? e.touches[0]!.clientY : null;
|
||||
}}
|
||||
onTouchMove={(e) => {
|
||||
markUserScroll();
|
||||
const y = e.touches.length === 1 ? e.touches[0]!.clientY : null;
|
||||
if (y !== null && touchY.current !== null && y > touchY.current) stopFollowing(e.target);
|
||||
else if (y !== null && touchY.current !== null && y < touchY.current) scrollingUp.current = false;
|
||||
touchY.current = y;
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
markUserScroll();
|
||||
if (e.defaultPrevented || (e.target as Element).closest("input, textarea, [contenteditable]")) return;
|
||||
if (e.key === "ArrowUp" || e.key === "PageUp" || e.key === "Home" || (e.key === " " && e.shiftKey))
|
||||
stopFollowing(e.target);
|
||||
else if (e.key === "ArrowDown" || e.key === "PageDown" || e.key === "End" || (e.key === " " && !e.shiftKey))
|
||||
scrollingUp.current = false;
|
||||
}}
|
||||
onPointerDown={() => {
|
||||
markUserScroll();
|
||||
scrollingUp.current = false;
|
||||
}}
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget;
|
||||
const atEnd = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
const laidOut = el.scrollHeight !== seenHeight.current;
|
||||
seenHeight.current = el.scrollHeight;
|
||||
const byUser = dragging.current || performance.now() - userScrolled.current < 500 || !laidOut;
|
||||
const movedDown = el.scrollTop > position.current.top;
|
||||
const stayedAtEnd = position.current.stick && el.scrollTop === position.current.top;
|
||||
const stick =
|
||||
(atEnd && !scrollingUp.current && (movedDown || stayedAtEnd)) || (position.current.stick && !byUser);
|
||||
const stick = atEnd || (position.current.stick && !byUser);
|
||||
// Still following: back to the end the offset was moved away from.
|
||||
if (stick && !atEnd && !dragging.current) el.scrollTop = el.scrollHeight;
|
||||
if (stick && !atEnd) el.scrollTop = el.scrollHeight;
|
||||
position.current = { top: el.scrollTop, stick };
|
||||
positions.set(key, position.current);
|
||||
setAway(!position.current.stick);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { act, fireEvent, render, waitFor } from "@testing-library/react";
|
||||
import { act, render, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
import type { AgentEntry, AgentTask } from "@/bindings";
|
||||
|
||||
@@ -73,7 +73,7 @@ it("redraws a diagram when the theme changes", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true])("preserves scroll ownership when a diagram finishes (away: %s)", async (away) => {
|
||||
it("follows the end when a diagram draws after the last update", async () => {
|
||||
// The diagram is still rendering when the conversation arrives at its end.
|
||||
let draw!: () => void;
|
||||
mermaid.render.mockReset().mockImplementation(
|
||||
@@ -94,16 +94,8 @@ it.each([false, true])("preserves scroll ownership when a diagram finishes (away
|
||||
const scroller = block.closest<HTMLElement>("div[tabindex='-1']")!;
|
||||
Object.defineProperty(scroller, "scrollHeight", { value: 1000, configurable: true });
|
||||
expect(scroller.scrollTop).toBe(0);
|
||||
if (away) {
|
||||
Object.defineProperty(scroller, "clientHeight", { value: 400, configurable: true });
|
||||
scroller.scrollTop = 600;
|
||||
fireEvent.scroll(scroller);
|
||||
fireEvent.wheel(scroller, { deltaY: -4 });
|
||||
scroller.scrollTop = 588;
|
||||
fireEvent.scroll(scroller);
|
||||
}
|
||||
// The draw lands after the last streamed chunk: the viewport must follow it.
|
||||
await act(async () => draw());
|
||||
await waitFor(() => expect(block).toHaveAttribute("data-mermaid-state", "ok"));
|
||||
expect(scroller.scrollTop).toBe(away ? 588 : 1000);
|
||||
expect(scroller.scrollTop).toBe(1000);
|
||||
});
|
||||
@@ -357,89 +357,6 @@ describe("message tags", () => {
|
||||
});
|
||||
|
||||
describe("scrolling", () => {
|
||||
it.each(["wheel", "touch", "ArrowUp", "PageUp", "Home", "Shift+Space"])(
|
||||
"releases following before an upward %s scroll, even within the bottom threshold",
|
||||
async (method) => {
|
||||
useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "Initial reply" }]]]) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const el = stream();
|
||||
let height = 1000;
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, get: () => height });
|
||||
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
|
||||
el.scrollTop = 600;
|
||||
fireEvent.scroll(el);
|
||||
if (method === "wheel") fireEvent.wheel(el, { deltaY: -4 });
|
||||
if (method === "touch") {
|
||||
fireEvent.touchStart(el, { touches: [{ clientY: 100 }] });
|
||||
fireEvent.touchMove(el, { touches: [{ clientY: 110 }] });
|
||||
}
|
||||
if (["ArrowUp", "PageUp", "Home"].includes(method)) fireEvent.keyDown(el, { key: method });
|
||||
if (method === "Shift+Space") fireEvent.keyDown(el, { key: " ", shiftKey: true });
|
||||
// A chunk can land before the browser dispatches the scroll event.
|
||||
height = 1004;
|
||||
act(() => useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "Streaming" }]]]) }));
|
||||
expect(el.scrollTop).toBe(600);
|
||||
el.scrollTop = 588;
|
||||
fireEvent.scroll(el);
|
||||
// A queued event at the same offset must not re-enable following near the bottom.
|
||||
fireEvent.scroll(el);
|
||||
el.scrollTop = 592;
|
||||
fireEvent.scroll(el);
|
||||
height = 1008;
|
||||
act(() => useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "More streaming" }]]]) }));
|
||||
expect(el.scrollTop).toBe(592);
|
||||
expect(screen.getByRole("button", { name: "Jump to Latest" })).toBeInTheDocument();
|
||||
// Scrolling down to the end resumes following.
|
||||
fireEvent.wheel(el, { deltaY: 12 });
|
||||
el.scrollTop = 608;
|
||||
fireEvent.scroll(el);
|
||||
height = 1100;
|
||||
act(() => useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "Latest" }]]]) }));
|
||||
expect(el.scrollTop).toBe(1100);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not move the viewport while the scrollbar is held during streaming", () => {
|
||||
useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "Initial reply" }]]]) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const el = stream();
|
||||
let height = 1000;
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, get: () => height });
|
||||
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
|
||||
el.scrollTop = 600;
|
||||
fireEvent.scroll(el);
|
||||
fireEvent.pointerDown(el);
|
||||
height = 1100;
|
||||
act(() => useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "Streaming" }]]]) }));
|
||||
expect(el.scrollTop).toBe(600);
|
||||
el.scrollTop = 588;
|
||||
fireEvent.scroll(el);
|
||||
fireEvent.pointerUp(window);
|
||||
height = 1200;
|
||||
act(() => useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "More streaming" }]]]) }));
|
||||
expect(el.scrollTop).toBe(588);
|
||||
});
|
||||
|
||||
it("keeps following when an inner tool output consumes the upward wheel", () => {
|
||||
const transcript: AgentEntry[] = [tool("live", { status: "running", output: "Tool output" })];
|
||||
useAgents.setState({ entries: new Map([["A", transcript]]) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const el = stream();
|
||||
const output = screen.getByText("Tool output");
|
||||
let height = 1000;
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, get: () => height });
|
||||
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
|
||||
output.style.overflowY = "auto";
|
||||
output.scrollTop = 100;
|
||||
el.scrollTop = 600;
|
||||
fireEvent.scroll(el);
|
||||
fireEvent.wheel(output, { deltaY: -4 });
|
||||
height = 1100;
|
||||
act(() => useAgents.setState({ entries: new Map([["A", [...transcript, { type: "agent", text: "Streaming" }]]]) }));
|
||||
expect(el.scrollTop).toBe(1100);
|
||||
expect(screen.queryByRole("button", { name: "Jump to Latest" })).toBeNull();
|
||||
});
|
||||
|
||||
it("hides Jump to Latest and follows new replies after retracting the conversation", async () => {
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const el = stream();
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
|
||||
Reference in new issue
Заблокувати користувача