mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 17:22:55 -10:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d0b644e20 | ||
|
|
6efff739dd | ||
|
|
8ca6e4bc01 | ||
|
|
8d909598d5 | ||
|
|
b99fe1704b | ||
|
|
159c2d6fd2 | ||
|
|
5084388a68 | ||
|
|
68342b32bf | ||
|
|
352efc02a7 | ||
|
|
24ffb9f16e | ||
|
|
8ebf839d8e | ||
|
|
14a31fe8a1 | ||
|
|
1af1744290 | ||
|
|
ee6786d573 | ||
|
|
5804fcad16 |
No files matched your search
@@ -56,7 +56,7 @@ pub struct Project {
|
||||
pub notebooks: Arc<crate::notebook::NotebookService>,
|
||||
pub(crate) kotlin_bridge: Option<Arc<crate::kotlin_bridge::KotlinBridge>>,
|
||||
pub(crate) phpstan: Option<Arc<crate::phpstan::Phpstan>>,
|
||||
pub(crate) watcher: Watcher,
|
||||
pub(crate) watcher: Arc<Watcher>,
|
||||
/// Watchers of the Project Structure content roots outside the project folder.
|
||||
pub(crate) content_watchers: Mutex<Vec<(PathBuf, Watcher)>>,
|
||||
/// Watchers of folders outside the project that language servers registered file
|
||||
@@ -305,6 +305,12 @@ impl Host {
|
||||
for repo in repos.read().all() {
|
||||
repo.set_execution_allowed(trusted.load(Ordering::Acquire));
|
||||
}
|
||||
// Shared handle: `unwatched_reason` is a live query on the watcher,
|
||||
// filled after `start` returns so the on-change callback can reach it.
|
||||
let watcher_weak = Arc::new(std::sync::OnceLock::<std::sync::Weak<Watcher>>::new());
|
||||
// One-shot latch for the post-start watch-limit warning (#1915): the
|
||||
// synchronous startup walk and a later batch can both observe it.
|
||||
let notify_watch_exhausted = Arc::new(AtomicBool::new(false));
|
||||
let watcher = {
|
||||
let kotlin_bridge = kotlin_bridge.clone();
|
||||
let phpstan = phpstan.clone();
|
||||
@@ -322,6 +328,8 @@ impl Host {
|
||||
let lsp = lsp.clone();
|
||||
let trusted = trusted.clone();
|
||||
let build_cache = build_cache.clone();
|
||||
let notify_watch_exhausted = notify_watch_exhausted.clone();
|
||||
let watcher_weak = watcher_weak.clone();
|
||||
Watcher::start(
|
||||
&root.clone(),
|
||||
excludes.clone(),
|
||||
@@ -408,9 +416,26 @@ impl Host {
|
||||
if git_dirs || vcs_moved {
|
||||
sink.emit(IdeEvent::VcsChanged { root: root.clone().into() });
|
||||
}
|
||||
// A watch limit hit after startup (per-directory mode, Linux):
|
||||
// folders past the limit never sync — surface it once, like
|
||||
// the polling fallback below (#1915). The check itself is
|
||||
// deferred to here, on a batch that already had events.
|
||||
if !notify_watch_exhausted.load(Ordering::Relaxed)
|
||||
&& let Some(msg) =
|
||||
watcher_weak.get().and_then(std::sync::Weak::upgrade).and_then(|w| w.unwatched_reason())
|
||||
{
|
||||
sink.emit(IdeEvent::Notify {
|
||||
level: ide_core::event::NotifyLevel::Warning,
|
||||
title: "External file changes sync may be slow".into(),
|
||||
body: Some(msg),
|
||||
});
|
||||
notify_watch_exhausted.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}),
|
||||
)?
|
||||
};
|
||||
let watcher = Arc::new(watcher);
|
||||
let _ = watcher_weak.set(Arc::downgrade(&watcher));
|
||||
if let Some(reason) = watcher.polling() {
|
||||
sink.emit(IdeEvent::Notify {
|
||||
level: ide_core::event::NotifyLevel::Warning,
|
||||
@@ -418,6 +443,17 @@ impl Host {
|
||||
body: Some(reason.into()),
|
||||
});
|
||||
}
|
||||
// A watch can also be refused part-way through the synchronous startup
|
||||
// walk (large tree, SYNC_WATCH_BUDGET) — after start() returns, before
|
||||
// any batch. Emit the same warning so the limit isn't silent there either.
|
||||
if let Some(reason) = watcher.unwatched_reason() {
|
||||
sink.emit(IdeEvent::Notify {
|
||||
level: ide_core::event::NotifyLevel::Warning,
|
||||
title: "External file changes sync may be slow".into(),
|
||||
body: Some(reason),
|
||||
});
|
||||
notify_watch_exhausted.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
// Scratch files live outside the project; watch them too so the
|
||||
// Project view and open editors follow changes made there.
|
||||
@@ -644,7 +680,7 @@ impl Host {
|
||||
notebooks,
|
||||
kotlin_bridge: None,
|
||||
phpstan: None,
|
||||
watcher,
|
||||
watcher: Arc::new(watcher),
|
||||
content_watchers: Mutex::new(Vec::new()),
|
||||
lsp_watchers: Mutex::new(Vec::new()),
|
||||
_scratch_watcher: None,
|
||||
|
||||
@@ -9,6 +9,7 @@ use std::sync::{Arc, OnceLock};
|
||||
|
||||
/// Built-in definitions, embedded at compile time. Add new languages here.
|
||||
const BUILTIN: &[(&str, &str)] = &[
|
||||
("arduino", include_str!("../../../languages/arduino.toml")),
|
||||
("asciidoc", include_str!("../../../languages/asciidoc.toml")),
|
||||
("astro", include_str!("../../../languages/astro.toml")),
|
||||
("bat", include_str!("../../../languages/bat.toml")),
|
||||
@@ -601,9 +602,11 @@ mod tests {
|
||||
"MsGenny",
|
||||
"Vue",
|
||||
"Angular Template",
|
||||
"Angular TypeScript",
|
||||
"ERB",
|
||||
"JSP",
|
||||
"Astro",
|
||||
"GoMod",
|
||||
];
|
||||
|
||||
fn reg() -> LanguageRegistry {
|
||||
@@ -716,7 +719,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_of_angular_projects_is_an_angular_template() {
|
||||
fn angular_projects_use_framework_modes() {
|
||||
let reg = reg();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
@@ -727,7 +730,12 @@ mod tests {
|
||||
assert_eq!(mode("html", "apps/web/src/app/app.html").as_deref(), Some("Angular Template"));
|
||||
assert_eq!(mode("html", "apps/admin/src/index.html").as_deref(), Some("Angular Template"));
|
||||
assert_eq!(mode("html", "docs/index.html"), None);
|
||||
assert_eq!(mode("typescript", "apps/web/src/app/app.ts"), None);
|
||||
for file in ["apps/web/src/app/app.ts", "apps/admin/src/app.mts", "apps/admin/src/app.cts"] {
|
||||
assert_eq!(mode("typescript", file).as_deref(), Some("Angular TypeScript"), "{file}");
|
||||
}
|
||||
assert_eq!(mode("typescript", "docs/example.ts"), None);
|
||||
assert_eq!(mode("javascript", "apps/web/src/app/app.js"), None);
|
||||
assert_eq!(mode("typescriptreact", "apps/web/src/app/app.tsx"), None);
|
||||
assert!(reg.is_framework_marker(&root.join("apps/admin/angular.json")));
|
||||
assert!(!reg.is_framework_marker(&root.join("apps/admin/package.json")));
|
||||
}
|
||||
@@ -860,6 +868,7 @@ mod tests {
|
||||
("x.h", "c"),
|
||||
("x.hpp", "cpp"),
|
||||
("x.cc", "cpp"),
|
||||
("blink.ino", "arduino"),
|
||||
("P.cs", "csharp"),
|
||||
("V.swift", "swift"),
|
||||
("AppDelegate.m", "objective-c"),
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
//! Go files a gopls search does not reach.
|
||||
//!
|
||||
//! gopls answers a search from the view of the file it is asked about: the
|
||||
//! modules of a `go.work`, or that file's own module, built for the host's
|
||||
//! GOOS/GOARCH without extra tags. Opening more files does not widen it (each
|
||||
//! gets a view of its own), so the generic scope widening is skipped for Go.
|
||||
//! Usages it leaves out are found as GoLand finds them, by resolving each
|
||||
//! mention of the name in its own file's view: files of other modules that use
|
||||
//! the declaration's module through a local `replace` (a multi-module
|
||||
//! repository without `go.work`), and files whose build constraints exclude
|
||||
//! them from the default build (`_windows.go`, `//go:build`).
|
||||
|
||||
use ide_core::{Pos, Range};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The files of `candidates` the search at `origin` does not cover and that can refer to
|
||||
/// `declaration`: those of the covered modules excluded from their default build, and those
|
||||
/// of the declaration's own module or one replacing it with its directory when not covered.
|
||||
pub(crate) fn files_beyond_search(origin: &Path, declaration: &Path, candidates: Vec<PathBuf>) -> Vec<PathBuf> {
|
||||
let mut modules = ModuleRoots::default();
|
||||
let (Some(origin_module), Some(declaring)) = (modules.of(origin), modules.of(declaration)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(declaring_path) = read_module_path(&declaring) else { return Vec::new() };
|
||||
let mut covered: HashSet<PathBuf> = workspace_modules(&origin_module).into_iter().collect();
|
||||
covered.insert(origin_module);
|
||||
let mut uses: HashMap<PathBuf, bool> = HashMap::new();
|
||||
candidates
|
||||
.into_iter()
|
||||
.filter(|file| {
|
||||
let Some(module) = modules.of(file) else { return false };
|
||||
// The covered modules share the search's view: only their excluded files are missed.
|
||||
if covered.contains(&module) {
|
||||
return !in_default_build(file);
|
||||
}
|
||||
*uses.entry(module.clone()).or_insert_with(|| {
|
||||
module == declaring
|
||||
|| std::fs::read_to_string(module.join("go.mod")).is_ok_and(|text| {
|
||||
local_replacements(&text, &module)
|
||||
.iter()
|
||||
.any(|(path, dir)| *path == declaring_path && *dir == declaring)
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether gopls' default view builds `file`: no build constraint other than the host's
|
||||
/// own GOOS/GOARCH file name suffix.
|
||||
fn in_default_build(file: &Path) -> bool {
|
||||
let name = file.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let stem = name.strip_suffix(".go").unwrap_or(&name);
|
||||
let stem = stem.strip_suffix("_test").unwrap_or(stem);
|
||||
let parts: Vec<&str> = stem.split('_').collect();
|
||||
// `name_GOOS`, `name_GOARCH`, `name_GOOS_GOARCH`; a file named just `linux.go` has no constraint.
|
||||
let host = |part: &&str| *part == host_goos() || *part == host_goarch();
|
||||
if parts.len() > 1 {
|
||||
let last = parts[parts.len() - 1];
|
||||
if (GOOS.contains(&last) || GOARCH.contains(&last)) && !host(&last) {
|
||||
return false;
|
||||
}
|
||||
if parts.len() > 2 && GOARCH.contains(&last) {
|
||||
let os = parts[parts.len() - 2];
|
||||
if GOOS.contains(&os) && !host(&os) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
std::fs::read_to_string(file).is_ok_and(|text| !constrained_header(&text))
|
||||
}
|
||||
|
||||
/// A `//go:build` or `// +build` line before the package clause.
|
||||
fn constrained_header(text: &str) -> bool {
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with("package ") {
|
||||
return false;
|
||||
}
|
||||
if line.starts_with("//go:build") || line.starts_with("// +build") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn host_goos() -> &'static str {
|
||||
match std::env::consts::OS {
|
||||
"macos" => "darwin",
|
||||
os => os,
|
||||
}
|
||||
}
|
||||
|
||||
fn host_goarch() -> &'static str {
|
||||
match std::env::consts::ARCH {
|
||||
"x86_64" => "amd64",
|
||||
"aarch64" => "arm64",
|
||||
"x86" => "386",
|
||||
"powerpc64" => "ppc64",
|
||||
arch => arch,
|
||||
}
|
||||
}
|
||||
|
||||
const GOOS: &[&str] = &[
|
||||
"aix",
|
||||
"android",
|
||||
"darwin",
|
||||
"dragonfly",
|
||||
"freebsd",
|
||||
"hurd",
|
||||
"illumos",
|
||||
"ios",
|
||||
"js",
|
||||
"linux",
|
||||
"nacl",
|
||||
"netbsd",
|
||||
"openbsd",
|
||||
"plan9",
|
||||
"solaris",
|
||||
"wasip1",
|
||||
"windows",
|
||||
"zos",
|
||||
];
|
||||
const GOARCH: &[&str] = &[
|
||||
"386",
|
||||
"amd64",
|
||||
"amd64p32",
|
||||
"arm",
|
||||
"armbe",
|
||||
"arm64",
|
||||
"arm64be",
|
||||
"loong64",
|
||||
"mips",
|
||||
"mipsle",
|
||||
"mips64",
|
||||
"mips64le",
|
||||
"mips64p32",
|
||||
"mips64p32le",
|
||||
"ppc",
|
||||
"ppc64",
|
||||
"ppc64le",
|
||||
"riscv",
|
||||
"riscv64",
|
||||
"s390",
|
||||
"s390x",
|
||||
"sparc",
|
||||
"sparc64",
|
||||
"wasm",
|
||||
];
|
||||
|
||||
/// Whole-word occurrences of `word` in `text`, as ranges in UTF-16 columns.
|
||||
pub(crate) fn word_ranges(text: &str, word: &str) -> Vec<Range> {
|
||||
let ident = |c: char| c.is_alphanumeric() || c == '_';
|
||||
let mut out = Vec::new();
|
||||
for (line_no, line) in text.lines().enumerate() {
|
||||
let code = line.split("//").next().unwrap_or(line);
|
||||
for (at, _) in code.match_indices(word) {
|
||||
let before = code[..at].chars().next_back();
|
||||
let after = code[at + word.len()..].chars().next();
|
||||
if before.is_some_and(ident) || after.is_some_and(ident) {
|
||||
continue;
|
||||
}
|
||||
let col = code[..at].encode_utf16().count() as u32;
|
||||
let len = word.encode_utf16().count() as u32;
|
||||
out.push(Range::new(Pos::new(line_no as u32, col), Pos::new(line_no as u32, col + len)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Nearest enclosing `go.mod` directory, cached per directory.
|
||||
#[derive(Default)]
|
||||
struct ModuleRoots(HashMap<PathBuf, Option<PathBuf>>);
|
||||
|
||||
impl ModuleRoots {
|
||||
fn of(&mut self, file: &Path) -> Option<PathBuf> {
|
||||
let dir = file.parent()?.to_path_buf();
|
||||
self.dir(&dir)
|
||||
}
|
||||
|
||||
fn dir(&mut self, dir: &Path) -> Option<PathBuf> {
|
||||
if let Some(found) = self.0.get(dir) {
|
||||
return found.clone();
|
||||
}
|
||||
let found =
|
||||
if dir.join("go.mod").is_file() { Some(dir.to_path_buf()) } else { dir.parent().and_then(|p| self.dir(p)) };
|
||||
self.0.insert(dir.to_path_buf(), found.clone());
|
||||
found
|
||||
}
|
||||
}
|
||||
|
||||
fn read_module_path(module: &Path) -> Option<String> {
|
||||
let text = std::fs::read_to_string(module.join("go.mod")).ok()?;
|
||||
text.lines().find_map(|line| Some(unquote(line.trim().strip_prefix("module ")?.trim())))
|
||||
}
|
||||
|
||||
/// The modules a `go.work` above `module` uses, as gopls loads them together.
|
||||
fn workspace_modules(module: &Path) -> Vec<PathBuf> {
|
||||
let Some(work) = module.ancestors().map(|d| d.join("go.work")).find(|p| p.is_file()) else { return Vec::new() };
|
||||
let Ok(text) = std::fs::read_to_string(&work) else { return Vec::new() };
|
||||
let base = work.parent().unwrap_or(Path::new(""));
|
||||
directives(&text, "use").into_iter().map(|dir| normalize(&base.join(dir))).collect()
|
||||
}
|
||||
|
||||
/// `replace <module> [version] => <local directory>` entries, directories resolved against `base`.
|
||||
fn local_replacements(go_mod: &str, base: &Path) -> Vec<(String, PathBuf)> {
|
||||
directives(go_mod, "replace")
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
let (from, to) = entry.split_once("=>")?;
|
||||
let from = unquote(from.split_whitespace().next()?);
|
||||
let to = unquote(to.split_whitespace().next()?);
|
||||
let local = to.starts_with("./")
|
||||
|| to.starts_with("../")
|
||||
|| Path::new(&to).is_absolute()
|
||||
|| to == "."
|
||||
|| to == "..";
|
||||
local.then(|| (from, normalize(&base.join(to))))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The arguments of `keyword` directives, single-line and block form.
|
||||
fn directives(text: &str, keyword: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut in_block = false;
|
||||
for line in text.lines() {
|
||||
let line = line.split("//").next().unwrap_or("").trim();
|
||||
if in_block {
|
||||
if line == ")" {
|
||||
in_block = false;
|
||||
} else if !line.is_empty() {
|
||||
out.push(line.to_owned());
|
||||
}
|
||||
} else if let Some(rest) = line.strip_prefix(keyword)
|
||||
&& rest.starts_with([' ', '\t', '('])
|
||||
{
|
||||
let rest = rest.trim();
|
||||
if rest == "(" {
|
||||
in_block = true;
|
||||
} else if !rest.is_empty() {
|
||||
out.push(rest.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn unquote(s: &str) -> String {
|
||||
s.trim_matches(|c| c == '"' || c == '`').to_owned()
|
||||
}
|
||||
|
||||
fn normalize(path: &Path) -> PathBuf {
|
||||
let mut out = PathBuf::new();
|
||||
for c in path.components() {
|
||||
match c {
|
||||
std::path::Component::CurDir => {}
|
||||
std::path::Component::ParentDir => {
|
||||
out.pop();
|
||||
}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn write(root: &Path, rel: &str, text: &str) -> PathBuf {
|
||||
let path = root.join(rel);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, text).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn names(root: &Path, files: Vec<PathBuf>) -> Vec<String> {
|
||||
files.iter().map(|f| f.strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/")).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modules_replacing_the_declarations_module_are_searched() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(root, "go.mod", "module example.com/app\n");
|
||||
let origin = write(root, "app.go", "package app\nfunc New() {}\n");
|
||||
write(root, "example/a/go.mod", "module a\n\nreplace example.com/app => ../..\n");
|
||||
write(root, "example/b/go.mod", "module b\n\nreplace (\n\texample.com/app v1.0.0 => \"../../\"\n)\n");
|
||||
write(root, "example/c/go.mod", "module c\n\nrequire example.com/app v1.0.0\n");
|
||||
write(root, "example/d/go.mod", "module d\n\nreplace example.com/app => github.com/fork/app v1.0.0\n");
|
||||
let candidates = vec![
|
||||
write(root, "same.go", "package app\n"),
|
||||
write(root, "same_plan9.go", "package app\n"),
|
||||
write(root, &format!("same_{}.go", host_goos()), "package app\n"),
|
||||
write(root, "same_plan9_test.go", "package app\n"),
|
||||
write(root, "tagged.go", "// Copyright\n\n//go:build integration\n\npackage app\n"),
|
||||
write(root, "plan9.go", "package app\n"),
|
||||
write(root, "example/a/main.go", "package main\n"),
|
||||
write(root, "example/b/main.go", "package main\n"),
|
||||
write(root, "example/c/main.go", "package main\n"),
|
||||
write(root, "example/d/main.go", "package main\n"),
|
||||
];
|
||||
assert_eq!(
|
||||
names(root, files_beyond_search(&origin, &origin, candidates)),
|
||||
["same_plan9.go", "same_plan9_test.go", "tagged.go", "example/a/main.go", "example/b/main.go"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_work_modules_are_covered_by_the_first_search() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(root, "go.work", "go 1.22\n\nuse (\n\t./lib // shared\n\t./app\n)\n");
|
||||
write(root, "lib/go.mod", "module example.com/lib\n");
|
||||
write(root, "app/go.mod", "module example.com/app\n\nreplace example.com/lib => ../lib\n");
|
||||
write(root, "tool/go.mod", "module example.com/tool\n\nreplace example.com/lib => ../lib\n");
|
||||
let declaration = write(root, "lib/lib.go", "package lib\n");
|
||||
let candidates = vec![
|
||||
write(root, "app/main.go", "package main\n"),
|
||||
write(root, "app/main_plan9.go", "package main\n"),
|
||||
write(root, "tool/main.go", "package main\n"),
|
||||
];
|
||||
assert_eq!(
|
||||
names(root, files_beyond_search(&declaration, &declaration, candidates)),
|
||||
["app/main_plan9.go", "tool/main.go"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_usage_site_in_another_module_searches_the_declaring_module() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(root, "go.mod", "module example.com/app\n");
|
||||
let declaration = write(root, "app.go", "package app\n");
|
||||
write(root, "cmd/go.mod", "module cmd\n\nreplace example.com/app => ../\n");
|
||||
let origin = write(root, "cmd/main.go", "package main\n");
|
||||
let candidates = vec![write(root, "other.go", "package app\n"), write(root, "cmd/util.go", "package main\n")];
|
||||
assert_eq!(names(root, files_beyond_search(&origin, &declaration, candidates)), ["other.go"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_ranges_are_whole_words_outside_line_comments() {
|
||||
let text = "x := app.New() // New here\nNewer := New\n\tvar é = New()\n";
|
||||
assert_eq!(
|
||||
word_ranges(text, "New"),
|
||||
[
|
||||
Range::new(Pos::new(0, 9), Pos::new(0, 12)),
|
||||
Range::new(Pos::new(1, 9), Pos::new(1, 12)),
|
||||
Range::new(Pos::new(2, 9), Pos::new(2, 12)),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ mod convert;
|
||||
mod external_project;
|
||||
mod file_ops;
|
||||
mod file_watch;
|
||||
mod go_scope;
|
||||
mod handler;
|
||||
mod inspect;
|
||||
pub mod jdtls;
|
||||
@@ -53,7 +54,7 @@ use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{Value, json};
|
||||
use server::{Conn, Running, Server};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Weak};
|
||||
@@ -1688,6 +1689,19 @@ impl LspManager {
|
||||
}
|
||||
let mut v = out.finish(References::default())?;
|
||||
convert::dedup_locations(&mut v.locations);
|
||||
if project_wide && let Some(t) = targets.first().filter(|_| snap.language.as_str() == "go") {
|
||||
// Usages in other modules take a view per module: those in reach come first.
|
||||
if let Some(partial) = partial
|
||||
&& !v.locations.is_empty()
|
||||
{
|
||||
partial(v.clone());
|
||||
}
|
||||
v.locations.extend(self.go_mentions_beyond_search(&snap, t, pos, &cancel, false).await.locations);
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
convert::dedup_locations(&mut v.locations);
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
@@ -2120,7 +2134,20 @@ impl LspManager {
|
||||
};
|
||||
let w = self.widened(snap, t, pos, cancel, query, files).await?;
|
||||
// The second edit covers the first: its files stay analysed.
|
||||
Ok(Renamed { edit: w.second.unwrap_or(w.first), incomplete: w.incomplete })
|
||||
let mut edit = w.second.unwrap_or(w.first);
|
||||
let mut more: BTreeMap<PathBuf, Vec<TextEdit>> = BTreeMap::new();
|
||||
for l in self.go_mentions_beyond_search(snap, t, pos, cancel, false).await.locations {
|
||||
more.entry(l.path.to_path_buf())
|
||||
.or_default()
|
||||
.push(TextEdit { range: l.range, new_text: new_name.to_owned() });
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let files =
|
||||
more.into_iter().map(|(path, edits)| ide_core::FileEdit::Edit { path: path.into(), edits, version: None });
|
||||
convert::merge_workspace_edit(&mut edit, WorkspaceEdit { files: files.collect(), ..Default::default() });
|
||||
Ok(Renamed { edit, incomplete: w.incomplete })
|
||||
}
|
||||
|
||||
// ---- file operations (`workspace/willRenameFiles`, `workspace/didRenameFiles`) ----
|
||||
@@ -2565,6 +2592,15 @@ impl LspManager {
|
||||
}
|
||||
HierarchyDirection::Outgoing => (query().await?, None),
|
||||
};
|
||||
let mut out = out;
|
||||
if direction == HierarchyDirection::Incoming {
|
||||
for caller in self.go_mentions_beyond_search(&snap, t, pos, cancel, true).await.callers {
|
||||
match out.iter_mut().find(|i| i.path == caller.path && i.range == caller.range) {
|
||||
Some(item) => item.from_ranges.extend(caller.from_ranges),
|
||||
None => out.push(caller),
|
||||
}
|
||||
}
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
|
||||
+184
-3
@@ -10,9 +10,11 @@
|
||||
//! closed again. Diagnostics the server publishes for them are dropped.
|
||||
|
||||
use crate::server::{Running, Server};
|
||||
use crate::types::ReferencesIncomplete;
|
||||
use crate::text::DocText;
|
||||
use crate::types::{HierarchyItem, ReferencesIncomplete};
|
||||
use crate::{DocSnap, LspManager, Target};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, LanguageId, Pos};
|
||||
use futures::StreamExt;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, LanguageId, Location, Pos};
|
||||
use lsp_types as lsp;
|
||||
use serde_json::json;
|
||||
use std::collections::HashSet;
|
||||
@@ -51,6 +53,14 @@ pub struct CandidateFiles {
|
||||
/// blocking thread and should stop early when cancelled.
|
||||
pub type CandidateFn = dyn Fn(&CandidateQuery, &CancellationToken) -> CandidateFiles + Send + Sync;
|
||||
|
||||
/// What a Go search adds beyond gopls' own answer.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct GoMentions {
|
||||
pub locations: Vec<Location>,
|
||||
/// The declarations containing them, as incoming calls (when asked for).
|
||||
pub callers: Vec<HierarchyItem>,
|
||||
}
|
||||
|
||||
/// A reference-based answer and, when files were opened for it, the second one.
|
||||
pub(crate) struct Widened<R> {
|
||||
pub first: R,
|
||||
@@ -110,7 +120,9 @@ impl LspManager {
|
||||
Fut: Future<Output = IdeResult<R>>,
|
||||
{
|
||||
let busy = t.server.is_busy();
|
||||
let word = search_word(snap, pos).filter(|_| t.caps().open_close);
|
||||
// gopls searches the view of the file asked about; files opened for it only get views of
|
||||
// their own (see `go_scope`).
|
||||
let word = search_word(snap, pos).filter(|_| t.caps().open_close && snap.language.as_str() != "go");
|
||||
let candidates = async {
|
||||
match word {
|
||||
Some(w) => self.candidates(t, w, cancel).await,
|
||||
@@ -162,6 +174,175 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Go: usages of the symbol at `pos` that the search at `pos` does not reach (see
|
||||
/// `go_scope`), each mention of the name resolved in its own file's view; with `callers`,
|
||||
/// also the declarations containing them, as incoming calls.
|
||||
pub(crate) async fn go_mentions_beyond_search(
|
||||
&self,
|
||||
snap: &DocSnap,
|
||||
t: &Target,
|
||||
pos: Pos,
|
||||
cancel: &CancellationToken,
|
||||
callers: bool,
|
||||
) -> GoMentions {
|
||||
if snap.language.as_str() != "go" || !t.caps().open_close {
|
||||
return GoMentions::default();
|
||||
}
|
||||
let Some(word) = search_word(snap, pos) else { return GoMentions::default() };
|
||||
let declarations: Vec<Location> = self
|
||||
.goto_in(snap, std::slice::from_ref(t), "textDocument/definition", pos, Some(cancel), None)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|l| l.uri.is_none())
|
||||
.collect();
|
||||
let Some(declaration) = declarations.first() else { return GoMentions::default() };
|
||||
let Some(candidates) = self.candidates(t, word.clone(), cancel).await else { return GoMentions::default() };
|
||||
let (origin, declared) = (snap.path.clone(), declaration.path.to_path_buf());
|
||||
let files = tokio::task::spawn_blocking(move || {
|
||||
crate::go_scope::files_beyond_search(&origin, &declared, candidates.files)
|
||||
.into_iter()
|
||||
.filter_map(|f| {
|
||||
let text = std::fs::read_to_string(&f).ok()?;
|
||||
let ranges = crate::go_scope::word_ranges(&text, &word);
|
||||
(!ranges.is_empty()).then(|| (f, DocText::new(&text), ranges))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if files.is_empty() || cancel.is_cancelled() {
|
||||
return GoMentions::default();
|
||||
}
|
||||
// Open, each file gives gopls a view to resolve its mentions in (its module, its build).
|
||||
let _opened = self.open_transient(t, files.iter().map(|(f, _, _)| f.clone()).collect(), cancel).await;
|
||||
let texts: Vec<(PathBuf, Arc<DocText>)> =
|
||||
files.iter().map(|(path, text, _)| (path.clone(), Arc::new(text.clone()))).collect();
|
||||
let probes: Vec<(PathBuf, Arc<DocText>, ide_core::Range)> = files
|
||||
.into_iter()
|
||||
.zip(&texts)
|
||||
.flat_map(|((path, _, ranges), (_, text))| {
|
||||
let text = text.clone();
|
||||
ranges.into_iter().map(move |r| (path.clone(), text.clone(), r))
|
||||
})
|
||||
.collect();
|
||||
let declarations = &declarations;
|
||||
let found: Vec<Option<Location>> = futures::stream::iter(probes)
|
||||
.map(|(path, text, range)| async move {
|
||||
let params = json!({
|
||||
"textDocument": { "uri": crate::uri::path_to_uri_string(&path) },
|
||||
"position": text.ide_to_lsp(t.enc(), range.start),
|
||||
});
|
||||
let r = crate::loading::while_loading(
|
||||
&t.server,
|
||||
Some(cancel),
|
||||
crate::loading::LOADING_WAIT,
|
||||
crate::goto_is_empty,
|
||||
|| {
|
||||
self.call::<_, Option<lsp::GotoDefinitionResponse>>(
|
||||
t,
|
||||
"textDocument/definition",
|
||||
params.clone(),
|
||||
Some(cancel),
|
||||
crate::DEFAULT_TIMEOUT,
|
||||
)
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let targets = match r {
|
||||
Ok(Some(r)) => crate::convert::goto_response(&self.conv(t.enc()), r),
|
||||
_ => return None,
|
||||
};
|
||||
let same = targets
|
||||
.iter()
|
||||
.any(|l| declarations.iter().any(|d| d.path == l.path && d.range.start == l.range.start));
|
||||
same.then(|| Location { path: path.into(), range, uri: None })
|
||||
})
|
||||
.buffer_unordered(8)
|
||||
.collect()
|
||||
.await;
|
||||
let locations: Vec<Location> = found.into_iter().flatten().collect();
|
||||
let mut out = GoMentions { callers: Vec::new(), locations };
|
||||
if callers && !cancel.is_cancelled() {
|
||||
for (path, text) in &texts {
|
||||
let here: Vec<ide_core::Range> =
|
||||
out.locations.iter().filter(|l| l.path == *path).map(|l| l.range).collect();
|
||||
if !here.is_empty() {
|
||||
out.callers.extend(self.go_callers_in(t, path, text, &here, cancel).await);
|
||||
}
|
||||
}
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
return GoMentions::default();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The declarations of `path` containing `calls`, innermost first, each with its call sites.
|
||||
async fn go_callers_in(
|
||||
&self,
|
||||
t: &Target,
|
||||
path: &Path,
|
||||
text: &DocText,
|
||||
calls: &[ide_core::Range],
|
||||
cancel: &CancellationToken,
|
||||
) -> Vec<HierarchyItem> {
|
||||
let params = json!({ "textDocument": { "uri": crate::uri::path_to_uri_string(path) } });
|
||||
let r: IdeResult<Option<lsp::DocumentSymbolResponse>> =
|
||||
self.call(t, "textDocument/documentSymbol", params, Some(cancel), crate::DEFAULT_TIMEOUT).await;
|
||||
let range =
|
||||
|r: lsp::Range| ide_core::Range::new(text.lsp_to_ide(t.enc(), r.start), text.lsp_to_ide(t.enc(), r.end));
|
||||
// (name, kind, detail, range, selection range) of every declaration.
|
||||
let mut symbols = Vec::new();
|
||||
match r {
|
||||
Ok(Some(lsp::DocumentSymbolResponse::Nested(nested))) => {
|
||||
let mut stack = nested;
|
||||
while let Some(s) = stack.pop() {
|
||||
symbols.push((s.name, s.kind, s.detail, range(s.range), range(s.selection_range)));
|
||||
stack.extend(s.children.unwrap_or_default());
|
||||
}
|
||||
}
|
||||
#[allow(deprecated)]
|
||||
Ok(Some(lsp::DocumentSymbolResponse::Flat(flat))) => {
|
||||
for s in flat {
|
||||
let r = range(s.location.range);
|
||||
symbols.push((s.name, s.kind, None, r, r));
|
||||
}
|
||||
}
|
||||
_ => return Vec::new(),
|
||||
}
|
||||
let contains =
|
||||
|outer: &ide_core::Range, inner: &ide_core::Range| outer.start <= inner.start && inner.end <= outer.end;
|
||||
let mut items: Vec<HierarchyItem> = Vec::new();
|
||||
for call in calls {
|
||||
// The innermost function or method; a package-level declaration otherwise.
|
||||
let callable = |k: lsp::SymbolKind| matches!(k, lsp::SymbolKind::FUNCTION | lsp::SymbolKind::METHOD);
|
||||
let innermost = |pick: &dyn Fn(lsp::SymbolKind) -> bool| {
|
||||
symbols
|
||||
.iter()
|
||||
.filter(|(_, kind, _, r, _)| pick(*kind) && contains(r, call))
|
||||
.min_by_key(|(_, _, _, r, _)| (r.end.line - r.start.line, r.end.col.saturating_sub(r.start.col)))
|
||||
};
|
||||
let Some((name, kind, detail, r, selection)) = innermost(&callable).or_else(|| innermost(&|_| true)) else {
|
||||
continue;
|
||||
};
|
||||
match items.iter_mut().find(|i| i.range == *r && i.name == *name) {
|
||||
Some(item) => item.from_ranges.push(*call),
|
||||
None => items.push(HierarchyItem {
|
||||
name: name.clone(),
|
||||
kind: crate::convert::symbol_kind(*kind),
|
||||
detail: detail.clone(),
|
||||
path: path.to_path_buf().into(),
|
||||
uri: None,
|
||||
range: *r,
|
||||
selection_range: *selection,
|
||||
from_ranges: vec![*call],
|
||||
}),
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
/// Opens `files` on `t` (read from disk) unless the user has them open;
|
||||
/// they are closed when the returned guard drops. Files too large or not
|
||||
/// readable as text are left out and flagged on the guard.
|
||||
|
||||
@@ -2930,6 +2930,165 @@ async fn references_open_unopened_files_that_mention_the_name() {
|
||||
assert_eq!(seen.lock().len(), 2);
|
||||
}
|
||||
|
||||
/// A Go project: the module at the root, `tools` replacing it with `../` and `other` requiring a
|
||||
/// published copy. The mock answers a definition in `tools` like gopls in that module's view.
|
||||
fn go_modules_harness() -> (Harness, Arc<MockState>, PathBuf, PathBuf, PathBuf, PathBuf) {
|
||||
let h = harness_for("go", "go", &[("gopls", incremental_caps())], &[]);
|
||||
let s = h.launcher.state("gopls");
|
||||
let write = |rel: &str, text: &str| {
|
||||
let path = h.root.join(rel);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, text).unwrap();
|
||||
path
|
||||
};
|
||||
write("go.mod", "module example.com/app\n");
|
||||
write("tools/go.mod", "module example.com/tools\n\nreplace example.com/app => ../\n");
|
||||
write("other/go.mod", "module example.com/other\n\nrequire example.com/app v1.0.0\n");
|
||||
let same = write("same.go", "package app\nvar x = New()\n");
|
||||
// Line 1 uses the declaration; line 2 is a local `New` of the tools module.
|
||||
let tools = write("tools/gen.go", "package tools\nvar a = app.New()\nfunc New() {}\n");
|
||||
write("other/o.go", "package other\nvar b = app.New()\n");
|
||||
// Built for plan9 only: outside gopls' default view of the root module.
|
||||
let plan9 = write("boot_plan9.go", "package app\nfunc boot() { New() }\n");
|
||||
let decl = h.root.join("a.go");
|
||||
let candidates = vec![same.clone(), tools.clone(), h.root.join("other/o.go"), plan9.clone()];
|
||||
h.mgr.set_candidates_hook(Box::new(move |_, _| ide_lsp::CandidateFiles {
|
||||
files: candidates.clone(),
|
||||
limit_hit: false,
|
||||
}));
|
||||
let at = |line: u32, col: u32| json!({ "start": { "line": line, "character": col }, "end": { "line": line, "character": col + 3 } });
|
||||
let (decl_uri, same_uri, tools_uri, plan9_uri) = (uri_of(&decl), uri_of(&same), uri_of(&tools), uri_of(&plan9));
|
||||
let span = |line: u32, from: u32, to: u32| json!({ "start": { "line": line, "character": from }, "end": { "line": line, "character": to } });
|
||||
*s.responder.lock() = Some(Arc::new(move |_, method, params| {
|
||||
let uri = params["textDocument"]["uri"].as_str().unwrap_or_default().to_owned();
|
||||
let line = params["position"]["line"].as_u64().unwrap_or(0);
|
||||
match method {
|
||||
// The view of the root module: the declaration and its usage in same.go.
|
||||
"textDocument/references" => Some(json!([
|
||||
{ "uri": decl_uri, "range": at(0, 5) },
|
||||
{ "uri": same_uri, "range": at(1, 8) },
|
||||
])),
|
||||
"textDocument/rename" => Some(json!({ "changes": {
|
||||
decl_uri.clone(): [{ "range": at(0, 5), "newText": "Make" }],
|
||||
same_uri.clone(): [{ "range": at(1, 8), "newText": "Make" }],
|
||||
} })),
|
||||
"textDocument/definition" if uri == decl_uri => Some(json!({ "uri": decl_uri, "range": at(0, 5) })),
|
||||
"textDocument/definition" if uri == tools_uri && line == 1 => {
|
||||
Some(json!({ "uri": decl_uri, "range": at(0, 5) }))
|
||||
}
|
||||
"textDocument/definition" if uri == tools_uri => Some(json!({ "uri": tools_uri, "range": at(2, 5) })),
|
||||
"textDocument/definition" if uri == plan9_uri => Some(json!({ "uri": decl_uri, "range": at(0, 5) })),
|
||||
"textDocument/documentSymbol" if uri == tools_uri => Some(json!([
|
||||
{ "name": "a", "kind": 13, "range": span(1, 0, 17), "selectionRange": span(1, 4, 5) },
|
||||
{ "name": "New", "kind": 12, "range": span(2, 0, 13), "selectionRange": span(2, 5, 8) },
|
||||
])),
|
||||
"textDocument/documentSymbol" if uri == plan9_uri => Some(json!([
|
||||
{ "name": "boot", "kind": 12, "range": span(1, 0, 20), "selectionRange": span(1, 5, 9) },
|
||||
])),
|
||||
"textDocument/prepareCallHierarchy" => Some(
|
||||
json!([{ "name": "New", "kind": 12, "uri": decl_uri, "range": span(0, 0, 13), "selectionRange": at(0, 5) }]),
|
||||
),
|
||||
"callHierarchy/incomingCalls" => Some(json!([{
|
||||
"from": { "name": "x", "kind": 13, "uri": same_uri, "range": span(1, 0, 13), "selectionRange": span(1, 4, 5) },
|
||||
"fromRanges": [at(1, 8)],
|
||||
}])),
|
||||
_ => None,
|
||||
}
|
||||
}));
|
||||
(h, s, decl, same, tools, plan9)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn go_searches_resolve_other_modules_that_replace_the_declarations_module() {
|
||||
let (h, s, decl, same, tools, plan9) = go_modules_harness();
|
||||
let (doc, _) = h.open("a.go", "func New() {}\n");
|
||||
h.wait_status("gopls", ServerStatus::Ready).await;
|
||||
eventually(|| s.docs.lock().len() == 1).await;
|
||||
|
||||
let partials = Arc::new(Mutex::new(Vec::new()));
|
||||
let seen = partials.clone();
|
||||
let partial = move |r: ide_lsp::types::References| seen.lock().push(r.locations.len());
|
||||
let refs = h
|
||||
.mgr
|
||||
.references_streaming(doc, Pos::new(0, 5), true, true, true, Default::default(), Some(&partial))
|
||||
.await
|
||||
.unwrap();
|
||||
let mut found: Vec<(PathBuf, u32)> =
|
||||
refs.locations.iter().map(|l| (l.path.to_path_buf(), l.range.start.line)).collect();
|
||||
found.sort();
|
||||
let mut want = vec![(decl.clone(), 0), (same.clone(), 1), (tools.clone(), 1), (plan9.clone(), 1)];
|
||||
want.sort();
|
||||
assert_eq!(found, want);
|
||||
// Those in gopls' reach arrive first.
|
||||
assert_eq!(*partials.lock(), [2]);
|
||||
// Only the module replacing the declaration's module was opened, never the root module's file.
|
||||
let opened = uris(&s.notified("textDocument/didOpen"));
|
||||
assert!(opened.contains(&uri_of(&tools)));
|
||||
assert!(opened.contains(&uri_of(&plan9)));
|
||||
assert!(!opened.contains(&uri_of(&same)));
|
||||
assert!(!opened.iter().any(|u| u.ends_with("other/o.go")));
|
||||
assert_eq!(s.requested("textDocument/references").len(), 1);
|
||||
|
||||
let renamed = h.mgr.rename(doc, Pos::new(0, 5), "Make".into(), Default::default()).await.unwrap();
|
||||
let mut edited: Vec<(PathBuf, Vec<u32>)> = renamed
|
||||
.edit
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|f| match f {
|
||||
ide_core::FileEdit::Edit { path, edits, .. } => {
|
||||
Some((path.to_path_buf(), edits.iter().map(|e| e.range.start.line).collect()))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
edited.sort();
|
||||
let mut want = vec![(decl, vec![0]), (same, vec![1]), (tools, vec![1]), (plan9, vec![1])];
|
||||
want.sort();
|
||||
assert_eq!(edited, want);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn go_incoming_calls_include_callers_beyond_the_search() {
|
||||
let (h, s, _, same, tools, plan9) = go_modules_harness();
|
||||
let (doc, _) = h.open("a.go", "func New() {}\n");
|
||||
h.wait_status("gopls", ServerStatus::Ready).await;
|
||||
eventually(|| s.docs.lock().len() == 1).await;
|
||||
|
||||
let calls =
|
||||
h.mgr.call_hierarchy(doc, Pos::new(0, 5), HierarchyDirection::Incoming, Default::default()).await.unwrap();
|
||||
let mut callers: Vec<(PathBuf, String, Vec<u32>)> = calls
|
||||
.items
|
||||
.iter()
|
||||
.map(|i| (i.path.to_path_buf(), i.name.clone(), i.from_ranges.iter().map(|r| r.start.line).collect()))
|
||||
.collect();
|
||||
callers.sort();
|
||||
let mut want = vec![
|
||||
(same, "x".to_owned(), vec![1]),
|
||||
(tools, "a".to_owned(), vec![1]),
|
||||
(plan9.clone(), "boot".to_owned(), vec![1]),
|
||||
];
|
||||
want.sort();
|
||||
assert_eq!(callers, want);
|
||||
let boot = calls.items.iter().find(|i| i.name == "boot").unwrap();
|
||||
assert_eq!((boot.selection_range.start, boot.from_ranges[0].start), (Pos::new(1, 5), Pos::new(1, 14)));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn go_searches_open_nothing_when_no_other_module_uses_the_declaration() {
|
||||
let (h, s, _, same, _, plan9) = go_modules_harness();
|
||||
std::fs::write(h.root.join("tools/go.mod"), "module example.com/tools\n").unwrap();
|
||||
std::fs::remove_file(&plan9).unwrap();
|
||||
let (doc, _) = h.open("a.go", "func New() {}\n");
|
||||
h.wait_status("gopls", ServerStatus::Ready).await;
|
||||
eventually(|| s.docs.lock().len() == 1).await;
|
||||
|
||||
let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap();
|
||||
assert_eq!(refs.locations.len(), 2);
|
||||
assert_eq!(uris(&s.notified("textDocument/didOpen")).len(), 1, "only a.go");
|
||||
assert!(s.requested("textDocument/definition").len() <= 1);
|
||||
assert!(!uris(&s.notified("textDocument/didOpen")).contains(&uri_of(&same)));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn references_never_reopen_or_close_the_users_files() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
|
||||
@@ -23,38 +23,61 @@ fn wait_exit(events: &Receiver<IdeEvent>, id: RunId, timeout: Duration) -> Optio
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
let left = deadline.saturating_duration_since(Instant::now());
|
||||
match events.recv_timeout(left) {
|
||||
Ok(IdeEvent::RunExited { run, code }) if run == id => return code,
|
||||
Ok(_) => {}
|
||||
Err(_) => return None,
|
||||
match events.recv_timeout(left).expect("RunExited event") {
|
||||
IdeEvent::RunExited { run, code } if run == id => return code,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for(what: &str, mut ready: impl FnMut() -> bool) {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while !ready() {
|
||||
assert!(Instant::now() < deadline, "timed out waiting for {what}");
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_still_running(events: &Receiver<IdeEvent>, timeout: Duration) {
|
||||
let event = events.recv_timeout(timeout);
|
||||
assert!(
|
||||
matches!(event, Err(mpsc::RecvTimeoutError::Timeout)),
|
||||
"run exited before shutdown was released: {event:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stopped_run_keeps_its_port_until_the_group_is_gone() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (sink, events) = channel_sink();
|
||||
let pm = ProcessManager::new();
|
||||
// The `mvn`-starts-a-JVM shape: the direct child dies on SIGINT while the process it
|
||||
// started ignores it and keeps its socket open for a moment.
|
||||
// started ignores it and keeps its socket open until the test releases it.
|
||||
let script = tmp.path().join("hold.py");
|
||||
std::fs::write(
|
||||
&script,
|
||||
"import signal, socket, sys, time\n\
|
||||
"import pathlib, signal, socket, sys, time\n\
|
||||
signal.signal(signal.SIGINT, signal.SIG_IGN)\n\
|
||||
s = socket.socket(); s.bind(('127.0.0.1', 0)); s.listen()\n\
|
||||
open(sys.argv[1], 'w').write(str(s.getsockname()[1]))\n\
|
||||
time.sleep(2)\n",
|
||||
while not pathlib.Path(sys.argv[2]).exists(): time.sleep(0.01)\n",
|
||||
)
|
||||
.unwrap();
|
||||
let port_file = tmp.path().join("port");
|
||||
let release_file = tmp.path().join("release");
|
||||
let config = RunConfig {
|
||||
id: "boot".into(),
|
||||
name: "boot".into(),
|
||||
kind: "shell".into(),
|
||||
command: "/bin/sh".into(),
|
||||
args: vec!["-c".into(), format!("python3 {} {} & sleep 60", script.display(), port_file.display())],
|
||||
args: vec![
|
||||
"-c".into(),
|
||||
"python3 \"$1\" \"$2\" \"$3\" & sleep 60".into(),
|
||||
"sh".into(),
|
||||
script.display().to_string(),
|
||||
port_file.display().to_string(),
|
||||
release_file.display().to_string(),
|
||||
],
|
||||
cwd: None,
|
||||
env: BTreeMap::new(),
|
||||
detected: false,
|
||||
@@ -64,18 +87,24 @@ fn a_stopped_run_keeps_its_port_until_the_group_is_gone() {
|
||||
extras: Default::default(),
|
||||
};
|
||||
let info = pm.start(tmp.path(), &config, &StartOptions::default(), sink, Box::new(|_| {})).expect("start");
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while std::fs::read_to_string(&port_file).is_err() && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
wait_for("the listening port", || {
|
||||
std::fs::read_to_string(&port_file).ok().is_some_and(|text| text.trim().parse::<u16>().is_ok())
|
||||
});
|
||||
let port: u16 = std::fs::read_to_string(&port_file).expect("port").trim().parse().unwrap();
|
||||
|
||||
let stopped = Instant::now();
|
||||
pm.stop(info.id).unwrap();
|
||||
let pid = info.pid.expect("child pid") as libc::pid_t;
|
||||
wait_for("the direct child to exit", || unsafe { libc::kill(pid, 0) != 0 });
|
||||
let exit = events.recv_timeout(Duration::from_secs(1));
|
||||
let port_held = std::net::TcpStream::connect(("127.0.0.1", port)).is_ok();
|
||||
std::fs::write(&release_file, "").unwrap();
|
||||
assert!(
|
||||
matches!(exit, Err(mpsc::RecvTimeoutError::Timeout)),
|
||||
"run exited while its descendant held the port: {exit:?}"
|
||||
);
|
||||
assert!(port_held, "the descendant still holds its port");
|
||||
let code = wait_exit(&events, info.id, Duration::from_secs(20));
|
||||
let elapsed = stopped.elapsed();
|
||||
assert!(code.is_some(), "the run reports its exit");
|
||||
assert!(elapsed >= Duration::from_millis(1500), "exit came {elapsed:?} after Stop, before the port was free");
|
||||
assert!(
|
||||
std::net::TcpStream::connect(("127.0.0.1", port)).is_err(),
|
||||
"the port is still bound when the run reports finished"
|
||||
@@ -87,15 +116,16 @@ fn a_first_stop_does_not_kill_a_program_that_is_still_shutting_down() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (sink, events) = channel_sink();
|
||||
let pm = ProcessManager::new();
|
||||
// Ignores SIGINT, then exits by itself after 2 s, like a slow Spring Boot shutdown: a first
|
||||
// Stop waits for it instead of killing it (exit code 137).
|
||||
// Ignores SIGINT until stdin releases the shutdown, like a slow Spring Boot shutdown.
|
||||
// A first Stop must wait even past the grace period instead of killing it (exit code 137).
|
||||
let script = tmp.path().join("slow.py");
|
||||
let ready_file = tmp.path().join("ready");
|
||||
std::fs::write(
|
||||
&script,
|
||||
"import signal, sys, time\n\
|
||||
"import pathlib, signal, sys\n\
|
||||
signal.signal(signal.SIGINT, signal.SIG_IGN)\n\
|
||||
print('ready', flush=True)\n\
|
||||
time.sleep(2)\n",
|
||||
pathlib.Path(sys.argv[1]).touch()\n\
|
||||
sys.stdin.readline()\n",
|
||||
)
|
||||
.unwrap();
|
||||
let config = RunConfig {
|
||||
@@ -103,7 +133,7 @@ fn a_first_stop_does_not_kill_a_program_that_is_still_shutting_down() {
|
||||
name: "slow".into(),
|
||||
kind: "shell".into(),
|
||||
command: "python3".into(),
|
||||
args: vec![script.display().to_string()],
|
||||
args: vec![script.display().to_string(), ready_file.display().to_string()],
|
||||
cwd: None,
|
||||
env: BTreeMap::new(),
|
||||
detected: false,
|
||||
@@ -113,12 +143,12 @@ fn a_first_stop_does_not_kill_a_program_that_is_still_shutting_down() {
|
||||
extras: Default::default(),
|
||||
};
|
||||
let info = pm.start(tmp.path(), &config, &StartOptions::default(), sink, Box::new(|_| {})).expect("start");
|
||||
std::thread::sleep(Duration::from_millis(500)); // let the program install its handler
|
||||
let start = Instant::now();
|
||||
pm.stop(info.id).unwrap();
|
||||
wait_for("the SIGINT handler", || ready_file.exists());
|
||||
pm.stop_with_grace(info.id, Duration::from_millis(100)).unwrap();
|
||||
assert_still_running(&events, Duration::from_secs(1));
|
||||
pm.write_stdin(info.id, "finish shutdown\n").unwrap();
|
||||
let code = wait_exit(&events, info.id, Duration::from_secs(10));
|
||||
assert_eq!(code, Some(0), "the program exited by itself, not by SIGKILL");
|
||||
assert!(start.elapsed() >= Duration::from_millis(1500), "stop waited for the shutdown, not a timer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -127,11 +157,13 @@ fn a_second_stop_kills_a_program_that_ignores_the_first() {
|
||||
let (sink, events) = channel_sink();
|
||||
let pm = ProcessManager::new();
|
||||
let script = tmp.path().join("stuck.py");
|
||||
let ready_file = tmp.path().join("ready");
|
||||
std::fs::write(
|
||||
&script,
|
||||
"import signal, time\n\
|
||||
"import pathlib, signal, sys\n\
|
||||
signal.signal(signal.SIGINT, signal.SIG_IGN)\n\
|
||||
time.sleep(60)\n",
|
||||
pathlib.Path(sys.argv[1]).touch()\n\
|
||||
sys.stdin.readline()\n",
|
||||
)
|
||||
.unwrap();
|
||||
let config = RunConfig {
|
||||
@@ -139,7 +171,7 @@ fn a_second_stop_kills_a_program_that_ignores_the_first() {
|
||||
name: "stuck".into(),
|
||||
kind: "shell".into(),
|
||||
command: "python3".into(),
|
||||
args: vec![script.display().to_string()],
|
||||
args: vec![script.display().to_string(), ready_file.display().to_string()],
|
||||
cwd: None,
|
||||
env: BTreeMap::new(),
|
||||
detected: false,
|
||||
@@ -149,9 +181,9 @@ fn a_second_stop_kills_a_program_that_ignores_the_first() {
|
||||
extras: Default::default(),
|
||||
};
|
||||
let info = pm.start(tmp.path(), &config, &StartOptions::default(), sink, Box::new(|_| {})).expect("start");
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
wait_for("the SIGINT handler", || ready_file.exists());
|
||||
pm.stop(info.id).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
assert_still_running(&events, Duration::from_millis(200));
|
||||
pm.stop(info.id).unwrap();
|
||||
let code = wait_exit(&events, info.id, Duration::from_secs(10));
|
||||
assert_eq!(code, Some(137), "the second Stop SIGKILLs");
|
||||
|
||||
@@ -15,6 +15,11 @@ pub(crate) struct Cache {
|
||||
repo: gix::ThreadSafeRepository,
|
||||
files: [PathBuf; 4],
|
||||
stamp: Vec<Option<(SystemTime, u64)>>,
|
||||
/// `objects/pack` directory and its modification time. An external
|
||||
/// `git fetch`/`pull`/`repack` adds packs without touching the `files`
|
||||
/// above — watching the directory catches them (issue #1919).
|
||||
pack_dir: PathBuf,
|
||||
pack_stamp: Option<SystemTime>,
|
||||
}
|
||||
|
||||
fn cache_files(git_dir: &Path, common_dir: &Path) -> [PathBuf; 4] {
|
||||
@@ -36,6 +41,12 @@ fn stamp(files: &[PathBuf]) -> Vec<Option<(SystemTime, u64)>> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The pack directory's modification time: adding or removing a pack file
|
||||
/// updates it. `None` when the directory can't be read.
|
||||
fn pack_stamp(pack_dir: &Path) -> Option<SystemTime> {
|
||||
std::fs::metadata(pack_dir).ok()?.modified().ok()
|
||||
}
|
||||
|
||||
pub(crate) fn discover(path: &Path) -> IdeResult<Option<Repo>> {
|
||||
let start = if path.is_file() { path.parent().unwrap_or(path) } else { path };
|
||||
if let Err(e) = gix::discover::upwards(start) {
|
||||
@@ -57,7 +68,8 @@ pub(crate) fn discover(path: &Path) -> IdeResult<Option<Repo>> {
|
||||
let git_dir = absolute(repo.git_dir())?;
|
||||
let common_dir = absolute(repo.common_dir())?;
|
||||
let files = cache_files(&git_dir, &common_dir);
|
||||
let cache = Cache { stamp: stamp(&files), files, repo: ts };
|
||||
let pack_dir = common_dir.join("objects").join("pack");
|
||||
let cache = Cache { stamp: stamp(&files), files, repo: ts, pack_stamp: pack_stamp(&pack_dir), pack_dir };
|
||||
Ok(Some(Repo {
|
||||
execution_allowed: std::sync::atomic::AtomicBool::new(true),
|
||||
root,
|
||||
@@ -96,11 +108,13 @@ impl Repo {
|
||||
}
|
||||
let mut cache = self.inner.lock().unwrap_or_else(|p| p.into_inner());
|
||||
let now = stamp(&cache.files);
|
||||
if now != cache.stamp {
|
||||
let pack_now = pack_stamp(&cache.pack_dir);
|
||||
if now != cache.stamp || pack_now != cache.pack_stamp {
|
||||
match gix::ThreadSafeRepository::open(&self.git_dir) {
|
||||
Ok(fresh) => {
|
||||
cache.repo = fresh;
|
||||
cache.stamp = now;
|
||||
cache.pack_stamp = pack_now;
|
||||
}
|
||||
Err(e) => tracing::warn!("reopening repository failed: {e}"),
|
||||
}
|
||||
|
||||
@@ -1366,6 +1366,30 @@ fn status_reflects_external_changes_without_reopen() {
|
||||
assert_eq!(repo.branch_info().unwrap().head.as_deref(), Some("x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branches_in_newly_written_packs_stay_visible() {
|
||||
let fx = Fixture::new();
|
||||
fx.write("a", "1\n");
|
||||
fx.commit_all("a");
|
||||
let repo = fx.repo();
|
||||
assert_eq!(repo.branches().unwrap().len(), 1);
|
||||
|
||||
// An external pull: objects move into a pack the IDE has never seen, then a
|
||||
// new branch points at a commit living only in that pack.
|
||||
fx.write("a", "2\n");
|
||||
fx.commit_all("b");
|
||||
fx.git(&["repack", "-adq"]);
|
||||
fx.git(&["checkout", "-q", "-b", "uat"]);
|
||||
let uat = fx.git(&["rev-parse", "uat"]);
|
||||
assert!(std::fs::read_dir(fx.path(".git/objects/pack")).unwrap().count() >= 2);
|
||||
|
||||
let names: Vec<String> = repo.branches().unwrap().into_iter().map(|b| b.name).collect();
|
||||
assert!(names.contains(&"uat".to_string()), "branches: {names:?}, want uat at {uat}");
|
||||
|
||||
// Status peels HEAD for the tree-index diff: the same failure drops it too.
|
||||
assert!(repo.status().is_ok(), "status failed: {:?}", repo.status().unwrap_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_worktree_and_submodule() {
|
||||
let fx = Fixture::new();
|
||||
|
||||
@@ -117,6 +117,13 @@ impl Watcher {
|
||||
self.polling.as_deref()
|
||||
}
|
||||
|
||||
/// A watch failure past startup (the OS watch limit ran out mid-tree):
|
||||
/// directories past the limit never sync (issue #1915). `None` while every
|
||||
/// directory has a live watch.
|
||||
pub fn unwatched_reason(&self) -> Option<String> {
|
||||
self.inner.state.exhausted.lock().clone()
|
||||
}
|
||||
|
||||
/// Replaces the exclude globs (Settings › Project Structure / File Types
|
||||
/// changed): later events below newly excluded folders are dropped. In
|
||||
/// per-directory mode, folders no longer excluded are watched once they change.
|
||||
@@ -163,6 +170,7 @@ impl Watcher {
|
||||
queue: Mutex::default(),
|
||||
wake: Condvar::new(),
|
||||
stop: AtomicBool::new(false),
|
||||
exhausted: Mutex::new(None),
|
||||
});
|
||||
let weak: Weak<DirState> = Arc::downgrade(&state);
|
||||
let watcher = W::new(
|
||||
@@ -234,8 +242,9 @@ const MAX_WATCHES_HINT: &str = "the inotify watch limit is exhausted (fs.inotify
|
||||
/// instance per watcher (`EMFILE`, os error 24) or one watch per directory
|
||||
/// (`ENOSPC`, mapped by notify to [`ErrorKind::MaxFilesWatch`]). A bare OS
|
||||
/// error sends users off to search engines otherwise (issue #788).
|
||||
fn watcher_error(what: &str, error: notify::Error) -> IdeError {
|
||||
let hint = match error.kind {
|
||||
/// The fix hint when an OS watch limit is hit, or `None` for other errors.
|
||||
fn watch_limit_hint(error: ¬ify::Error) -> Option<&'static str> {
|
||||
match error.kind {
|
||||
ErrorKind::MaxFilesWatch => Some(MAX_WATCHES_HINT),
|
||||
ErrorKind::Io(ref e) if cfg!(target_os = "linux") && e.raw_os_error() == Some(24) => Some(
|
||||
"the inotify instance limit is exhausted (fs.inotify.max_user_instances); raise it, e.g. \
|
||||
@@ -243,8 +252,11 @@ fn watcher_error(what: &str, error: notify::Error) -> IdeError {
|
||||
),
|
||||
ErrorKind::Io(ref e) if cfg!(target_os = "linux") && e.raw_os_error() == Some(28) => Some(MAX_WATCHES_HINT),
|
||||
_ => None,
|
||||
};
|
||||
match hint {
|
||||
}
|
||||
}
|
||||
|
||||
fn watcher_error(what: &str, error: notify::Error) -> IdeError {
|
||||
match watch_limit_hint(&error) {
|
||||
Some(hint) => io_error(format!("{what}: {error}; {hint}")),
|
||||
None => io_error(format!("{what}: {error}")),
|
||||
}
|
||||
@@ -259,6 +271,10 @@ struct DirState {
|
||||
queue: Mutex<Vec<(notify::Event, Instant)>>,
|
||||
wake: Condvar,
|
||||
stop: AtomicBool,
|
||||
/// Set when a `watch()` call reports the OS watch limit (inotify
|
||||
/// `ENOSPC`/`EMFILE`): later directories are never seen, so the project is
|
||||
/// silently stale past that point — surfaced via [`Watcher::unwatched_reason`].
|
||||
exhausted: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
impl DirState {
|
||||
@@ -362,7 +378,15 @@ impl DirState {
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("cannot watch {}: {e}", dir.display());
|
||||
if let Some(hint) = watch_limit_hint(&e) {
|
||||
tracing::warn!("cannot watch {}: {e}", dir.display());
|
||||
// Recorded, not delivered: the watch may fail after start,
|
||||
// on the debounce thread — the UI picks it up via
|
||||
// `unwatched_reason()`.
|
||||
*self.exhausted.lock() = Some(format!("cannot watch {}: {e}; {hint}", dir.display()));
|
||||
} else {
|
||||
tracing::debug!("cannot watch {}: {e}", dir.display());
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -1320,4 +1344,62 @@ mod tests {
|
||||
let other = notify::Error::io(std::io::Error::from_raw_os_error(13));
|
||||
assert!(!watcher_error("cannot start file watcher", other).message.contains("inotify"));
|
||||
}
|
||||
|
||||
/// Watch calls succeed until `budget` is spent, then `ENOSPC` — mirrors
|
||||
/// `fs.inotify.max_user_watches` running out part-way through a tree.
|
||||
struct ExhaustedWatcher {
|
||||
budget: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
impl notify::Watcher for ExhaustedWatcher {
|
||||
fn new<F: notify::EventHandler>(_: F, _: notify::Config) -> notify::Result<Self> {
|
||||
Ok(Self { budget: std::sync::atomic::AtomicUsize::new(3) })
|
||||
}
|
||||
|
||||
fn watch(&mut self, _dir: &Path, _: RecursiveMode) -> notify::Result<()> {
|
||||
if self.budget.fetch_sub(1, Ordering::SeqCst) == 0 {
|
||||
return Err(notify::Error::new(ErrorKind::MaxFilesWatch));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unwatch(&mut self, _: &Path) -> notify::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn kind() -> WatcherKind {
|
||||
WatcherKind::NullWatcher
|
||||
}
|
||||
}
|
||||
|
||||
/// #1915: a watch refused mid-tree must not stay silent — the limit is
|
||||
/// reported through `unwatched_reason` so the UI can warn like the
|
||||
/// polling fallback does.
|
||||
#[test]
|
||||
fn unwatched_reason_reports_the_watch_limit() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
for d in ["a", "b", "c", "d"] {
|
||||
std::fs::create_dir_all(root.join(d)).unwrap();
|
||||
}
|
||||
let filter = EventFilter::new(root.clone(), &[]);
|
||||
let (excludes, rules) = (filter.excludes.clone(), filter.gitignore.clone());
|
||||
let (tx, _rx) = std::sync::mpsc::channel::<FsBatch>();
|
||||
let emit: Emit = Arc::new(move |b| {
|
||||
let _ = tx.send(b.clone());
|
||||
});
|
||||
let inner = Watcher::start_queued::<ExhaustedWatcher>(
|
||||
&root,
|
||||
filter,
|
||||
emit,
|
||||
WatchMode::PerDirectory,
|
||||
notify::Config::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let watcher = Watcher { inner, excludes, rules, polling: None };
|
||||
assert!(watcher.polling().is_none(), "the OS watcher did start");
|
||||
let reason = watcher.unwatched_reason().expect("watch limit must be reported");
|
||||
assert!(reason.contains("max_user_watches"), "{reason}");
|
||||
assert!(reason.contains("cannot watch"), "{reason}");
|
||||
}
|
||||
}
|
||||
+189
-1
@@ -19,6 +19,7 @@ const TASK = {
|
||||
commands: [],
|
||||
usage: null,
|
||||
queue: [],
|
||||
capabilities: { fork: true, rollback: true, steer: true, images: true },
|
||||
};
|
||||
|
||||
const PIXEL = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
||||
@@ -65,7 +66,15 @@ async function openReply(page: Page, text: string, imageData?: string) {
|
||||
error: null,
|
||||
providers: [],
|
||||
tasks: new Map([[task.id, task]]),
|
||||
entries: new Map([[task.id, [{ type: "agent", text }]]]),
|
||||
entries: new Map([
|
||||
[
|
||||
task.id,
|
||||
[
|
||||
{ type: "user", text: "Show Markdown", attachments: [], sentMs: 0, doneMs: 1, steered: false },
|
||||
{ type: "agent", text },
|
||||
],
|
||||
],
|
||||
]),
|
||||
unread: new Set(),
|
||||
docked: null,
|
||||
});
|
||||
@@ -149,3 +158,182 @@ test("loads local and web reply images", async ({ page }) => {
|
||||
.toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
test("copies reply Markdown with the keyboard and keeps code copying separate", async ({ page, context }) => {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
const reply = await openReply(page, REPLY);
|
||||
const copy = page.getByRole("button", { name: "Copy Markdown", exact: true });
|
||||
const actions = copy.locator("..");
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(actions).toHaveCSS("opacity", "0");
|
||||
await copy.focus();
|
||||
await expect(actions).toHaveCSS("opacity", "1");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByRole("button", { name: "Copied", exact: true })).toBeVisible();
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(REPLY);
|
||||
await reply.getByRole("button", { name: "Copy Code", exact: true }).click();
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("const a = 1;");
|
||||
});
|
||||
|
||||
test("reveals message actions on hover without shifting content and quotes into the draft", async ({ page }) => {
|
||||
const reply = await openReply(page, REPLY);
|
||||
const copy = page.getByRole("button", { name: "Copy Markdown", exact: true });
|
||||
const actions = copy.locator("..");
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(actions).toHaveCSS("opacity", "0");
|
||||
const before = await reply.boundingBox();
|
||||
await reply.hover();
|
||||
await expect(actions).toHaveCSS("opacity", "1");
|
||||
expect(await reply.boundingBox()).toEqual(before);
|
||||
await expect(actions.getByRole("button")).toHaveCount(4);
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(actions).toHaveCSS("opacity", "0");
|
||||
const userCopy = page.getByRole("button", { name: "Copy", exact: true });
|
||||
await expect(userCopy.locator("..")).toHaveCSS("opacity", "0");
|
||||
await page.getByText("Show Markdown", { exact: true }).hover();
|
||||
await expect(userCopy.locator("..")).toHaveCSS("opacity", "1");
|
||||
await reply.hover();
|
||||
await page.getByRole("button", { name: "Quote Reply", exact: true }).click();
|
||||
const draft = page.getByRole("textbox", { name: "Message", exact: true });
|
||||
await expect(draft).toBeFocused();
|
||||
await expect(draft).toHaveValue(
|
||||
REPLY.trimEnd()
|
||||
.split("\n")
|
||||
.map((line) => (line ? `> ${line}` : ">"))
|
||||
.join("\n") + "\n\n",
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps chat history readable and selections free of timestamps and status labels", async ({ page, context }) => {
|
||||
await page.setViewportSize({ width: 1800, height: 1000 });
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await openReply(page, "Hi! What would you like to work on?");
|
||||
await page.evaluate(async (task) => {
|
||||
const path = "/src/features/agents/store.ts";
|
||||
const store = await import(path);
|
||||
const user = (text: string) => ({ type: "user", text, attachments: [], sentMs: 1, doneMs: 5001, steered: false });
|
||||
store.useAgents.setState({
|
||||
entries: new Map([
|
||||
[
|
||||
task.id,
|
||||
[
|
||||
user("hi"),
|
||||
{ type: "agent", text: "Hi! What would you like to work on?" },
|
||||
user("im bob"),
|
||||
{ type: "agent", text: "Hi Bob! What are we working on today?" },
|
||||
user("yes"),
|
||||
{ type: "thought", text: "**Requesting task context**" },
|
||||
{ type: "agent", text: "What would you like me to proceed with?" },
|
||||
],
|
||||
],
|
||||
]),
|
||||
});
|
||||
store.openTaskInEditorLocation(task.id);
|
||||
}, TASK);
|
||||
const stream = page.locator("[class*='_stream_']:visible");
|
||||
const replies = stream.locator("[class*='_reply_']");
|
||||
await expect(replies).toHaveCount(3);
|
||||
const geometry = await stream.evaluate((root) => {
|
||||
const reply = root.querySelector<HTMLElement>("[class*='_reply_']")!;
|
||||
const bubble = root.querySelector<HTMLElement>("[class*='_user_']")!;
|
||||
const composer = root
|
||||
.closest("[class*='_conversation_']")!
|
||||
.querySelector<HTMLElement>("[class*='_composerArea_']")!;
|
||||
const rr = reply.getBoundingClientRect();
|
||||
const br = bubble.getBoundingClientRect();
|
||||
const cr = composer.getBoundingClientRect();
|
||||
return {
|
||||
reply: rr.width,
|
||||
root: root.clientWidth,
|
||||
bubbleLeft: br.left,
|
||||
replyLeft: rr.left,
|
||||
bubbleRight: br.right,
|
||||
replyRight: rr.right,
|
||||
columnCenter: (rr.left + rr.right) / 2,
|
||||
composerCenter: (cr.left + cr.right) / 2,
|
||||
};
|
||||
});
|
||||
expect(geometry.reply).toBe(900);
|
||||
expect(geometry.reply).toBeLessThan(geometry.root);
|
||||
expect(Math.abs(geometry.bubbleRight - geometry.replyRight)).toBeLessThan(1);
|
||||
expect(Math.abs(geometry.bubbleLeft - geometry.replyLeft)).toBeLessThan(1);
|
||||
expect(Math.abs(geometry.columnCenter - geometry.composerCenter)).toBeLessThan(1);
|
||||
await stream.getByText("hi", { exact: true }).hover();
|
||||
await expect(stream.locator("[class*='_messageMeta_']").first()).toHaveCSS("opacity", "1");
|
||||
const selected = await stream.evaluate((root) => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(root);
|
||||
const selection = window.getSelection()!;
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
return selection.toString();
|
||||
});
|
||||
expect(selected).toContain("hi");
|
||||
expect(selected).toContain("Hi Bob!");
|
||||
expect(selected).toContain("What would you like me to proceed with?");
|
||||
expect(selected).not.toMatch(/AM|PM|5s|Reasoning|Requesting task context/);
|
||||
await page.keyboard.press("ControlOrMeta+c");
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(selected);
|
||||
await stream.locator("[class*='_thought_'] > summary").click();
|
||||
await expect(stream.getByText("Requesting task context", { exact: true })).toBeVisible();
|
||||
await expect(stream.locator("[class*='_thoughtBody_'] strong")).toHaveText("Requesting task context");
|
||||
await page.evaluate(async (id) => {
|
||||
const path = "/src/features/agents/store.ts";
|
||||
const store = await import(path);
|
||||
store.dockTask(id);
|
||||
}, TASK.id);
|
||||
await expect(stream).toBeVisible();
|
||||
const narrow = await stream.evaluate((root) => ({
|
||||
width: root.clientWidth,
|
||||
content: root.scrollWidth,
|
||||
reply: root.querySelector<HTMLElement>("[class*='_reply_']")!.getBoundingClientRect().width,
|
||||
padding:
|
||||
Number.parseFloat(getComputedStyle(root).paddingLeft) + Number.parseFloat(getComputedStyle(root).paddingRight),
|
||||
}));
|
||||
expect(narrow.width).toBeGreaterThan(0);
|
||||
expect(narrow.content).toBeLessThanOrEqual(narrow.width);
|
||||
expect(Math.abs(narrow.reply - (narrow.width - narrow.padding))).toBeLessThan(1);
|
||||
});
|
||||
|
||||
test("keeps hover actions reachable when the pointer leaves the message body", async ({ page, context }) => {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
const reply = await openReply(page, "A short answer.");
|
||||
const copy = page.getByRole("button", { name: "Copy Markdown", exact: true });
|
||||
await reply.hover();
|
||||
const box = await copy.boundingBox();
|
||||
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2, { steps: 20 });
|
||||
await expect(copy.locator("..")).toHaveCSS("opacity", "1");
|
||||
await copy.click();
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("A short answer.");
|
||||
});
|
||||
|
||||
test("selects through code blocks and tables while keeping copy controls outside the code", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
const tree = "rust/\n├── Cargo.toml\n└── src/\n └── main.rs";
|
||||
const flow = "内置文本 SAMPLE\n ↓ parser::parse\nVec<Item>\n ↓ Inventory::add\n终端输出";
|
||||
const text = `项目结构:\n\n\`\`\`text\n${tree}\n\`\`\`\n\n| 模块 | 职责 |\n| --- | --- |\n| main.rs | 程序入口 |\n\n数据流是:\n\n\`\`\`text\n${flow}\n\`\`\``;
|
||||
const reply = await openReply(page, text);
|
||||
await expect(reply.getByRole("button", { name: "Copy Code", exact: true })).toHaveCount(2);
|
||||
const selected = await reply.evaluate((root) => {
|
||||
const codes = [...root.querySelectorAll("pre > code")];
|
||||
if (codes.some((code) => code.parentElement!.querySelector("button"))) throw new Error("Control in code selection");
|
||||
const start = root.querySelector("p")!.firstChild!;
|
||||
const end = codes.at(-1)!.lastChild!;
|
||||
window.getSelection()!.setBaseAndExtent(start, 0, end, end.textContent!.length);
|
||||
return window.getSelection()!.toString();
|
||||
});
|
||||
expect(selected).toContain(tree);
|
||||
expect(selected).toContain("程序入口");
|
||||
expect(selected).toContain(flow);
|
||||
await page.keyboard.press("ControlOrMeta+c");
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(selected);
|
||||
await page.evaluate(() => window.getSelection()!.removeAllRanges());
|
||||
const copies = reply.getByRole("button", { name: "Copy Code", exact: true });
|
||||
await copies.first().click();
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(tree);
|
||||
await copies.last().click();
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(flow);
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import type { AgentEntry, AgentTask } from "../src/bindings";
|
||||
|
||||
const task: AgentTask = {
|
||||
id: "long-chat",
|
||||
title: "Long conversation",
|
||||
status: "ready",
|
||||
isolation: "local",
|
||||
provider: "codex",
|
||||
model: null,
|
||||
cwd: "/dev/acme-api",
|
||||
branch: null,
|
||||
base: null,
|
||||
baseCommit: null,
|
||||
error: null,
|
||||
createdMs: 0,
|
||||
updatedMs: 0,
|
||||
config: [],
|
||||
commands: [],
|
||||
usage: null,
|
||||
queue: [],
|
||||
};
|
||||
|
||||
const entries: AgentEntry[] = Array.from({ length: 80 }, (_, turn): AgentEntry[] => [
|
||||
{ type: "user", text: `Request ${turn}`, attachments: [], steered: false },
|
||||
...["read", "execute", "edit"].map((kind): AgentEntry => ({
|
||||
type: "tool",
|
||||
id: `${turn}-${kind}`,
|
||||
kind,
|
||||
title: `${kind} src/module-${turn}.ts`,
|
||||
status: "completed",
|
||||
locations: [],
|
||||
output: Array.from({ length: 80 }, (_, line) => `output ${turn}:${line}`).join("\n"),
|
||||
diffs: [],
|
||||
})),
|
||||
{ type: "thought", text: `Checking module ${turn}.\n\n${"Details of the investigation. ".repeat(30)}` },
|
||||
{
|
||||
type: "agent",
|
||||
text: `Reply ${turn}\n\n- Checked the module.\n- Updated the implementation.\n\n\`\`\`text\nmodule-${turn}\n src/\n tests/\n\`\`\``,
|
||||
},
|
||||
]).flat();
|
||||
|
||||
async function openHistory(page: Page) {
|
||||
await page.goto("/");
|
||||
await page.getByRole("tree", { name: "Project" }).waitFor();
|
||||
await page.evaluate(
|
||||
async ({ task, entries }) => {
|
||||
const path = "/src/features/agents/store.ts";
|
||||
const store = await import(path);
|
||||
store.useAgents.setState({
|
||||
loaded: true,
|
||||
error: null,
|
||||
providers: [],
|
||||
tasks: new Map([[task.id, task]]),
|
||||
entries: new Map([[task.id, entries]]),
|
||||
unread: new Set(),
|
||||
docked: null,
|
||||
});
|
||||
store.openTaskInEditorLocation(task.id);
|
||||
},
|
||||
{ task, entries },
|
||||
);
|
||||
const stream = page.locator("[class*='_stream_']:visible");
|
||||
await expect(stream.locator(":scope > *")).toHaveCount(entries.length);
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
return stream;
|
||||
}
|
||||
|
||||
for (const selected of [false, true]) {
|
||||
test(`keeps long-chat scroll geometry stable while jumping through history (selected: ${selected})`, async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
const stream = await openHistory(page);
|
||||
const result = await stream.evaluate(async (el, selected) => {
|
||||
let text = "";
|
||||
if (selected) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
window.getSelection()!.removeAllRanges();
|
||||
window.getSelection()!.addRange(range);
|
||||
text = window.getSelection()!.toString();
|
||||
}
|
||||
await new Promise(requestAnimationFrame);
|
||||
const initialHeight = el.scrollHeight;
|
||||
const samples: Array<{ height: number; drift: number }> = [];
|
||||
// A thumb can jump into previously unvisited rows in either direction.
|
||||
for (const fraction of [0, 0.75, 0.25, 1, 0.5, 0.1, 0.9, 0]) {
|
||||
el.scrollTop = fraction * (initialHeight - el.clientHeight);
|
||||
const target = el.scrollTop;
|
||||
await new Promise(requestAnimationFrame);
|
||||
await new Promise(requestAnimationFrame);
|
||||
samples.push({ height: el.scrollHeight, drift: Math.abs(el.scrollTop - target) });
|
||||
}
|
||||
return { initialHeight, samples, text, after: window.getSelection()!.toString() };
|
||||
}, selected);
|
||||
expect(result.initialHeight).toBeGreaterThan(10_000);
|
||||
for (const sample of result.samples) {
|
||||
expect(Math.abs(sample.height - result.initialHeight)).toBeLessThanOrEqual(1);
|
||||
expect(sample.drift).toBeLessThanOrEqual(1);
|
||||
}
|
||||
if (selected) {
|
||||
expect(result.text).toContain("Request 0");
|
||||
expect(result.text).toContain("Reply 79");
|
||||
expect(result.after).toBe(result.text);
|
||||
await stream.focus();
|
||||
await page.keyboard.press("ControlOrMeta+c");
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(result.text);
|
||||
await page.evaluate(() => window.getSelection()!.removeAllRanges());
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
async function placeCaret(page: Page, marker: string) {
|
||||
await page.locator(".cm-content").evaluate((element, marker) => {
|
||||
const view = (element as HTMLElement & { cmTile: { view: import("@codemirror/view").EditorView } }).cmTile.view;
|
||||
const anchor = view.state.doc.toString().indexOf(marker) + marker.length;
|
||||
view.dispatch({ selection: { anchor } });
|
||||
view.focus();
|
||||
}, marker);
|
||||
}
|
||||
|
||||
test("Angular templates highlight and close tags in inline and external documents", async ({ workbench: page }) => {
|
||||
await page.evaluate(async () => {
|
||||
const backendPath = "/src/editor/core/backend.ts";
|
||||
const contributionsPath = "/src/workbench/contributions.ts";
|
||||
const { docBackend } = await import(backendPath);
|
||||
const { editorService } = await import(contributionsPath);
|
||||
const files = docBackend().files;
|
||||
files.set("/dev/acme-api/angular.json", "{}");
|
||||
files.set(
|
||||
"/dev/acme-api/app.ts",
|
||||
'@Component({\n template: `<div [title]="name">{{ name }}</div>\n @if (ready) { <p>Ready</p> }\n <r-card\n <section\n <article><\n `\n})\nexport class App {}\n',
|
||||
);
|
||||
files.set("/dev/acme-api/app.html", "<r-card\n<div\n<article><");
|
||||
files.set("/dev/acme-api/plain.ts", "const data = { template: `<r-card/>` };");
|
||||
await editorService().openFile("/dev/acme-api/app.ts");
|
||||
});
|
||||
const inline = page.locator('.cm-editor[data-language="Angular TypeScript"]');
|
||||
await expect(inline).toBeVisible();
|
||||
await expect(inline.locator(".cm-content span").filter({ hasText: /^div$/ }).first()).toBeVisible();
|
||||
await expect(inline.locator(".cm-content span").filter({ hasText: /^@if$/ })).toBeVisible();
|
||||
for (const [marker, text, result] of [
|
||||
["<r-card", "/", "<r-card/>"],
|
||||
["<section", ">", "<section></section>"],
|
||||
["<article><", "/", "<article></article>"],
|
||||
]) {
|
||||
await placeCaret(page, marker!);
|
||||
await page.keyboard.type(text!);
|
||||
await expect(inline.locator(".cm-content")).toContainText(result!);
|
||||
}
|
||||
await page.evaluate(async () => {
|
||||
const path = "/src/workbench/contributions.ts";
|
||||
await (await import(path)).editorService().openFile("/dev/acme-api/app.html");
|
||||
});
|
||||
const external = page.locator('.cm-editor[data-language="Angular Template"]');
|
||||
await expect(external).toBeVisible();
|
||||
for (const [marker, text, result] of [
|
||||
["<r-card", "/", "<r-card/>"],
|
||||
["<div", ">", "<div></div>"],
|
||||
["<article><", "/", "<article></article>"],
|
||||
]) {
|
||||
await placeCaret(page, marker!);
|
||||
await page.keyboard.type(text!);
|
||||
await expect(external.locator(".cm-content")).toContainText(result!);
|
||||
}
|
||||
await page.evaluate(async () => {
|
||||
const path = "/src/workbench/contributions.ts";
|
||||
await (await import(path)).editorService().openFile("/dev/acme-api/plain.ts");
|
||||
});
|
||||
await expect(page.locator(".cm-content span").filter({ hasText: /^r-card$/ })).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { expect, stubDesktop, test } from "./fixtures";
|
||||
|
||||
test("keeps a wide Git graph filled during scrollbar drags and its edges interactive afterwards", async ({
|
||||
workbench: page,
|
||||
}) => {
|
||||
await stubDesktop(page);
|
||||
await page.evaluate(async () => {
|
||||
const root = "/dev/acme-api";
|
||||
const count = 3000;
|
||||
const id = (i: number) => i.toString(16).padStart(40, "0");
|
||||
const commits = Array.from({ length: count }, (_, i) => ({
|
||||
id: id(i),
|
||||
shortId: id(i).slice(-8),
|
||||
parents: i + 18 < count ? [id(i + 18)] : [],
|
||||
refs: i === 0 ? ["HEAD", "main"] : [],
|
||||
author: "Author",
|
||||
email: "[email protected]",
|
||||
timeMs: count - i,
|
||||
summary: `Commit ${i}`,
|
||||
message: `Commit ${i}`,
|
||||
}));
|
||||
const h = window.__ipc.handlers;
|
||||
h.vcs_log = ({ query }) => {
|
||||
const { skip, limit, range } = query as {
|
||||
skip: number;
|
||||
limit: number;
|
||||
range?: string;
|
||||
};
|
||||
return range ? [] : commits.slice(skip, limit ? skip + limit : undefined);
|
||||
};
|
||||
h.vcs_log_refs = () => ({});
|
||||
h.vcs_branches = () => [];
|
||||
h.vcs_branch_info = () => ({
|
||||
head: "main",
|
||||
headCommit: id(0),
|
||||
upstream: null,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
operation: null,
|
||||
});
|
||||
h.vcs_config_get = () => null;
|
||||
h.vcs_commit_files = () => [];
|
||||
h.vcs_log_details_batch = ({ revisions }) =>
|
||||
(revisions as string[]).map((revision) => ({
|
||||
message: `Commit ${parseInt(revision, 16)}`,
|
||||
containingBranches: [],
|
||||
signature: "N",
|
||||
signer: "",
|
||||
key: "",
|
||||
fingerprint: "",
|
||||
}));
|
||||
const storeModule = "/src/features/vcs/store.ts";
|
||||
const { useVcs } = await import(/* @vite-ignore */ storeModule);
|
||||
useVcs.setState({
|
||||
available: true,
|
||||
root,
|
||||
repo: root,
|
||||
roots: [root],
|
||||
branchesLoaded: true,
|
||||
});
|
||||
const contextModule = "/src/platform/context.ts";
|
||||
const { setContext } = await import(/* @vite-ignore */ contextModule);
|
||||
setContext("vcsAvailable", true);
|
||||
const actionsModule = "/src/platform/actions.ts";
|
||||
const { executeAction } = await import(/* @vite-ignore */ actionsModule);
|
||||
await executeAction("vcs.showLog");
|
||||
});
|
||||
const list = page.getByRole("listbox", { name: "Commits" });
|
||||
await expect(list.getByRole("option").first()).toBeVisible();
|
||||
await page.evaluate(async () => {
|
||||
const module = "/src/features/vcs/logStore.ts";
|
||||
const { useGitLog, loadMore } = await import(/* @vite-ignore */ module);
|
||||
while (!useGitLog.getState().done) await loadMore();
|
||||
});
|
||||
|
||||
const box = (await list.boundingBox())!;
|
||||
const x = box.x + box.width - 5;
|
||||
await page.mouse.move(x, box.y + 8);
|
||||
await page.mouse.down();
|
||||
try {
|
||||
for (const fraction of [0.2, 0.7, 0.4, 0.85, 0.3]) {
|
||||
await page.mouse.move(x, box.y + box.height * fraction, { steps: 8 });
|
||||
expect(
|
||||
await list.evaluate((element) => {
|
||||
const top = element.scrollTop;
|
||||
const positions = [...element.querySelectorAll<HTMLElement>('[role="option"]')].map((row) => row.offsetTop);
|
||||
return {
|
||||
scrolling: top > 0,
|
||||
covered: Math.min(...positions) <= top && Math.max(...positions) + 26 >= top + element.clientHeight,
|
||||
};
|
||||
}),
|
||||
).toEqual({ scrolling: true, covered: true });
|
||||
}
|
||||
} finally {
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
const index = await list.evaluate((element) => Math.ceil(element.scrollTop / 26));
|
||||
const row = list.locator(`[id="commit-log-${index}"]`);
|
||||
await row.click();
|
||||
await expect(row).toHaveAttribute("aria-selected", "true");
|
||||
await list.press("ArrowDown");
|
||||
await expect(list.locator(`[id="commit-log-${index + 1}"]`)).toHaveAttribute("aria-selected", "true");
|
||||
// Rows drawn mid-drag get their edge hit targets back once scrolling stops.
|
||||
await expect(row.getByRole("button", { name: "Collapse this fragment" }).first()).toBeAttached();
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
id = "arduino"
|
||||
name = "Arduino"
|
||||
# Sketches are C++ with an implicit `Arduino.h` and generated prototypes; clangd can't compile
|
||||
# them on its own, so they get C++ highlighting without a language server.
|
||||
extensions = ["ino"]
|
||||
line-comment = "//"
|
||||
block-comment = ["/*", "*/"]
|
||||
indent = { tab-size = 2, insert-spaces = true }
|
||||
ui-mode = "C++"
|
||||
tree-sitter = "cpp"
|
||||
@@ -3,6 +3,7 @@ name = "Go Module"
|
||||
filenames = ["go.mod"]
|
||||
line-comment = "//"
|
||||
indent = { tab-size = 4, insert-spaces = false }
|
||||
ui-mode = "GoMod"
|
||||
|
||||
[[language-servers]]
|
||||
name = "gopls"
|
||||
|
||||
@@ -3,6 +3,7 @@ name = "Go Workspace"
|
||||
filenames = ["go.work"]
|
||||
line-comment = "//"
|
||||
indent = { tab-size = 4, insert-spaces = false }
|
||||
ui-mode = "GoMod"
|
||||
|
||||
[[language-servers]]
|
||||
name = "gopls"
|
||||
|
||||
@@ -8,6 +8,10 @@ indent = { tab-size = 2, insert-spaces = true }
|
||||
ui-mode = "TypeScript"
|
||||
tree-sitter = "typescript"
|
||||
|
||||
[[framework-ui-modes]]
|
||||
ui-mode = "Angular TypeScript"
|
||||
root-markers = ["angular.json", "node_modules/@angular/core"]
|
||||
|
||||
[[language-servers]]
|
||||
name = "vtsls"
|
||||
tsserver-project = true
|
||||
|
||||
@@ -26,6 +26,39 @@
|
||||
"typingLarge.mainThreadMsPerKey": 20,
|
||||
"typingLarge.tbtMs": 50,
|
||||
|
||||
"typingAngular.openMs": 200,
|
||||
"typingAngular.p95Ms": 30,
|
||||
"typingAngular.maxMs": 40,
|
||||
"typingAngular.mainThreadMsPerKey": 30,
|
||||
"typingAngular.tbtMs": 50,
|
||||
|
||||
"completion.openMs": 150,
|
||||
"completion.narrowP95Ms": 20,
|
||||
"completion.narrowMissedFramePct": 2,
|
||||
"completion.narrowMainThreadMsPerKey": 35,
|
||||
"completion.arrowP95Ms": 20,
|
||||
"completion.arrowMissedFramePct": 2,
|
||||
"completion.tbtMs": 50,
|
||||
|
||||
"consoleOutput.streamMs": 4600,
|
||||
"consoleOutput.droppedFramesPct": 2,
|
||||
"consoleOutput.keyP95Ms": 20,
|
||||
"consoleOutput.keyMissedFramePct": 10,
|
||||
"consoleOutput.tbtMs": 50,
|
||||
|
||||
"gitLog.showMs": 300,
|
||||
"gitLog.droppedFramesPct": 2,
|
||||
"gitLog.tbtMs": 50,
|
||||
"gitLog.arrowKeyP95Ms": 20,
|
||||
"gitLog.arrowKeyMissedFramePct": 5,
|
||||
"gitLogScrollbar.frameP95Ms": 17.5,
|
||||
"gitLogScrollbar.droppedFramesPct": 2,
|
||||
"gitLogScrollbar.tbtMs": 50,
|
||||
|
||||
"resizePanels.droppedFramesPct": 2,
|
||||
"resizePanels.bottomDroppedFramesPct": 2,
|
||||
"resizePanels.tbtMs": 50,
|
||||
|
||||
"keyRepeat.p95Ms": 20,
|
||||
"keyRepeat.maxMs": 33,
|
||||
"keyRepeat.missedFramePct": 2,
|
||||
|
||||
@@ -110,6 +110,88 @@ const typingAngular: Scenario = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Code completion with a language server's 2,000 items: the first letter opens
|
||||
* the popup (after CodeMirror's 100 ms typing delay), each further letter
|
||||
* narrows it, arrows move the selection.
|
||||
*/
|
||||
const completion: Scenario = {
|
||||
id: "completion",
|
||||
title: "Code completion over 2,000 items",
|
||||
async run(h) {
|
||||
await h.page.evaluate(() =>
|
||||
(window as never as { __openideaPerf: { fakeCompletion(n: number): void } }).__openideaPerf.fakeCompletion(2_000),
|
||||
);
|
||||
try {
|
||||
await h.openFile(`${ROOT}/src/completion/Orders.ts`, tsSource(2_000, "completion"));
|
||||
const st = await h.editorState();
|
||||
await h.setCursor(Math.floor((st?.length ?? 0) / 2));
|
||||
await h.parserIdle();
|
||||
await h.page.keyboard.press("End");
|
||||
await h.page.keyboard.press("Enter");
|
||||
const refresh = await h.refreshMs();
|
||||
const openMs = await h.measure(() => h.page.keyboard.press("g"), ".cm-tooltip-autocomplete li", "Result<Order>");
|
||||
const t0 = await h.now();
|
||||
const before = await h.counters();
|
||||
const narrow = await h.typeMeasured("etOrd", 150);
|
||||
const work = Harness.delta(before, await h.counters());
|
||||
const arrows = await h.pressMeasured(Array(20).fill("ArrowDown"), 60);
|
||||
await h.page.keyboard.press("Escape");
|
||||
return {
|
||||
openMs,
|
||||
...keySummary(narrow, refresh, "narrow"),
|
||||
narrowMainThreadMsPerKey: work.taskMs / 5,
|
||||
...keySummary(arrows, refresh, "arrow"),
|
||||
tbtMs: totalBlockingTime(await h.longTasksSince(t0)),
|
||||
};
|
||||
} finally {
|
||||
await h.page.evaluate(() =>
|
||||
(window as never as { __openideaPerf: { fakeCompletion(n: number): void } }).__openideaPerf.fakeCompletion(0),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A process printing 50,000 log lines (stack frames among them) at 200 lines a
|
||||
* frame (~4 s) into the Run console, while the user types in an editor: the
|
||||
* console must keep up and the editor must stay responsive.
|
||||
*/
|
||||
const consoleOutput: Scenario = {
|
||||
id: "consoleOutput",
|
||||
title: "Type while the Run console streams 50k lines",
|
||||
async run(h) {
|
||||
await h.openFile(SAMPLE);
|
||||
const st = await h.editorState();
|
||||
await h.setCursor(Math.floor((st?.length ?? 0) / 2));
|
||||
await h.page.keyboard.press("End");
|
||||
await h.page.keyboard.press("Enter");
|
||||
const refresh = await h.refreshMs();
|
||||
const t0 = await h.now();
|
||||
await h.inPage((p) => p.startFrames());
|
||||
const streamMs = h.page.evaluate(async () => {
|
||||
const w = window as never as { __openideaPerf: { runAction(id: string, arg?: unknown): Promise<boolean> } };
|
||||
const started = performance.now();
|
||||
await w.__openideaPerf.runAction("run.sampleOutput", { lines: 50_000, perFrame: 200 });
|
||||
return performance.now() - started;
|
||||
});
|
||||
// The console takes focus with it; type back in the editor while it streams.
|
||||
await h.page.waitForTimeout(100);
|
||||
await h.setCursor((await h.editorState())?.head ?? 0);
|
||||
const keys = await h.typeMeasured(TYPED.slice(0, 40), 70);
|
||||
const total = await streamMs;
|
||||
const frames = await h.inPage((p) => p.stopFrames());
|
||||
await h.inPage((p) => p.waitShown("body", "processed order #49998", 5_000));
|
||||
return {
|
||||
// 250 frames of output at the display rate is the floor.
|
||||
streamMs: total,
|
||||
...frameSummary(frames, refresh),
|
||||
...keySummary(keys, refresh, "key"),
|
||||
tbtMs: totalBlockingTime(await h.longTasksSince(t0)),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** Held key: auto-repeat at ~30 Hz must never fall behind. */
|
||||
const keyRepeat: Scenario = {
|
||||
id: "keyRepeat",
|
||||
@@ -185,6 +267,153 @@ const scroll: Scenario = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Git Log with 20,000 commits on branching lanes, loaded page by page as the
|
||||
* backend does (150 ms a page, like `git log`): showing it, walking the
|
||||
* selection with the arrow keys (the details pane follows) and wheel-scrolling
|
||||
* through several pages.
|
||||
*/
|
||||
const gitLog: Scenario = {
|
||||
id: "gitLog",
|
||||
title: "Show, navigate and scroll a 20k-commit Git Log",
|
||||
async run(h) {
|
||||
await h.page.evaluate(() =>
|
||||
(
|
||||
window as never as { __openideaPerf: { fakeGitLog(n: number, latencyMs?: number): void } }
|
||||
).__openideaPerf.fakeGitLog(20_000, 150),
|
||||
);
|
||||
const showMs = await h.measure(
|
||||
() =>
|
||||
h.page.evaluate(() =>
|
||||
(window as never as { __openideaPerf: { runAction(id: string): Promise<boolean> } }).__openideaPerf.runAction(
|
||||
"vcs.showLog",
|
||||
),
|
||||
),
|
||||
'[role="listbox"][aria-label="Commits"]',
|
||||
"order flow step 0",
|
||||
);
|
||||
const refresh = await h.refreshMs();
|
||||
const list = h.page.getByRole("listbox", { name: "Commits" });
|
||||
await list.getByRole("option").first().click();
|
||||
const keyLat = await h.pressMeasured(Array(40).fill("ArrowDown"), 50);
|
||||
const box = (await list.boundingBox())!;
|
||||
await h.page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
const t0 = await h.now();
|
||||
await h.inPage((p) => p.startFrames());
|
||||
// Far enough to load several more pages on the way.
|
||||
for (let i = 0; i < 160; i++) {
|
||||
await h.page.mouse.wheel(0, i < 120 ? 600 : -600);
|
||||
await h.page.waitForTimeout(16);
|
||||
}
|
||||
await h.page.waitForTimeout(200);
|
||||
const frames = await h.inPage((p) => p.stopFrames());
|
||||
return {
|
||||
showMs,
|
||||
...frameSummary(frames, refresh),
|
||||
tbtMs: totalBlockingTime(await h.longTasksSince(t0)),
|
||||
...keySummary(keyLat, refresh, "arrowKey"),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** Scrollbar-sized jumps across a loaded graph, rather than incremental wheel scrolling. */
|
||||
const gitLogScrollbar: Scenario = {
|
||||
id: "gitLogScrollbar",
|
||||
title: "Drag through a loaded 6k-commit Git Log",
|
||||
async run(h) {
|
||||
await h.page.evaluate(async () => {
|
||||
const probe = (
|
||||
window as never as {
|
||||
__openideaPerf: {
|
||||
fakeGitLog(n: number): void;
|
||||
runAction(id: string): Promise<boolean>;
|
||||
};
|
||||
}
|
||||
).__openideaPerf;
|
||||
probe.fakeGitLog(6000);
|
||||
await probe.runAction("vcs.showLog");
|
||||
});
|
||||
const list = h.page.getByRole("listbox", { name: "Commits" });
|
||||
await list.waitFor();
|
||||
await list.evaluate(async (element) => {
|
||||
for (let i = 0; i < 300 && element.scrollHeight < 6000 * 26; i++) {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
await new Promise(requestAnimationFrame);
|
||||
}
|
||||
if (element.scrollHeight < 6000 * 26) throw new Error("Git Log did not finish loading");
|
||||
element.scrollTop = 0;
|
||||
});
|
||||
await h.page.waitForTimeout(200);
|
||||
const refresh = await h.refreshMs();
|
||||
const t0 = await h.now();
|
||||
await h.inPage((p) => p.startFrames());
|
||||
await list.evaluate(async (element) => {
|
||||
const max = element.scrollHeight - element.clientHeight;
|
||||
for (let i = 0; i < 180; i++) {
|
||||
element.scrollTop = ((i < 90 ? i : 180 - i) / 90) * max;
|
||||
await new Promise(requestAnimationFrame);
|
||||
}
|
||||
});
|
||||
const frames = await h.inPage((p) => p.stopFrames());
|
||||
return {
|
||||
...frameSummary(frames, refresh),
|
||||
tbtMs: totalBlockingTime(await h.longTasksSince(t0)),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Dragging the splitters with an editor, the Project tree and the Git Log
|
||||
* open: every pointer move resizes the islands, so each frame relays them out.
|
||||
*/
|
||||
const resizePanels: Scenario = {
|
||||
id: "resizePanels",
|
||||
title: "Drag the tool window splitters",
|
||||
async run(h) {
|
||||
await h.openFile(SAMPLE);
|
||||
await h.page.evaluate(() =>
|
||||
(window as never as { __openideaPerf: { fakeGitLog(n: number): void } }).__openideaPerf.fakeGitLog(2_000),
|
||||
);
|
||||
await h.measure(
|
||||
() =>
|
||||
h.page.evaluate(() =>
|
||||
(window as never as { __openideaPerf: { runAction(id: string): Promise<boolean> } }).__openideaPerf.runAction(
|
||||
"vcs.showLog",
|
||||
),
|
||||
),
|
||||
'[role="listbox"][aria-label="Commits"]',
|
||||
"order flow step 0",
|
||||
);
|
||||
const refresh = await h.refreshMs();
|
||||
const drag = async (name: string, dx: number, dy: number) => {
|
||||
const box = (await h.page.getByRole("separator", { name }).boundingBox())!;
|
||||
const x = box.x + box.width / 2;
|
||||
const y = box.y + box.height / 2;
|
||||
await h.page.mouse.move(x, y);
|
||||
await h.page.mouse.down();
|
||||
await h.inPage((p) => p.startFrames());
|
||||
// Out and back twice, one pointer move a frame.
|
||||
for (let i = 0; i < 120; i++) {
|
||||
const t = Math.sin((i / 60) * Math.PI);
|
||||
await h.page.mouse.move(x + dx * t, y + dy * t);
|
||||
await h.page.waitForTimeout(16);
|
||||
}
|
||||
await h.page.mouse.up();
|
||||
await h.page.waitForTimeout(200);
|
||||
return frameSummary(await h.inPage((p) => p.stopFrames()), refresh);
|
||||
};
|
||||
const t0 = await h.now();
|
||||
const side = await drag("Resize left tool window", 300, 0);
|
||||
const bottom = await drag("Resize bottom tool window", 0, -250);
|
||||
return {
|
||||
...side,
|
||||
tbtMs: totalBlockingTime(await h.longTasksSince(t0)),
|
||||
bottomFrameP95Ms: bottom.frameP95Ms,
|
||||
bottomDroppedFramesPct: bottom.droppedFramesPct,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** Cold load: navigation → Project tree painted. */
|
||||
const startup: Scenario = {
|
||||
id: "startup",
|
||||
@@ -369,6 +598,11 @@ export const SCENARIOS: Scenario[] = [
|
||||
typing,
|
||||
typingLarge,
|
||||
typingAngular,
|
||||
completion,
|
||||
consoleOutput,
|
||||
gitLog,
|
||||
gitLogScrollbar,
|
||||
resizePanels,
|
||||
keyRepeat,
|
||||
scroll,
|
||||
tabs,
|
||||
|
||||
@@ -65,6 +65,47 @@ export interface EditorContribution {
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor extensions whose code loads with the first editor that wants them: `wants`
|
||||
* decides synchronously, `load` resolves the real `extensions`. Until it has, the editor
|
||||
* runs without them and reconfigures once loaded.
|
||||
*/
|
||||
export function lazyEditorExtension(
|
||||
wants: (ctx: EditorDocContext) => boolean,
|
||||
load: () => Promise<(ctx: EditorDocContext) => Extension>,
|
||||
): (ctx: EditorDocContext) => Extension {
|
||||
let loaded: ((ctx: EditorDocContext) => Extension) | null = null;
|
||||
let pending: Promise<(ctx: EditorDocContext) => Extension> | null = null;
|
||||
const get = () =>
|
||||
(pending ??= load().then(
|
||||
(f) => (loaded = f),
|
||||
(e: unknown) => {
|
||||
pending = null;
|
||||
throw e;
|
||||
},
|
||||
));
|
||||
return (ctx) => {
|
||||
if (!wants(ctx)) return [];
|
||||
if (loaded) return loaded(ctx);
|
||||
const slot = new Compartment();
|
||||
return [
|
||||
slot.of([]),
|
||||
ViewPlugin.define((view) => {
|
||||
let live = true;
|
||||
get().then(
|
||||
(f) => live && view.dispatch({ effects: slot.reconfigure(f(ctx)) }),
|
||||
(e: unknown) => console.error("could not load editor extensions", e),
|
||||
);
|
||||
return {
|
||||
destroy: () => {
|
||||
live = false;
|
||||
},
|
||||
};
|
||||
}),
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
export interface TextEditorHandle {
|
||||
view: EditorView;
|
||||
ctx: EditorDocContext;
|
||||
|
||||
@@ -119,6 +119,8 @@ interface MemDoc {
|
||||
|
||||
const EXT_LANG: Record<string, string> = {
|
||||
ts: "typescript",
|
||||
mts: "typescript",
|
||||
cts: "typescript",
|
||||
tsx: "typescriptreact",
|
||||
js: "javascript",
|
||||
jsx: "javascriptreact",
|
||||
@@ -155,7 +157,7 @@ const MEM_LANGUAGES: LanguageInfo[] = [
|
||||
{
|
||||
id: "typescript",
|
||||
name: "TypeScript",
|
||||
extensions: ["ts"],
|
||||
extensions: ["ts", "mts", "cts"],
|
||||
uiMode: "TypeScript",
|
||||
lineComment: "//",
|
||||
blockComment: ["/*", "*/"],
|
||||
@@ -377,12 +379,20 @@ export function createMemoryBackend(files: Record<string, string> = {}): MemoryB
|
||||
...(d.uri ? { uri: d.uri } : {}),
|
||||
...(d.uiMode ? { uiMode: d.uiMode } : {}),
|
||||
});
|
||||
// An Angular project's templates (an `angular.json` above them), like the backend's `framework-ui-modes`.
|
||||
const uiModeOf = (path: string) =>
|
||||
path.endsWith(".html") &&
|
||||
[...store.keys()].some((f) => f.endsWith("/angular.json") && path.startsWith(f.slice(0, -"angular.json".length)))
|
||||
? "Angular Template"
|
||||
: null;
|
||||
// Framework modes mirror the backend's `framework-ui-modes` definitions.
|
||||
const uiModeOf = (path: string) => {
|
||||
const angular = [...store.keys()].some((f) => {
|
||||
const root = f.endsWith("/angular.json")
|
||||
? f.slice(0, -"angular.json".length)
|
||||
: f.includes("/node_modules/@angular/core/")
|
||||
? f.slice(0, f.indexOf("/node_modules/@angular/core/") + 1)
|
||||
: null;
|
||||
return root !== null && path.startsWith(root);
|
||||
});
|
||||
if (!angular) return null;
|
||||
if (path.endsWith(".html")) return "Angular Template";
|
||||
return /\.(?:ts|mts|cts)$/.test(path) ? "Angular TypeScript" : null;
|
||||
};
|
||||
const get = (doc: DocId) => {
|
||||
const d = docs.get(doc);
|
||||
if (!d) throw new IpcError({ kind: "notFound", message: `document ${doc} is not open` });
|
||||
|
||||
@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { ensureSyntaxTree, foldable, matchBrackets } from "@codemirror/language";
|
||||
import { NodeProp, type Tree } from "@lezer/common";
|
||||
import { IterMode, NodeProp, type Tree } from "@lezer/common";
|
||||
import { buildParserFile } from "@lezer/generator";
|
||||
import { highlightTree, tagHighlighter, tags as t } from "@lezer/highlight";
|
||||
import { findLanguageDescription, loadSupport } from "../languages";
|
||||
@@ -556,6 +556,39 @@ describe("Angular templates", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("reuses the trees of identical text and attributes wherever they are", () => {
|
||||
const part = '<p [title]="t">{{ a + 1 }} @if (b) {</p>\n';
|
||||
tokens(part);
|
||||
// The same nodes at other offsets come from the memo; tokens are sliced at their positions.
|
||||
const doc = `<div>${part}</div>\n${part}<span>x</span>${part}`;
|
||||
const all = tokens(doc);
|
||||
expect(all.filter((tok) => tok == "a:var")).toHaveLength(3);
|
||||
expect(all.filter((tok) => tok == "title:attr")).toHaveLength(3);
|
||||
expect(all.filter((tok) => tok == "@if:control")).toHaveLength(3);
|
||||
const mounts: unknown[] = [];
|
||||
angularLanguage.parser.parse(doc).iterate({
|
||||
mode: IterMode.IgnoreMounts,
|
||||
enter: (n) => {
|
||||
const mount = n.name == "Text" && n.tree?.prop(NodeProp.mounted);
|
||||
if (mount) mounts.push(mount.tree);
|
||||
},
|
||||
});
|
||||
expect(mounts).toHaveLength(3);
|
||||
expect(new Set(mounts).size).toBe(1);
|
||||
});
|
||||
|
||||
it("parses only text with Angular syntax as Angular", () => {
|
||||
const doc = "<ul>\n <li>Tom & Jerry</li>\n <li>{{ a }} & {{ b }}</li>\n</ul>\n@if (x) {\n}";
|
||||
const tree = angularLanguage.parser.parse(doc);
|
||||
const parentAt = (text: string) => tree.resolveInner(doc.indexOf(text) + 1, 1).parent?.name;
|
||||
// Plain text and the whitespace between tags stay HTML text.
|
||||
expect(parentAt("Tom")).toBe("Element");
|
||||
expect(parentAt("\n <li>")).toBe("Element");
|
||||
expect(parentAt("{{ a")).toBe("Interpolation");
|
||||
expect(tokens(doc)).toContain("@if:control");
|
||||
expect(tokens(doc)).toContain("b:var");
|
||||
});
|
||||
|
||||
it("is generated from angular.grammar", () => {
|
||||
const dir = import.meta.dirname;
|
||||
const { parser, terms } = buildParserFile(readFileSync(resolve(dir, "angular.grammar"), "utf8"), {
|
||||
|
||||
@@ -7,10 +7,23 @@
|
||||
* parsed as one, and folding blocks.
|
||||
*/
|
||||
import { LRLanguage, LanguageSupport, foldService, matchBrackets, syntaxTree } from "@codemirror/language";
|
||||
import { EditorSelection } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { html } from "@codemirror/lang-html";
|
||||
import { javascriptLanguage } from "@codemirror/lang-javascript";
|
||||
import { styleTags, tags as t } from "@lezer/highlight";
|
||||
import { IterMode, parseMixed, type Input, type NestedParse, type SyntaxNode, type SyntaxNodeRef } from "@lezer/common";
|
||||
import {
|
||||
IterMode,
|
||||
Parser,
|
||||
parseMixed,
|
||||
type Input,
|
||||
type NestedParse,
|
||||
type PartialParse,
|
||||
type SyntaxNode,
|
||||
type SyntaxNodeRef,
|
||||
type Tree,
|
||||
type TreeFragment,
|
||||
} from "@lezer/common";
|
||||
import { JS_TAGS } from "../jsTags";
|
||||
import { parser } from "./parser";
|
||||
|
||||
@@ -84,8 +97,66 @@ const attrParser = baseParser.configure({
|
||||
top: "Attribute",
|
||||
});
|
||||
|
||||
const textMixed = { parser: textParser },
|
||||
attrMixed = { parser: attrParser };
|
||||
/**
|
||||
* `inner` with its trees remembered by text. Most text and attribute nodes
|
||||
* an edit recreates (the document's own text, nodes up to the viewport) are
|
||||
* unchanged, but the mixed parser only reuses nested trees of nodes it kept, so
|
||||
* each would be parsed again on every keystroke. Their parse depends on
|
||||
* nothing but their text, and trees are relative to their start.
|
||||
*/
|
||||
class TextMemo extends Parser {
|
||||
private readonly trees = new Map<string, Tree>();
|
||||
private chars = 0;
|
||||
constructor(private readonly inner: Parser) {
|
||||
super();
|
||||
}
|
||||
createParse(
|
||||
input: Input,
|
||||
fragments: readonly TreeFragment[],
|
||||
ranges: readonly { from: number; to: number }[],
|
||||
): PartialParse {
|
||||
const range = ranges.length == 1 ? ranges[0]! : null;
|
||||
if (!range || range.to - range.from > MEMO_MAX_TEXT) return this.inner.startParse(input, fragments, ranges);
|
||||
const text = input.read(range.from, range.to);
|
||||
const known = this.trees.get(text);
|
||||
if (known) return { advance: () => known, parsedPos: range.to, stoppedAt: null, stopAt() {} };
|
||||
const parse = this.inner.startParse(input, fragments, ranges);
|
||||
const remember = (tree: Tree) => this.remember(text, tree);
|
||||
return {
|
||||
advance() {
|
||||
const tree = parse.advance();
|
||||
// Stopping past the text (the outer parse's stop point) still parses all of it.
|
||||
if (tree && (parse.stoppedAt == null || parse.stoppedAt >= range.to)) remember(tree);
|
||||
return tree;
|
||||
},
|
||||
get parsedPos() {
|
||||
return parse.parsedPos;
|
||||
},
|
||||
get stoppedAt() {
|
||||
return parse.stoppedAt;
|
||||
},
|
||||
stopAt: (pos) => parse.stopAt(pos),
|
||||
};
|
||||
}
|
||||
private remember(text: string, tree: Tree) {
|
||||
if (this.trees.has(text)) return;
|
||||
this.trees.set(text, tree);
|
||||
this.chars += text.length;
|
||||
// Oldest first.
|
||||
for (const [old] of this.trees) {
|
||||
if (this.chars <= MEMO_CHARS) break;
|
||||
this.trees.delete(old);
|
||||
this.chars -= old.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Longer texts are parsed incrementally as usual; the memo holds about this much text. */
|
||||
const MEMO_MAX_TEXT = 4096,
|
||||
MEMO_CHARS = 2_000_000;
|
||||
|
||||
const textMixed = { parser: new TextMemo(textParser) },
|
||||
attrMixed = { parser: new TextMemo(attrParser) };
|
||||
|
||||
// Text is parsed per node, like upstream, so an unfinished `{{` or `@if (`
|
||||
// ends with it. But HTML splits text at each `&` (`@if (a && b) {`), so a run
|
||||
@@ -93,8 +164,15 @@ const textMixed = { parser: textParser },
|
||||
// document) holding it.
|
||||
const TEXT_NODES = new Set(["Text", "EntityReference", "CharacterReference", "InvalidEntity"]);
|
||||
|
||||
/** The runs of text directly in `node` that entities split, `null` without any. */
|
||||
function entityRuns(node: SyntaxNode): { from: number; to: number }[] | null {
|
||||
/**
|
||||
* Whether text holds Angular syntax: interpolations, blocks and their braces all
|
||||
* need a `{`, `}` or `@`. Other text (mostly the whitespace between tags) stays
|
||||
* plain HTML text instead of costing a nested parse each.
|
||||
*/
|
||||
const ANGULAR_TEXT = /[{}@]/;
|
||||
|
||||
/** The runs of Angular text directly in `node` that entities split, `null` without any. */
|
||||
function entityRuns(node: SyntaxNode, input: Input): { from: number; to: number }[] | null {
|
||||
const runs: { from: number; to: number }[] = [];
|
||||
let from = -1,
|
||||
to = -1,
|
||||
@@ -108,12 +186,12 @@ function entityRuns(node: SyntaxNode): { from: number; to: number }[] | null {
|
||||
parts++;
|
||||
continue;
|
||||
}
|
||||
if (parts > 1) runs.push({ from, to });
|
||||
if (parts > 1 && ANGULAR_TEXT.test(input.read(from, to))) runs.push({ from, to });
|
||||
({ from, to } = child);
|
||||
parts = 1;
|
||||
} while (child.nextSibling());
|
||||
}
|
||||
if (parts > 1) runs.push({ from, to });
|
||||
if (parts > 1 && ANGULAR_TEXT.test(input.read(from, to))) runs.push({ from, to });
|
||||
return runs.length ? runs : null;
|
||||
}
|
||||
|
||||
@@ -125,10 +203,10 @@ function mixAngular(node: SyntaxNodeRef, input: Input): NestedParse | null {
|
||||
return /^[*#([]|\{\{/.test(input.read(node.from, node.to)) ? attrMixed : null;
|
||||
// Text in a run is covered by its parent's overlay, so not asked about.
|
||||
case "Text":
|
||||
return textMixed;
|
||||
return ANGULAR_TEXT.test(input.read(node.from, node.to)) ? textMixed : null;
|
||||
case "Document":
|
||||
case "Element": {
|
||||
const overlay = entityRuns(node.node);
|
||||
const overlay = entityRuns(node.node, input);
|
||||
return overlay && { parser: node.name == "Document" ? documentRunsParser : runsParser, overlay };
|
||||
}
|
||||
}
|
||||
@@ -154,9 +232,40 @@ const blockFolding = foldService.of((state, lineStart, lineEnd) => {
|
||||
return null;
|
||||
});
|
||||
|
||||
const selfCloseTags = EditorView.inputHandler.of((view, from, to, text, insertTransaction) => {
|
||||
const { state } = view;
|
||||
if (view.composing || state.readOnly || from !== to || text !== "/") return false;
|
||||
const heads = state.selection.ranges
|
||||
.filter((range) => {
|
||||
if (!range.empty || !angularLanguage.isActiveAt(state, range.head, -1)) return false;
|
||||
const node = syntaxTree(state).resolveInner(range.head, -1);
|
||||
return node.name === "TagName" && node.to === range.head && node.parent?.name === "OpenTag";
|
||||
})
|
||||
.map((range) => range.head);
|
||||
if (!heads.length) return false;
|
||||
const base = insertTransaction();
|
||||
const mappedHeads = new Set(heads.map((head) => base.changes.mapPos(head, 1)));
|
||||
const closing = base.state.changeByRange((range) => {
|
||||
if (!range.empty || !mappedHeads.has(range.head) || base.state.sliceDoc(range.head - 1, range.head) !== "/")
|
||||
return { range };
|
||||
return {
|
||||
range: EditorSelection.cursor(range.head + 1),
|
||||
changes: {
|
||||
from: range.head,
|
||||
to: range.head + (base.state.sliceDoc(range.head, range.head + 1) === ">" ? 1 : 0),
|
||||
insert: ">",
|
||||
},
|
||||
};
|
||||
});
|
||||
if (closing.changes.empty) return false;
|
||||
view.dispatch([base, base.state.update(closing, { userEvent: "input.type", scrollIntoView: true })]);
|
||||
return true;
|
||||
});
|
||||
|
||||
export function angular(): LanguageSupport {
|
||||
return new LanguageSupport(angularLanguage, [
|
||||
baseHTML.support,
|
||||
selfCloseTags,
|
||||
blockFolding,
|
||||
baseHTML.language.data.of({
|
||||
closeBrackets: { brackets: ["[", "{", '"'] },
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EditorSelection, EditorState, type Extension } from "@codemirror/state";
|
||||
import { history, undo } from "@codemirror/commands";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { ensureSyntaxTree } from "@codemirror/language";
|
||||
import { highlightTree, tagHighlighter, tags as t } from "@lezer/highlight";
|
||||
import { findLanguageDescription, loadSupport } from "../languages";
|
||||
import { angular } from "./index";
|
||||
|
||||
async function inlineSupport() {
|
||||
return (await loadSupport(findLanguageDescription("Angular TypeScript", "/p/app.ts")!))!;
|
||||
}
|
||||
|
||||
const highlighter = tagHighlighter([
|
||||
{ tag: t.tagName, class: "tag" },
|
||||
{ tag: t.attributeName, class: "attr" },
|
||||
{ tag: t.variableName, class: "var" },
|
||||
{ tag: t.controlKeyword, class: "control" },
|
||||
{ tag: t.definitionKeyword, class: "definition" },
|
||||
{ tag: t.string, class: "string" },
|
||||
]);
|
||||
|
||||
function tokens(state: EditorState) {
|
||||
const out: string[] = [];
|
||||
highlightTree(ensureSyntaxTree(state, state.doc.length, 5000)!, highlighter, (from, to, cls) =>
|
||||
out.push(`${state.sliceDoc(from, to)}:${cls}`),
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
function type(view: EditorView, text: string) {
|
||||
const { from, to } = view.state.selection.main;
|
||||
const insert = () => view.state.update(view.state.replaceSelection(text), { userEvent: "input.type" });
|
||||
if (!view.state.facet(EditorView.inputHandler).some((h) => h(view, from, to, text, insert))) view.dispatch(insert());
|
||||
}
|
||||
|
||||
function editor(marked: string, extensions: Extension) {
|
||||
const pos = marked.indexOf("|");
|
||||
return new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: marked.replace("|", ""),
|
||||
selection: { anchor: pos },
|
||||
extensions,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
describe("Angular inline templates", () => {
|
||||
it.each(["`", "'", '"'])("highlights a %s-quoted component template", async (quote) => {
|
||||
const template = '<div [title]="name">{{ name }} @if (ready) {} @for (x of xs; track x) {} @let y = count;</div>';
|
||||
const body = quote === '"' ? template.replaceAll('"', "'") : template;
|
||||
const state = EditorState.create({
|
||||
doc: `@Component({ template: ${quote}${body}${quote} }) class App {}`,
|
||||
extensions: await inlineSupport(),
|
||||
});
|
||||
expect(tokens(state)).toEqual(
|
||||
expect.arrayContaining(["div:tag", "title:attr", "name:var", "@if:control", "@for:control", "@let:definition"]),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"const x = { template: `<div>{{ name }}</div>` };",
|
||||
"@Other({ template: `<div>{{ name }}</div>` }) class App {}",
|
||||
"@Component({ nested: { template: `<div>{{ name }}</div>` } }) class App {}",
|
||||
"@Component({ templateUrl: `<div>{{ name }}</div>` }) class App {}",
|
||||
"Component({ template: `<div>{{ name }}</div>` });",
|
||||
"@Component({ template: `<div>${name}</div>` }) class App {}",
|
||||
"@Component({ template: `<div>` + suffix }) class App {}",
|
||||
])("keeps unrelated strings plain: %s", async (doc) => {
|
||||
expect(tokens(EditorState.create({ doc, extensions: await inlineSupport() }))).not.toContain("div:tag");
|
||||
});
|
||||
|
||||
it("keeps ordinary TypeScript projects plain", async () => {
|
||||
const support = await loadSupport(findLanguageDescription("TypeScript", "/p/app.ts")!);
|
||||
expect(
|
||||
tokens(EditorState.create({ doc: "@Component({ template: `<div/>` }) class App {}", extensions: support! })),
|
||||
).not.toContain("div:tag");
|
||||
});
|
||||
|
||||
it("supports quoted metadata keys and namespace decorators", async () => {
|
||||
const state = EditorState.create({
|
||||
doc: '@ng.Component({ "template": `<r-card/>` }) class App {}',
|
||||
extensions: await inlineSupport(),
|
||||
});
|
||||
expect(tokens(state)).toContain("r-card:tag");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"@Component(/* metadata */ { template /* key */ : /* value */ `<div/>` }) class App {}",
|
||||
"@Component({ template: `<div",
|
||||
"@Component({ template: '<div",
|
||||
])("highlights unfinished and commented templates: %s", async (doc) => {
|
||||
expect(tokens(EditorState.create({ doc, extensions: await inlineSupport() }))).toContain("div:tag");
|
||||
});
|
||||
|
||||
it("matches a fresh parse after editing templates and decorator context", async () => {
|
||||
const extensions = await inlineSupport();
|
||||
let state = EditorState.create({
|
||||
doc: "@Component({ template: `<div>{{ name }}</div>` }) class App {}",
|
||||
extensions,
|
||||
});
|
||||
for (const [search, replacement] of [
|
||||
["name", "user.name"],
|
||||
["Component", "Other"],
|
||||
["Other", "Component"],
|
||||
["template:", "other:"],
|
||||
]) {
|
||||
const from = state.doc.toString().indexOf(search!);
|
||||
state = state.update({ changes: { from, to: from + search!.length, insert: replacement } }).state;
|
||||
expect(tokens(state)).toEqual(tokens(EditorState.create({ doc: state.doc, extensions })));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(["inline", "external"])("Angular tag closing (%s)", (mode) => {
|
||||
async function support() {
|
||||
return mode === "inline" ? inlineSupport() : angular();
|
||||
}
|
||||
function wrap(template: string) {
|
||||
return mode === "inline" ? `@Component({ template: \`${template}\` }) class App {}` : template;
|
||||
}
|
||||
|
||||
it.each([
|
||||
["<div|", ">", "<div></div>"],
|
||||
["<div><|", "/", "<div></div>"],
|
||||
["<r-card|", "/", "<r-card/>"],
|
||||
["<r-card|>", "/", "<r-card/>"],
|
||||
["<input|", ">", "<input>"],
|
||||
['<r-card title="a/b|"', "/", '<r-card title="a/b/"'],
|
||||
["<!-- <r-card| -->", "/", "<!-- <r-card/ -->"],
|
||||
["{{ count |}}", "/", "{{ count /}}"],
|
||||
])("handles %s + %s", async (before, text, after) => {
|
||||
const view = editor(wrap(before), await support());
|
||||
try {
|
||||
type(view, text);
|
||||
expect(view.state.doc.toString()).toBe(wrap(after));
|
||||
} finally {
|
||||
view.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it("closes tags at multiple cursors in one undoable input", async () => {
|
||||
const doc = wrap("<r-card\n<app-item");
|
||||
const ranges = [doc.indexOf("<r-card") + 7, doc.indexOf("<app-item") + 9].map((p) => EditorSelection.cursor(p));
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc,
|
||||
selection: EditorSelection.create(ranges),
|
||||
extensions: [await support(), history(), EditorState.allowMultipleSelections.of(true)],
|
||||
}),
|
||||
});
|
||||
try {
|
||||
type(view, "/");
|
||||
expect(view.state.doc.toString()).toBe(wrap("<r-card/>\n<app-item/>"));
|
||||
expect(undo(view)).toBe(true);
|
||||
expect(view.state.doc.toString()).toBe(doc);
|
||||
} finally {
|
||||
view.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { LanguageSupport, LRLanguage } from "@codemirror/language";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import { parseMixed, type Input, type NestedParse, type SyntaxNode, type SyntaxNodeRef } from "@lezer/common";
|
||||
import { angular, angularLanguage } from "./index";
|
||||
|
||||
function nextCode(node: SyntaxNode): SyntaxNode | null {
|
||||
let next = node.nextSibling;
|
||||
while (next && (next.name === "BlockComment" || next.name === "LineComment")) next = next.nextSibling;
|
||||
return next;
|
||||
}
|
||||
|
||||
function mixTemplate(node: SyntaxNodeRef, input: Input): NestedParse | null {
|
||||
if (node.name !== "Decorator") return null;
|
||||
const call = node.node.getChild("CallExpression");
|
||||
const callee = call?.firstChild;
|
||||
const name = callee?.name === "MemberExpression" ? callee.lastChild : callee;
|
||||
if (!name || !["VariableName", "PropertyName"].includes(name.name) || input.read(name.from, name.to) !== "Component")
|
||||
return null;
|
||||
const args = call?.getChild("ArgList");
|
||||
const metadata = args?.firstChild && nextCode(args.firstChild);
|
||||
if (metadata?.name !== "ObjectExpression") return null;
|
||||
for (const property of metadata.getChildren("Property")) {
|
||||
const key = property.firstChild;
|
||||
if (!key || !["template", "'template'", '"template"'].includes(input.read(key.from, key.to))) continue;
|
||||
const colon = nextCode(key);
|
||||
const value = colon?.name === ":" ? nextCode(colon) : null;
|
||||
if (!value || nextCode(value) || !["String", "TemplateString"].includes(value.name)) continue;
|
||||
// A dynamic JavaScript template retains its interpolation expressions.
|
||||
if (value.getChild("Interpolation")) continue;
|
||||
const quote = input.read(value.from, value.from + 1);
|
||||
const from = value.from + 1;
|
||||
const to = value.to - (!value.lastChild?.type.isError && input.read(value.to - 1, value.to) === quote ? 1 : 0);
|
||||
if (from < to) return { parser: angularLanguage.parser, overlay: [{ from, to }] };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const typescript = javascript({ typescript: true });
|
||||
|
||||
export function angularTypescript(): LanguageSupport {
|
||||
// Mount on the decorator so changing its name or metadata invalidates the template too.
|
||||
const language = (typescript.language as LRLanguage).configure(
|
||||
{ wrap: parseMixed(mixTemplate) },
|
||||
"angular-typescript",
|
||||
);
|
||||
return new LanguageSupport(language, [typescript.support, angular().support]);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { goModParser, initialGoModState, type GoModState } from "./gomod";
|
||||
import { StringStream } from "@codemirror/language";
|
||||
|
||||
function tokenize(line: string, state: GoModState) {
|
||||
const stream = new StringStream(line, 4, 1);
|
||||
const tokens: { text: string; token: string | null }[] = [];
|
||||
while (!stream.eol()) {
|
||||
const start = stream.pos;
|
||||
const kind = goModParser.token(stream, state);
|
||||
if (stream.pos > start && kind !== null) {
|
||||
tokens.push({ text: stream.string.slice(start, stream.pos), token: kind });
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
describe("goModParser", () => {
|
||||
it("highlights module declaration and go version", () => {
|
||||
const state = initialGoModState();
|
||||
|
||||
const line1 = tokenize("module onpay-aplikasi-api", state);
|
||||
expect(line1).toEqual([
|
||||
{ text: "module", token: "keyword" },
|
||||
{ text: "onpay-aplikasi-api", token: "string" },
|
||||
]);
|
||||
|
||||
const line2 = tokenize("go 1.27.0", state);
|
||||
expect(line2).toEqual([
|
||||
{ text: "go", token: "keyword" },
|
||||
{ text: "1.27.0", token: "number" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("highlights require blocks with module paths and versions", () => {
|
||||
const state = initialGoModState();
|
||||
|
||||
const line1 = tokenize("require (", state);
|
||||
expect(line1).toEqual([
|
||||
{ text: "require", token: "keyword" },
|
||||
{ text: "(", token: "paren" },
|
||||
]);
|
||||
|
||||
const line2 = tokenize("\tcloud.google.com/go/auth v0.20.0", state);
|
||||
expect(line2).toEqual([
|
||||
{ text: "cloud.google.com/go/auth", token: "string" },
|
||||
{ text: "v0.20.0", token: "number" },
|
||||
]);
|
||||
|
||||
const line3 = tokenize("\tgithub.com/shopspring/decimal v1.4.0 // indirect", state);
|
||||
expect(line3).toEqual([
|
||||
{ text: "github.com/shopspring/decimal", token: "string" },
|
||||
{ text: "v1.4.0", token: "number" },
|
||||
{ text: "// indirect", token: "comment" },
|
||||
]);
|
||||
|
||||
const line4 = tokenize(")", state);
|
||||
expect(line4).toEqual([{ text: ")", token: "paren" }]);
|
||||
});
|
||||
|
||||
it("highlights replace directive with operator", () => {
|
||||
const state = initialGoModState();
|
||||
|
||||
const line = tokenize("replace example.com/foo => ../bar", state);
|
||||
expect(line).toEqual([
|
||||
{ text: "replace", token: "keyword" },
|
||||
{ text: "example.com/foo", token: "string" },
|
||||
{ text: "=>", token: "operator" },
|
||||
{ text: "../bar", token: "string" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { StreamLanguage, type StreamParser } from "@codemirror/language";
|
||||
|
||||
const KEYWORDS = new Set(["module", "go", "toolchain", "require", "replace", "exclude", "retract", "use"]);
|
||||
|
||||
export interface GoModState {
|
||||
currentKeyword: string | null;
|
||||
inBlock: boolean;
|
||||
expectVersion: boolean;
|
||||
}
|
||||
|
||||
export function initialGoModState(): GoModState {
|
||||
return {
|
||||
currentKeyword: null,
|
||||
inBlock: false,
|
||||
expectVersion: false,
|
||||
};
|
||||
}
|
||||
|
||||
export const goModParser: StreamParser<GoModState> = {
|
||||
name: "gomod",
|
||||
startState: () => initialGoModState(),
|
||||
token(stream, state) {
|
||||
if (stream.eatSpace()) return null;
|
||||
|
||||
if (stream.sol()) {
|
||||
if (!state.inBlock) {
|
||||
state.currentKeyword = null;
|
||||
}
|
||||
state.expectVersion = false;
|
||||
}
|
||||
|
||||
// Comment
|
||||
if (stream.match(/^\/\/.*/)) {
|
||||
return "comment";
|
||||
}
|
||||
|
||||
// Punctuation & operators
|
||||
if (stream.match(/^=>/)) {
|
||||
state.expectVersion = false;
|
||||
return "operator";
|
||||
}
|
||||
if (stream.eat("(")) {
|
||||
state.inBlock = true;
|
||||
return "paren";
|
||||
}
|
||||
if (stream.eat(")")) {
|
||||
state.inBlock = false;
|
||||
state.currentKeyword = null;
|
||||
return "paren";
|
||||
}
|
||||
|
||||
// String literals
|
||||
if (stream.match(/^"(?:[^"\\]|\\.)*"/)) {
|
||||
return "string";
|
||||
}
|
||||
if (stream.match(/^`[^`]*`/)) {
|
||||
return "string";
|
||||
}
|
||||
|
||||
// Check words/tokens
|
||||
const wordMatch = stream.match(/^[^\s()]+/) as RegExpMatchArray | null;
|
||||
if (!wordMatch) {
|
||||
stream.next();
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = wordMatch[0] ?? "";
|
||||
|
||||
// Keyword at start of statement (not expecting a version)
|
||||
if (!state.expectVersion && KEYWORDS.has(token)) {
|
||||
state.currentKeyword = token;
|
||||
return "keyword";
|
||||
}
|
||||
|
||||
// Semantic versions: v1.2.3, 1.22, 1.22.0, v0.0.0-2023..., v4.4.0+incompatible
|
||||
const isVersion = /^v?[0-9]+(\.[0-9]+)*([+-][a-zA-Z0-9_.-]+)*$/.test(token) || /^\[v?[0-9]+.*\]$/.test(token);
|
||||
|
||||
if (isVersion) {
|
||||
state.expectVersion = false;
|
||||
return "number";
|
||||
}
|
||||
|
||||
// In require/replace/use block or following module/require keyword:
|
||||
// The first token is the module path/package name (string in GoLand: green)
|
||||
if (state.currentKeyword) {
|
||||
state.expectVersion = true;
|
||||
return "string";
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
languageData: {
|
||||
commentTokens: { line: "//" },
|
||||
indentOnInput: /^\s*\)\s*$/,
|
||||
},
|
||||
};
|
||||
|
||||
export const goModLanguage = StreamLanguage.define(goModParser);
|
||||
@@ -151,4 +151,18 @@ describe("language modes", () => {
|
||||
expect(derefs, mode).toEqual(["?.", ".", "?."]);
|
||||
}
|
||||
});
|
||||
|
||||
it("recognizes go.mod and go.work files by mode and filename", async () => {
|
||||
for (const [mode, path] of [
|
||||
["GoMod", "/project/go.mod"],
|
||||
[null, "/project/go.mod"],
|
||||
["GoMod", "/project/go.work"],
|
||||
[null, "/project/go.work"],
|
||||
] as const) {
|
||||
const desc = findLanguageDescription(mode, path);
|
||||
expect(desc?.name).toBe("GoMod");
|
||||
const support = await loadSupport(desc!);
|
||||
expect(support?.language).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -113,6 +113,10 @@ const OWN_MODES = [
|
||||
},
|
||||
}),
|
||||
),
|
||||
LanguageDescription.of({
|
||||
name: "Angular TypeScript",
|
||||
load: () => import("./angular/inline").then((m) => m.angularTypescript()),
|
||||
}),
|
||||
LanguageDescription.of({ name: "Angular Template", load: () => import("./angular").then((m) => m.angular()) }),
|
||||
LanguageDescription.of({
|
||||
name: "ERB",
|
||||
@@ -141,6 +145,13 @@ const OWN_MODES = [
|
||||
);
|
||||
},
|
||||
}),
|
||||
LanguageDescription.of({
|
||||
name: "GoMod",
|
||||
alias: ["gomod", "gowork"],
|
||||
extensions: ["mod", "work"],
|
||||
filename: /^(?:go\.mod|go\.work)$/,
|
||||
load: async () => new LanguageSupport((await import("./gomod")).goModLanguage),
|
||||
}),
|
||||
];
|
||||
|
||||
const BATCH_KEYWORDS = new Set([
|
||||
@@ -224,9 +235,9 @@ export function findLanguageDescription(uiMode: string | null, path: string): La
|
||||
const loose = LanguageDescription.matchLanguageName(BUILTIN_MODES, uiMode, false);
|
||||
if (loose) return OWN_MODES.find((l) => l.name === loose.name) ?? loose;
|
||||
}
|
||||
if (uiMode === null && infos) return null;
|
||||
const own = LanguageDescription.matchFilename(OWN_MODES, path.slice(path.lastIndexOf("/") + 1));
|
||||
if (own) return own;
|
||||
if (uiMode === null && infos) return null;
|
||||
const matched = LanguageDescription.matchFilename(BUILTIN_MODES, path.slice(path.lastIndexOf("/") + 1));
|
||||
return OWN_MODES.find((l) => l.name === matched?.name) ?? matched;
|
||||
}
|
||||
@@ -267,6 +278,7 @@ export const JAVA_TAGS: Parameters<typeof styleTags>[0] = {
|
||||
const EXTRA_TAGS: Record<string, Parameters<typeof styleTags>[0]> = {
|
||||
JavaScript: JS_TAGS,
|
||||
TypeScript: JS_TAGS,
|
||||
"Angular TypeScript": JS_TAGS,
|
||||
JSX: JS_TAGS,
|
||||
TSX: JS_TAGS,
|
||||
Java: JAVA_TAGS,
|
||||
|
||||
@@ -231,6 +231,20 @@ describe("DocumentManager + slots", () => {
|
||||
expect(slot.currentState().facet(languageFacet)?.name).not.toBe("angular");
|
||||
});
|
||||
|
||||
it.each(["ts", "mts", "cts"])("loads Angular inline templates for .%s documents", async (ext) => {
|
||||
const path = `/p/src/app.${ext}`;
|
||||
const { manager } = setup({
|
||||
"/p/angular.json": "{}",
|
||||
[path]: "@Component({ template: `<r-card/>` }) class App {}",
|
||||
});
|
||||
const model = await manager.acquire(path);
|
||||
const slot = new EditorSlot(model, "t1", host);
|
||||
expect(model.getMeta()).toMatchObject({ language: "typescript", uiMode: "Angular TypeScript" });
|
||||
expect(slot.currentState().facet(languageFacet)?.name).toBe("angular-typescript");
|
||||
slot.dispose();
|
||||
manager.stop();
|
||||
});
|
||||
|
||||
it("Save All rejects a failed document save after saving the remaining dirty documents", async () => {
|
||||
const { backend, manager } = setup({ "/p/a.txt": "a\n", "/p/b.txt": "b\n" }, { files: { autoSave: false } });
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { EditorState, Facet } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { lazyEditorExtension, type EditorDocContext } from "./api";
|
||||
|
||||
const marker = Facet.define<string>();
|
||||
const ctx = (language: string) => ({ path: `/p/a.${language}`, language }) as EditorDocContext;
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve));
|
||||
|
||||
describe("lazyEditorExtension", () => {
|
||||
it("reconfigures wanted editors once loaded and applies directly afterwards", async () => {
|
||||
const load = vi.fn(async () => (c: EditorDocContext) => marker.of(c.language));
|
||||
const extensions = lazyEditorExtension((c) => c.language === "sql", load);
|
||||
|
||||
const first = new EditorView({ state: EditorState.create({ extensions: extensions(ctx("sql")) }) });
|
||||
expect(first.state.facet(marker)).toEqual([]);
|
||||
await flush();
|
||||
expect(first.state.facet(marker)).toEqual(["sql"]);
|
||||
|
||||
const second = EditorState.create({ extensions: extensions(ctx("sql")) });
|
||||
expect(second.facet(marker)).toEqual(["sql"]);
|
||||
expect(extensions(ctx("ts"))).toEqual([]);
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
first.destroy();
|
||||
});
|
||||
});
|
||||
@@ -3,13 +3,15 @@ import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { _addEditor, _removeEditor, documentSynced, type EditorDocContext } from "@/editor/api";
|
||||
|
||||
let tauri = true;
|
||||
vi.mock("@/platform/ipc", async (orig) => {
|
||||
const actual = await orig<typeof import("@/platform/ipc")>();
|
||||
return { ...actual, isTauri: () => true };
|
||||
return { ...actual, isTauri: () => tauri };
|
||||
});
|
||||
|
||||
const { lspRequest, reportLspError } = await import("./lsp");
|
||||
const { IpcError } = await import("@/platform/ipc");
|
||||
const { setInvokeRoute } = await import("@/platform/tauriCore");
|
||||
|
||||
describe("lspRequest", () => {
|
||||
it("waits until open editors' edits reached the backend", async () => {
|
||||
@@ -27,6 +29,20 @@ describe("lspRequest", () => {
|
||||
_removeEditor(handle);
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it("outside Tauri, goes through an installed command route only", async () => {
|
||||
tauri = false;
|
||||
try {
|
||||
const run = vi.fn(async () => ({ status: "ok" as const, data: 2 }));
|
||||
await expect(lspRequest(run)).rejects.toMatchObject({ kind: "unavailable" });
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
setInvokeRoute(() => null);
|
||||
expect(await lspRequest(run)).toBe(2);
|
||||
} finally {
|
||||
setInvokeRoute(null);
|
||||
tauri = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("reportLspError", () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Facet } from "@codemirror/state";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { whenAllSynced, type EditorDocContext } from "@/editor/api";
|
||||
import { IpcError, call, isCancelled, isTauri, unavailableText } from "@/platform/ipc";
|
||||
import { hasInvokeRoute } from "@/platform/tauriCore";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import type { IdeError, ReferencesIncomplete } from "@/bindings";
|
||||
import { showHint } from "./hint";
|
||||
@@ -25,7 +26,8 @@ type Result<T> = { status: "ok"; data: T } | { status: "error"; error: IdeError
|
||||
* meanwhile is not sent (the backend ignores cancelling an id it hasn't seen).
|
||||
*/
|
||||
export async function lspRequest<T>(run: () => Promise<Result<T>>, wanted?: () => boolean): Promise<T> {
|
||||
if (!isTauri()) throw new IpcError({ kind: "unavailable", message: "No language server available" });
|
||||
if (!isTauri() && !hasInvokeRoute())
|
||||
throw new IpcError({ kind: "unavailable", message: "No language server available" });
|
||||
try {
|
||||
// Edits typed just before (⌘B right after typing) must reach the server first.
|
||||
await whenAllSynced();
|
||||
|
||||
@@ -14,6 +14,3 @@
|
||||
border-radius: var(--radius-small);
|
||||
cursor: pointer;
|
||||
}
|
||||
.marker:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { registerAction } from "@/platform/actions";
|
||||
import { AgentReply } from "./AgentReply";
|
||||
import { copyReplyAction } from "./replyActions";
|
||||
|
||||
const { notify } = vi.hoisted(() => ({ notify: vi.fn() }));
|
||||
vi.mock("@/workbench/contributions", () => ({ notifications: () => ({ notify }) }));
|
||||
|
||||
const writeText = vi.fn();
|
||||
let dispose: () => void;
|
||||
beforeEach(() => {
|
||||
writeText.mockReset().mockResolvedValue(undefined);
|
||||
notify.mockReset();
|
||||
vi.stubGlobal("navigator", { ...navigator, clipboard: { writeText } });
|
||||
dispose = registerAction(copyReplyAction);
|
||||
});
|
||||
afterEach(() => {
|
||||
dispose();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const reply = [
|
||||
"**OpenIDEA v0.1.38 is out!**",
|
||||
"",
|
||||
"## Highlights",
|
||||
"",
|
||||
"- **Docker & Compose** run targets.",
|
||||
"- [Release notes](https://example.com/releases)",
|
||||
"",
|
||||
"```sh",
|
||||
"pnpm tauri dev",
|
||||
"```",
|
||||
"",
|
||||
"| Feature | Status |",
|
||||
"| --- | --- |",
|
||||
"| 中文 | Ready |",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
it("copies the exact Markdown source and briefly confirms success", async () => {
|
||||
vi.useFakeTimers();
|
||||
render(<AgentReply text={reply} cwd="/repo" />);
|
||||
expect(screen.getByRole("heading", { name: "Highlights" })).toBeInTheDocument();
|
||||
await act(async () => fireEvent.click(screen.getByRole("button", { name: "Copy Markdown" })));
|
||||
expect(writeText).toHaveBeenCalledExactlyOnceWith(reply);
|
||||
expect(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument();
|
||||
act(() => vi.advanceTimersByTime(2000));
|
||||
expect(screen.getByRole("button", { name: "Copy Markdown" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("copies the latest streamed text and clears feedback when content changes", async () => {
|
||||
const { rerender } = render(<AgentReply text="**Partial**" cwd="/repo" />);
|
||||
await act(async () => fireEvent.click(screen.getByRole("button", { name: "Copy Markdown" })));
|
||||
rerender(<AgentReply text={reply} cwd="/repo" />);
|
||||
await act(async () => fireEvent.click(screen.getByRole("button", { name: "Copy Markdown" })));
|
||||
expect(writeText.mock.calls.map(([text]) => text)).toEqual(["**Partial**", reply]);
|
||||
});
|
||||
|
||||
it("reports a clipboard failure without claiming success and allows retry", async () => {
|
||||
writeText.mockRejectedValueOnce(new Error("denied"));
|
||||
render(<AgentReply text={reply} cwd="/repo" />);
|
||||
await act(async () => fireEvent.click(screen.getByRole("button", { name: "Copy Markdown" })));
|
||||
expect(notify).toHaveBeenCalledWith(expect.objectContaining({ level: "error", title: "Couldn't copy the reply" }));
|
||||
expect(screen.queryByRole("button", { name: "Copied" })).not.toBeInTheDocument();
|
||||
await act(async () => fireEvent.click(screen.getByRole("button", { name: "Copy Markdown" })));
|
||||
expect(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(["", " \n\t"])("does not offer copying an empty reply (%j)", (text) => {
|
||||
render(<AgentReply text={text} cwd="/repo" />);
|
||||
expect(screen.queryByRole("button", { name: "Copy Markdown" })).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import type { AgentTask } from "@/bindings";
|
||||
import { IconButton } from "@/ui";
|
||||
import { Markdown } from "./Markdown";
|
||||
import { copyReplyAction, type CopyReply } from "./replyActions";
|
||||
import { replyOperations, type ReplyTurn } from "./model";
|
||||
import styles from "./Session.module.css";
|
||||
|
||||
export function AgentReply({
|
||||
text,
|
||||
cwd,
|
||||
task,
|
||||
reply,
|
||||
turnCount = 0,
|
||||
onDiagramDrawn,
|
||||
}: {
|
||||
text: string;
|
||||
cwd: string;
|
||||
task?: AgentTask;
|
||||
reply?: ReplyTurn;
|
||||
turnCount?: number;
|
||||
onDiagramDrawn?: () => void;
|
||||
}) {
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const operations = task ? replyOperations(task, reply, turnCount) : { regenerate: false, fork: false };
|
||||
useEffect(() => {
|
||||
if (copied === null) return;
|
||||
const timer = setTimeout(() => setCopied(null), 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [copied]);
|
||||
return (
|
||||
<div className={styles.reply}>
|
||||
<Markdown text={text} cwd={cwd} onDiagramDrawn={onDiagramDrawn} />
|
||||
{text.trim() && (
|
||||
<div className={styles.replyFooter}>
|
||||
<span className={clsx(styles.messageActions, styles.replyActions)} data-copied={copied === text || undefined}>
|
||||
<IconButton
|
||||
icon={copied === text ? "check" : "copy"}
|
||||
title={copied === text ? "Copied" : "Copy Markdown"}
|
||||
size="small"
|
||||
action={copyReplyAction.id}
|
||||
actionArg={{ text, onCopied: () => setCopied(text) } satisfies CopyReply}
|
||||
/>
|
||||
{task && task.status !== "archived" && (
|
||||
<IconButton
|
||||
icon="quote"
|
||||
title="Quote Reply"
|
||||
size="small"
|
||||
action="agents.quoteReply"
|
||||
actionArg={{ task: task.id, text }}
|
||||
/>
|
||||
)}
|
||||
{task && reply && operations.regenerate && (
|
||||
<IconButton
|
||||
icon="refresh"
|
||||
title="Regenerate Response"
|
||||
size="small"
|
||||
action="agents.regenerateReply"
|
||||
actionArg={{ task: task.id, turn: reply.turn }}
|
||||
/>
|
||||
)}
|
||||
{task && reply && operations.fork && (
|
||||
<IconButton
|
||||
icon="branch"
|
||||
title="Branch Conversation from Here"
|
||||
size="small"
|
||||
action="agents.forkReply"
|
||||
actionArg={{ task: task.id, turn: reply.turn }}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { clearMocks, mockIPC } from "@tauri-apps/api/mocks";
|
||||
import { resetMermaidForTests } from "../viewers/markdown/mermaid";
|
||||
import { StrictMode } from "react";
|
||||
import { Markdown } from "./Markdown";
|
||||
|
||||
const { openFile, openExternalUrl } = vi.hoisted(() => ({
|
||||
@@ -40,6 +41,24 @@ beforeEach(() => {
|
||||
});
|
||||
afterEach(clearMocks);
|
||||
|
||||
it("keeps one copy control outside each code block when effects restart", () => {
|
||||
const { container } = render(
|
||||
<StrictMode>
|
||||
<Markdown
|
||||
text={"Before\n\n```text\nrust/\n└── src/main.rs\n```\n\n```ts\nconst a = 1;\n```\n\nAfter"}
|
||||
cwd="/repo"
|
||||
/>
|
||||
</StrictMode>,
|
||||
);
|
||||
expect(screen.getAllByRole("button", { name: "Copy Code" })).toHaveLength(2);
|
||||
const blocks = [...container.querySelectorAll("pre")];
|
||||
expect(blocks.map((pre) => pre.textContent)).toEqual(["rust/\n└── src/main.rs", "const a = 1;"]);
|
||||
for (const pre of blocks) {
|
||||
expect(pre.querySelector("button")).toBeNull();
|
||||
expect(pre.parentElement!.querySelectorAll(":scope > button[data-copy]")).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
"<style>body{outline:7px solid red}</style>",
|
||||
'<form action="https://example.com/x"><button>go</button></form>',
|
||||
|
||||
@@ -84,6 +84,14 @@ function renderHtml(text: string): string {
|
||||
ADD_ATTR: ["data-mermaid"],
|
||||
}) as HTMLElement;
|
||||
root.querySelectorAll("li").forEach(wrapLooseText);
|
||||
// WebKit cuts controls inside <pre> out of a text selection, even when transparent.
|
||||
root.querySelectorAll("pre").forEach((pre) => {
|
||||
if (pre.parentElement?.hasAttribute("data-mermaid")) return;
|
||||
const block = document.createElement("div");
|
||||
block.className = styles.codeBlock!;
|
||||
pre.replaceWith(block);
|
||||
block.appendChild(pre);
|
||||
});
|
||||
return root.innerHTML;
|
||||
}
|
||||
|
||||
@@ -136,6 +144,7 @@ export function openPathLink(link: PathLink) {
|
||||
function enhance(root: HTMLElement, cwd: string, images: Map<string, Promise<string>>): () => void {
|
||||
let alive = true;
|
||||
const disposeImages = loadImages(root, cwd, images);
|
||||
const copies: HTMLButtonElement[] = [];
|
||||
root.querySelectorAll<HTMLElement>("pre > code").forEach((code) => {
|
||||
const pre = code.parentElement!;
|
||||
const lang = [...code.classList].find((c) => c.startsWith("language-"))?.slice("language-".length) ?? null;
|
||||
@@ -149,14 +158,10 @@ function enhance(root: HTMLElement, cwd: string, images: Map<string, Promise<str
|
||||
copy.setAttribute("aria-label", label);
|
||||
copy.appendChild(iconNode("copy", 14));
|
||||
copy.dataset.copy = text;
|
||||
if (pre.parentElement?.hasAttribute("data-mermaid")) {
|
||||
// Mermaid source: not highlighted (the diagram draws over it), and the Copy
|
||||
// button sits on the block — the drawn diagram hides the code block itself.
|
||||
pre.parentElement.appendChild(copy);
|
||||
return;
|
||||
}
|
||||
void highlightInto(code, text, lang);
|
||||
pre.appendChild(copy);
|
||||
const block = pre.parentElement!;
|
||||
block.appendChild(copy);
|
||||
copies.push(copy);
|
||||
if (!block.hasAttribute("data-mermaid")) void highlightInto(code, text, lang);
|
||||
});
|
||||
root.querySelectorAll<HTMLElement>("code").forEach((code) => {
|
||||
if (code.parentElement?.tagName === "PRE" || code.closest("a")) return;
|
||||
@@ -173,6 +178,7 @@ function enhance(root: HTMLElement, cwd: string, images: Map<string, Promise<str
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
copies.forEach((copy) => copy.remove());
|
||||
disposeImages();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,13 +30,6 @@
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* The conversation hugs its content; spare space stays below the composer
|
||||
instead of inside the chat. */
|
||||
.streamArea,
|
||||
.stream {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -149,33 +142,43 @@
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 14px 16px 8px;
|
||||
padding: calc(var(--space-3) * 2) var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: calc(var(--space-3) * 2);
|
||||
}
|
||||
/* Actual row heights keep WebKit's scrollbar and cross-message selections anchored. */
|
||||
.stream > * {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
max-width: var(--agent-conversation-width);
|
||||
margin-inline: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.stream:focus {
|
||||
outline: none;
|
||||
}
|
||||
/*
|
||||
* Skip off-screen output layout; editable messages must repaint immediately when focus/content changes.
|
||||
* Thinking and tool output sit in a <div>: on <details> itself WebKit wouldn't expand them.
|
||||
*/
|
||||
.stream > :not(.message):not(details) {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-block-size: auto 80px;
|
||||
}
|
||||
|
||||
/* Your message with its actions, shown on hover. */
|
||||
.message {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.messageMeta {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 20px;
|
||||
padding-left: 12px;
|
||||
max-width: 100%;
|
||||
padding-top: 2px;
|
||||
font-size: var(--fs-small);
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.messageActions {
|
||||
display: inline-flex;
|
||||
@@ -187,11 +190,48 @@
|
||||
user-select: none;
|
||||
}
|
||||
.message:hover .messageActions,
|
||||
.message:hover .messageMeta,
|
||||
.reply:hover .messageActions,
|
||||
.message:focus-within .messageActions,
|
||||
.message:focus-within .messageMeta,
|
||||
.reply:focus-within .messageActions,
|
||||
.messageActions[data-copied],
|
||||
.messageActions:focus-within {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.reply {
|
||||
position: relative;
|
||||
}
|
||||
.replyFooter {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
display: flex;
|
||||
min-height: 20px;
|
||||
padding-top: 2px;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.replyActions {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.messageActions {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.messageMeta,
|
||||
.replyFooter {
|
||||
position: static;
|
||||
margin-top: 4px;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.findBar {
|
||||
flex: none;
|
||||
display: flex;
|
||||
@@ -218,9 +258,13 @@
|
||||
}
|
||||
|
||||
.user {
|
||||
padding: 2px 0 2px 10px;
|
||||
border-left: 2px solid var(--accent);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-popup);
|
||||
background: var(--bg-elev);
|
||||
color: var(--fg-strong);
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -277,11 +321,16 @@
|
||||
overflow: auto;
|
||||
}
|
||||
.markdown :global(pre code) {
|
||||
display: block;
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
.markdown :global(pre) {
|
||||
.codeBlock {
|
||||
position: relative;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.codeBlock > :global(pre) {
|
||||
margin: 0;
|
||||
}
|
||||
.codeCopy {
|
||||
position: absolute;
|
||||
@@ -299,8 +348,10 @@
|
||||
color: var(--fg-secondary);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.markdown :global(pre:hover) .codeCopy,
|
||||
.codeBlock:hover > .codeCopy,
|
||||
.markdown :global([data-mermaid]:hover) .codeCopy,
|
||||
.codeCopy:focus-visible {
|
||||
opacity: 1;
|
||||
@@ -418,8 +469,27 @@
|
||||
display: none;
|
||||
}
|
||||
.thought summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: fit-content;
|
||||
color: var(--fg-muted);
|
||||
font-size: var(--fs-small);
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.thought summary:hover {
|
||||
color: var(--fg);
|
||||
}
|
||||
.thoughtChevron {
|
||||
flex: none;
|
||||
}
|
||||
.thought[open] .thoughtChevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.thoughtBody {
|
||||
margin-top: var(--space-2);
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
.thoughtText {
|
||||
margin-top: 4px;
|
||||
@@ -563,7 +633,11 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 6px 12px 12px;
|
||||
align-self: center;
|
||||
width: 100%;
|
||||
max-width: calc(var(--agent-conversation-width) + var(--space-4) * 2);
|
||||
box-sizing: border-box;
|
||||
padding: var(--space-2) var(--space-4) var(--space-4);
|
||||
}
|
||||
|
||||
.background {
|
||||
|
||||
@@ -56,6 +56,7 @@ import { canSend, costLabel, emptyDraft, limitLabel, retryParts, usageLabel, wor
|
||||
import { ConfigPickers } from "./ConfigPickers";
|
||||
import { findRanges, formatDuration, formatSentAt } from "./conversation";
|
||||
import { ImagePreview, imageSrc } from "./ImagePreview";
|
||||
import { AgentReply } from "./AgentReply";
|
||||
import { Markdown } from "./Markdown";
|
||||
import {
|
||||
branchLabel,
|
||||
@@ -68,6 +69,8 @@ import {
|
||||
subagentTitle,
|
||||
subagentWorking,
|
||||
turnNumbers,
|
||||
replyTurns,
|
||||
type ReplyTurn,
|
||||
type SubagentEntry,
|
||||
} from "./model";
|
||||
import {
|
||||
@@ -79,7 +82,6 @@ import {
|
||||
editQueued,
|
||||
forkTask,
|
||||
rerunFrom,
|
||||
quoteInComposer,
|
||||
removeQueued,
|
||||
resendMessage,
|
||||
respond,
|
||||
@@ -492,6 +494,7 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
|
||||
const lastUser = entries?.findLast((e): e is UserEntry => e.type === "user");
|
||||
// Forks and undo apply to the task's own turns, not a subagent's conversation.
|
||||
const turns = useMemo(() => (entries && !subagent ? turnNumbers(entries) : null), [entries, subagent]);
|
||||
const replies = useMemo(() => (entries && !subagent ? replyTurns(entries) : null), [entries, subagent]);
|
||||
return (
|
||||
<div className={styles.streamArea}>
|
||||
{finding && <FindBar streamRef={ref} focus={findFocus} onClose={onCloseFind} />}
|
||||
@@ -510,7 +513,7 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
|
||||
const text = selectionIn(e.currentTarget);
|
||||
const items = copyMenuFor(e.target) ?? [];
|
||||
if (text.trim() && task.status !== "archived") {
|
||||
items.push({ id: "quote", label: "Quote", icon: "edit", run: () => quoteInComposer(task.id, text) });
|
||||
items.push({ kind: "action", action: "agents.quoteReply", label: "Quote", arg: { task: task.id, text } });
|
||||
}
|
||||
if (!items.length) return;
|
||||
e.preventDefault();
|
||||
@@ -529,6 +532,11 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
|
||||
entry={entry}
|
||||
turn={turns?.get(start + i) ?? null}
|
||||
turnCount={turns?.size ?? 0}
|
||||
reply={replies?.get(start + i)}
|
||||
working={
|
||||
start + i === entries.length - 1 &&
|
||||
(subagent ? subagentWorking(subagent.status) : isBusy(task.status))
|
||||
}
|
||||
onOpenSubagent={onOpenSubagent}
|
||||
onDiagramDrawn={onDiagramDrawn}
|
||||
/>
|
||||
@@ -761,6 +769,8 @@ const Entry = memo(function Entry({
|
||||
entry,
|
||||
turn,
|
||||
turnCount,
|
||||
reply,
|
||||
working,
|
||||
onOpenSubagent,
|
||||
onDiagramDrawn,
|
||||
}: {
|
||||
@@ -769,6 +779,8 @@ const Entry = memo(function Entry({
|
||||
/** Number of the turn this message started (from 0), when it started one. */
|
||||
turn: number | null;
|
||||
turnCount: number;
|
||||
reply?: ReplyTurn;
|
||||
working: boolean;
|
||||
onOpenSubagent: (id: string) => void;
|
||||
onDiagramDrawn: () => void;
|
||||
}) {
|
||||
@@ -776,15 +788,31 @@ const Entry = memo(function Entry({
|
||||
case "user":
|
||||
return <UserMessage task={task} entry={entry} turn={turn} turnCount={turnCount} />;
|
||||
case "agent":
|
||||
return <Markdown text={entry.text} cwd={task.cwd} onDiagramDrawn={onDiagramDrawn} />;
|
||||
return (
|
||||
<AgentReply
|
||||
text={entry.text}
|
||||
cwd={task.cwd}
|
||||
task={task}
|
||||
reply={reply}
|
||||
turnCount={turnCount}
|
||||
onDiagramDrawn={onDiagramDrawn}
|
||||
/>
|
||||
);
|
||||
case "thought":
|
||||
return (
|
||||
<div>
|
||||
<details className={styles.thought}>
|
||||
<summary>
|
||||
<T>Thinking</T>
|
||||
{working ? (
|
||||
<Spinner size={12} />
|
||||
) : (
|
||||
<Icon name="chevron-right" size={12} className={styles.thoughtChevron} />
|
||||
)}
|
||||
<T>{working ? "Thinking" : "Reasoning"}</T>
|
||||
</summary>
|
||||
<div className={styles.thoughtText}>{entry.text}</div>
|
||||
<div className={styles.thoughtBody}>
|
||||
<Markdown text={entry.text} cwd={task.cwd} onDiagramDrawn={onDiagramDrawn} />
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
@@ -829,7 +857,7 @@ const Entry = memo(function Entry({
|
||||
<div className={styles.messageFrom}>
|
||||
<T>Report to the parent agent</T>
|
||||
</div>
|
||||
<Markdown text={entry.text} cwd={task.cwd} onDiagramDrawn={onDiagramDrawn} />
|
||||
<AgentReply text={entry.text} cwd={task.cwd} task={task} onDiagramDrawn={onDiagramDrawn} />
|
||||
</div>
|
||||
);
|
||||
case "permission":
|
||||
@@ -1088,7 +1116,12 @@ function ToolEntry({ task, entry }: { task: AgentTask; entry: Extract<AgentEntry
|
||||
{entry.status === "failed" && <Icon name="error" size={12} />}
|
||||
</>
|
||||
);
|
||||
if (!entry.output) return <div className={styles.tool}>{head}</div>;
|
||||
if (!entry.output)
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.tool}>{head}</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<details className={styles.toolDetails}>
|
||||
@@ -1190,7 +1223,7 @@ function SubagentCard({
|
||||
<T>Result</T>
|
||||
</summary>
|
||||
<div className={styles.subagentReply}>
|
||||
<Markdown text={entry.reply} cwd={task.cwd} onDiagramDrawn={onDiagramDrawn} />
|
||||
<AgentReply text={entry.reply} cwd={task.cwd} task={task} onDiagramDrawn={onDiagramDrawn} />
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { sendPaths, sendSelection } from "./references";
|
||||
import { focusedSession } from "./sessionFocus";
|
||||
import { connect, disconnect, openHome, useAgents } from "./store";
|
||||
import { taskActions } from "./taskActions";
|
||||
import { replyActions } from "./replyActions";
|
||||
|
||||
/** Settings › Tools › Agents (also used by the Settings window). */
|
||||
export function activateSettingsPages(): () => void {
|
||||
@@ -100,6 +101,7 @@ export function activate(): () => void {
|
||||
}),
|
||||
registerActions([
|
||||
...taskActions,
|
||||
...replyActions,
|
||||
{
|
||||
id: "agents.findInConversation",
|
||||
title: "Find in Conversation",
|
||||
|
||||
@@ -25,7 +25,8 @@ vi.mock("@tauri-apps/api/webview", () => ({
|
||||
}));
|
||||
|
||||
import { SessionEditor } from "./SessionEditor";
|
||||
import { updateComposer, useAgents } from "./store";
|
||||
import { composerDraft, sendMessage, updateComposer, useAgents } from "./store";
|
||||
import { replyActions } from "./replyActions";
|
||||
|
||||
const task: AgentTask = {
|
||||
id: "editing",
|
||||
@@ -64,6 +65,46 @@ async function edit() {
|
||||
return { user, box, send: screen.getAllByRole("button", { name: "Send" })[0]! };
|
||||
}
|
||||
|
||||
it.each([false, true])("recovers images after failed regeneration (new draft: %s)", async (draft) => {
|
||||
const image = { mimeType: "image/png", data: "AAA", name: "shot.png" };
|
||||
const added = { mimeType: "image/png", data: "BBB", name: "added.png" };
|
||||
useAgents.setState({
|
||||
entries: new Map([
|
||||
[
|
||||
task.id,
|
||||
[
|
||||
{ type: "user", text: "Original message", attachments: [image.name], images: [image], steered: false },
|
||||
entries[1]!,
|
||||
],
|
||||
],
|
||||
]),
|
||||
});
|
||||
ipc.rollback.mockImplementation(async () => {
|
||||
useAgents.setState({ entries: new Map([[task.id, []]]) });
|
||||
return { status: "ok", data: null };
|
||||
});
|
||||
let finish!: (value: unknown) => void;
|
||||
ipc.prompt.mockReturnValueOnce(new Promise((resolve) => (finish = resolve)));
|
||||
const running = replyActions
|
||||
.find((a) => a.id === "agents.regenerateReply")!
|
||||
.run({ ctx: {}, arg: { task: task.id, turn: 0 } });
|
||||
await waitFor(() => expect(ipc.prompt).toHaveBeenCalledTimes(1));
|
||||
const files = draft ? [{ path: "/project/a.ts", rel: "a.ts" }] : [];
|
||||
if (draft) updateComposer(task.id, { text: "Another draft @a.ts", files, images: [added] });
|
||||
finish({ status: "error", error: { kind: "internal", message: "Send failed" } });
|
||||
await running;
|
||||
const expected = {
|
||||
text: draft ? "Original message\nAnother draft @a.ts" : "Original message",
|
||||
files,
|
||||
images: draft ? [image, added] : [image],
|
||||
};
|
||||
expect(composerDraft(task.id)).toEqual(expected);
|
||||
expect(useAgents.getState().focus?.key).toBe(task.id);
|
||||
await sendMessage(task.id, false);
|
||||
expect(ipc.prompt).toHaveBeenLastCalledWith(task.id, { ...expected, files: files.map((f) => f.path) }, false);
|
||||
expect(ipc.rollback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe("a sent message with images", () => {
|
||||
afterEach(() => {
|
||||
entries[0] = { type: "user", text: "Original message", attachments: [], sentMs: 1, doneMs: 2, steered: false };
|
||||
|
||||
@@ -126,6 +126,49 @@ export function turnNumbers(entries: AgentEntry[]): Map<number, number> {
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface ReplyTurn {
|
||||
turn: number;
|
||||
prompt: Extract<AgentEntry, { type: "user" }>;
|
||||
steered: boolean;
|
||||
}
|
||||
|
||||
/** Only the last reply of a turn offers actions that change the conversation. */
|
||||
export function replyTurns(entries: AgentEntry[]): Map<number, ReplyTurn> {
|
||||
const out = new Map<number, ReplyTurn>();
|
||||
let current: ReplyTurn | null = null;
|
||||
let previous: number | null = null;
|
||||
let turn = 0;
|
||||
entries.forEach((entry, i) => {
|
||||
if (entry.type === "user") {
|
||||
if (!entry.steered) {
|
||||
current = { turn: turn++, prompt: entry, steered: false };
|
||||
previous = null;
|
||||
} else if (current) current.steered = true;
|
||||
} else if (entry.type === "agent" && entry.text.trim() && current) {
|
||||
if (previous !== null) out.delete(previous);
|
||||
out.set(i, current);
|
||||
previous = i;
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export function replyOperations(task: AgentTask, reply: ReplyTurn | undefined, turnCount: number) {
|
||||
const idle = task.status !== "archived" && !isBusy(task.status) && !!reply;
|
||||
const latest = reply?.turn === turnCount - 1;
|
||||
const { fork = false, rollback = false } = task.capabilities ?? {};
|
||||
return {
|
||||
regenerate:
|
||||
idle &&
|
||||
latest &&
|
||||
rollback &&
|
||||
!reply.steered &&
|
||||
!!reply.prompt.text.trim() &&
|
||||
reply.prompt.attachments.length === (reply.prompt.images?.length ?? 0),
|
||||
fork: idle && fork && (rollback || latest),
|
||||
};
|
||||
}
|
||||
|
||||
/** "3m", "2h", "5d" since `ms`. */
|
||||
export function age(ms: number, now = Date.now()): string {
|
||||
const s = Math.max(0, Math.round((now - ms) / 1000));
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AgentEntry, AgentTask } from "@/bindings";
|
||||
import { replyOperations, replyTurns } from "./model";
|
||||
import { replyActions } from "./replyActions";
|
||||
|
||||
const backend = vi.hoisted(() => ({
|
||||
tasks: new Map<string, AgentTask>(),
|
||||
entries: new Map<string, AgentEntry[]>(),
|
||||
forkTask: vi.fn(),
|
||||
rerunFrom: vi.fn(),
|
||||
quoteInComposer: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
}));
|
||||
vi.mock("./store", () => ({
|
||||
useAgents: { getState: () => backend },
|
||||
forkTask: backend.forkTask,
|
||||
rerunFrom: backend.rerunFrom,
|
||||
quoteInComposer: backend.quoteInComposer,
|
||||
}));
|
||||
vi.mock("@/workbench/contributions", () => ({ notifications: () => ({ notify: backend.notify }) }));
|
||||
|
||||
const prompt: Extract<AgentEntry, { type: "user" }> = {
|
||||
type: "user",
|
||||
text: "Original request",
|
||||
attachments: [],
|
||||
sentMs: 0,
|
||||
doneMs: 1,
|
||||
steered: false,
|
||||
};
|
||||
const task: AgentTask = {
|
||||
id: "t1",
|
||||
title: "Task",
|
||||
provider: "codex",
|
||||
status: "ready",
|
||||
isolation: "local",
|
||||
model: null,
|
||||
cwd: "/repo",
|
||||
branch: null,
|
||||
base: null,
|
||||
baseCommit: null,
|
||||
error: null,
|
||||
createdMs: 0,
|
||||
updatedMs: 0,
|
||||
config: [],
|
||||
commands: [],
|
||||
usage: null,
|
||||
queue: [],
|
||||
capabilities: { fork: true, rollback: true, steer: true, images: true },
|
||||
};
|
||||
const entries: AgentEntry[] = [prompt, { type: "agent", text: "**Answer**" }];
|
||||
const run = (id: string, arg: unknown = { task: task.id, turn: 0 }) =>
|
||||
replyActions.find((action) => action.id === `agents.${id}`)!.run({ ctx: {}, arg });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
backend.forkTask.mockResolvedValue(undefined);
|
||||
backend.rerunFrom.mockResolvedValue(undefined);
|
||||
backend.tasks = new Map([[task.id, task]]);
|
||||
backend.entries = new Map([[task.id, entries]]);
|
||||
});
|
||||
|
||||
describe("reply turns", () => {
|
||||
it("offers conversation actions only on the final nonempty reply of each turn", () => {
|
||||
const result = replyTurns([
|
||||
{ type: "agent", text: "Unassociated reply" },
|
||||
prompt,
|
||||
{ type: "agent", text: "Progress" },
|
||||
{ ...prompt, text: "Additional instruction", steered: true },
|
||||
{ type: "agent", text: "Done" },
|
||||
{ ...prompt, text: "Next request" },
|
||||
{ type: "agent", text: "Next answer" },
|
||||
{ type: "agent", text: " " },
|
||||
]);
|
||||
expect([...result].map(([index, reply]) => [index, reply.turn, reply.steered])).toEqual([
|
||||
[4, 0, true],
|
||||
[6, 1, false],
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["ready", true],
|
||||
["running", false],
|
||||
["starting", false],
|
||||
["needsInput", false],
|
||||
["archived", false],
|
||||
] as const)("enables conversation actions when %s: %s", (status, enabled) => {
|
||||
expect(replyOperations({ ...task, status }, { turn: 0, prompt, steered: false }, 1)).toEqual({
|
||||
regenerate: enabled,
|
||||
fork: enabled,
|
||||
});
|
||||
});
|
||||
|
||||
it("requires rollback for regeneration and forking earlier turns", () => {
|
||||
const older = { turn: 0, prompt, steered: false };
|
||||
expect(replyOperations(task, older, 2)).toEqual({ regenerate: false, fork: true });
|
||||
const noRollback = { ...task, capabilities: { ...task.capabilities!, rollback: false } };
|
||||
expect(replyOperations(noRollback, older, 2)).toEqual({ regenerate: false, fork: false });
|
||||
expect(replyOperations(noRollback, older, 1)).toEqual({ regenerate: false, fork: true });
|
||||
});
|
||||
});
|
||||
|
||||
it("quotes raw Markdown without sending a prompt", async () => {
|
||||
await run("quoteReply", { task: task.id, text: "**Answer**" });
|
||||
expect(backend.quoteInComposer).toHaveBeenCalledWith(task.id, "**Answer**");
|
||||
expect(backend.rerunFrom).not.toHaveBeenCalled();
|
||||
backend.tasks.set(task.id, { ...task, status: "archived" });
|
||||
await run("quoteReply", { task: task.id, text: "More" });
|
||||
expect(backend.quoteInComposer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("regenerates using the original prompt and its images", async () => {
|
||||
const image = { mimeType: "image/png", data: "AAA", name: "shot.png" };
|
||||
backend.entries.set(task.id, [{ ...prompt, attachments: [image.name], images: [image] }, entries[1]!]);
|
||||
await run("regenerateReply");
|
||||
expect(backend.rerunFrom).toHaveBeenCalledWith(task.id, 0, prompt.text, [image]);
|
||||
});
|
||||
|
||||
it.each(["files", "steered", "earlier", "busy", "unsupported", "subagent"])(
|
||||
"does not replay an invalid request: %s",
|
||||
async (kind) => {
|
||||
if (kind === "files") backend.entries.set(task.id, [{ ...prompt, attachments: ["main.rs"] }, entries[1]!]);
|
||||
if (kind === "steered") backend.entries.set(task.id, [...entries, { ...prompt, steered: true }]);
|
||||
if (kind === "earlier") backend.entries.set(task.id, [...entries, prompt, entries[1]!]);
|
||||
if (kind === "busy") backend.tasks.set(task.id, { ...task, status: "running" });
|
||||
if (kind === "unsupported") backend.tasks.set(task.id, { ...task, capabilities: undefined });
|
||||
if (kind === "subagent") backend.entries.set(task.id, [{ type: "agent", text: "Child reply" }]);
|
||||
await run("regenerateReply");
|
||||
expect(backend.rerunFrom).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("branches after the reply's turn", async () => {
|
||||
await run("forkReply");
|
||||
expect(backend.forkTask).toHaveBeenCalledWith(task.id, 1);
|
||||
});
|
||||
|
||||
it("reports regeneration errors and allows retry", async () => {
|
||||
backend.rerunFrom.mockRejectedValueOnce(new Error("Unavailable"));
|
||||
await run("regenerateReply");
|
||||
expect(backend.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: "error", title: "Couldn't regenerate the response" }),
|
||||
);
|
||||
await run("regenerateReply");
|
||||
expect(backend.rerunFrom).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("ignores duplicate clicks while regeneration is pending", async () => {
|
||||
let finish!: () => void;
|
||||
backend.rerunFrom.mockReturnValueOnce(
|
||||
new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
const first = run("regenerateReply");
|
||||
await run("regenerateReply");
|
||||
expect(backend.rerunFrom).toHaveBeenCalledTimes(1);
|
||||
finish();
|
||||
await first;
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { Action } from "@/platform/actions";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { replyOperations, replyTurns, turnNumbers } from "./model";
|
||||
import { forkTask, quoteInComposer, rerunFrom, useAgents } from "./store";
|
||||
|
||||
export interface CopyReply {
|
||||
text: string;
|
||||
onCopied?: () => void;
|
||||
}
|
||||
|
||||
export const copyReplyAction: Action = {
|
||||
id: "agents.copyReplyMarkdown",
|
||||
title: "Copy Markdown",
|
||||
category: "Agents",
|
||||
icon: "copy",
|
||||
hidden: true,
|
||||
run: async ({ arg }) => {
|
||||
const reply = arg as CopyReply | undefined;
|
||||
if (!reply || typeof reply.text !== "string" || !reply.text.trim()) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(reply.text);
|
||||
reply.onCopied?.();
|
||||
} catch (e) {
|
||||
notifications().notify({ level: "error", title: "Couldn't copy the reply", body: String(e) });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
interface ReplyTarget {
|
||||
task: string;
|
||||
turn: number;
|
||||
}
|
||||
|
||||
function resolveReply(arg: unknown) {
|
||||
const target = arg as ReplyTarget | undefined;
|
||||
if (!target || typeof target.task !== "string" || !Number.isInteger(target.turn)) return null;
|
||||
const task = useAgents.getState().tasks.get(target.task);
|
||||
const entries = useAgents.getState().entries.get(target.task);
|
||||
if (!task || !entries) return null;
|
||||
const reply = [...replyTurns(entries).values()].find((r) => r.turn === target.turn);
|
||||
if (!reply) return null;
|
||||
return { task, reply, operations: replyOperations(task, reply, turnNumbers(entries).size) };
|
||||
}
|
||||
|
||||
const pending = new Set<string>();
|
||||
async function runReplyOperation(task: string, title: string, run: () => Promise<void>) {
|
||||
if (pending.has(task)) return;
|
||||
pending.add(task);
|
||||
try {
|
||||
await run();
|
||||
} catch (e) {
|
||||
notifications().notify({ level: "error", title, body: String(e) });
|
||||
} finally {
|
||||
pending.delete(task);
|
||||
}
|
||||
}
|
||||
|
||||
export const replyActions: Action[] = [
|
||||
copyReplyAction,
|
||||
{
|
||||
id: "agents.quoteReply",
|
||||
title: "Quote Reply",
|
||||
category: "Agents",
|
||||
icon: "quote",
|
||||
hidden: true,
|
||||
run: ({ arg }) => {
|
||||
const target = arg as { task: string; text: string } | undefined;
|
||||
if (!target || typeof target.text !== "string" || !target.text.trim()) return;
|
||||
const task = useAgents.getState().tasks.get(target.task);
|
||||
if (task && task.status !== "archived") quoteInComposer(task.id, target.text);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "agents.regenerateReply",
|
||||
title: "Regenerate Response",
|
||||
category: "Agents",
|
||||
icon: "refresh",
|
||||
hidden: true,
|
||||
run: ({ arg }) => {
|
||||
const resolved = resolveReply(arg);
|
||||
if (!resolved?.operations.regenerate) return;
|
||||
const { task, reply } = resolved;
|
||||
return runReplyOperation(task.id, "Couldn't regenerate the response", () =>
|
||||
rerunFrom(task.id, reply.turn, reply.prompt.text, reply.prompt.images ?? []),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "agents.forkReply",
|
||||
title: "Branch Conversation from Here",
|
||||
category: "Agents",
|
||||
icon: "branch",
|
||||
hidden: true,
|
||||
run: ({ arg }) => {
|
||||
const resolved = resolveReply(arg);
|
||||
if (!resolved?.operations.fork) return;
|
||||
const { task, reply } = resolved;
|
||||
return runReplyOperation(task.id, "Couldn't copy the task", () => forkTask(task.id, reply.turn + 1));
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -555,9 +555,12 @@ export async function rerunFrom(task: string, turn: number, text: string, images
|
||||
try {
|
||||
await call(commands.agentPrompt(task, { text, files: [], images }, false));
|
||||
} catch (e) {
|
||||
// Rollback removes the inline editor; recover its text in the session composer.
|
||||
// Rollback removes the original turn; recover its prompt in the session composer.
|
||||
const current = composerDraft(task);
|
||||
updateComposer(task, { text: current.text ? `${text}\n${current.text}` : text });
|
||||
updateComposer(task, {
|
||||
text: current.text ? `${text}\n${current.text}` : text,
|
||||
images: [...images, ...current.images],
|
||||
});
|
||||
focusComposer(task);
|
||||
throw e;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,8 @@ import { tx } from "@/platform/i18n";
|
||||
/** `templates` feature's language-data key for finer template contexts. */
|
||||
const TEMPLATE_CONTEXT_KEY = "openideaTemplateContexts";
|
||||
|
||||
export const SQL_LANGUAGE = "sql";
|
||||
import { SQL_LANGUAGE } from "./sqlLanguage";
|
||||
export { SQL_LANGUAGE };
|
||||
const ROW_LIMITS = ["100", "500", "1000", "5000", "50000"];
|
||||
|
||||
/** Statement outcomes of the last run, kept on the text (IDEA's gutter check / error and timing). */
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
* results grids, and a table data editor. Connections, introspection and
|
||||
* statements run in Rust (`ide-db`).
|
||||
*/
|
||||
import { lazyComponent } from "@/ui";
|
||||
import { activeEditor, registerEditorContribution } from "@/editor/api";
|
||||
import { lazyComponent, lazyWhen } from "@/ui";
|
||||
import { activeEditor, lazyEditorExtension, registerEditorContribution } from "@/editor/api";
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import { activeTableEditor, installTableEditorFocus } from "./tableEditorFocus";
|
||||
import { useProject } from "@/platform/project";
|
||||
@@ -17,8 +17,7 @@ import {
|
||||
setFileDecorations,
|
||||
toolWindowService,
|
||||
} from "@/workbench/contributions";
|
||||
import { SQL_LANGUAGE, consoleExtension, executeInView, explainInView } from "./console";
|
||||
import { DatabaseDialogs } from "./Dialogs";
|
||||
import { SQL_LANGUAGE } from "./sqlLanguage";
|
||||
import { TABLE_KIND, tableTitle } from "./tableInput";
|
||||
import { nodeTarget, type DbNode } from "./nodes";
|
||||
import { registerDatabaseTreeActions } from "./treeActions";
|
||||
@@ -35,6 +34,8 @@ import {
|
||||
watchIdleConnections,
|
||||
} from "./store";
|
||||
|
||||
const useDialogOpen = () => useDatabase((s) => !!s.dialog);
|
||||
|
||||
export function activate(): () => void {
|
||||
const disposers = [
|
||||
registerToolWindow({
|
||||
@@ -60,8 +61,18 @@ export function activate(): () => void {
|
||||
title: tableTitle,
|
||||
icon: () => "database",
|
||||
}),
|
||||
registerOverlay({ id: "database.dialogs", component: DatabaseDialogs }),
|
||||
registerEditorContribution({ id: "database.console", order: 57, extensions: consoleExtension }),
|
||||
registerOverlay({
|
||||
id: "database.dialogs",
|
||||
component: lazyWhen(useDialogOpen, () => import("./Dialogs").then((m) => m.DatabaseDialogs)),
|
||||
}),
|
||||
registerEditorContribution({
|
||||
id: "database.console",
|
||||
order: 57,
|
||||
extensions: lazyEditorExtension(
|
||||
(ctx) => ctx.language === SQL_LANGUAGE && !ctx.virtual && !ctx.readOnly,
|
||||
() => import("./console").then((m) => m.consoleExtension),
|
||||
),
|
||||
}),
|
||||
// A reopened project starts with a fresh backend: forget the old one's state.
|
||||
useProject.subscribe((s, prev) => {
|
||||
if (s.project?.root === prev.project?.root) return;
|
||||
@@ -151,7 +162,7 @@ export function activate(): () => void {
|
||||
openSourceDialog(null);
|
||||
return;
|
||||
}
|
||||
await executeInView(e.view, e.ctx.path);
|
||||
await (await import("./console")).executeInView(e.view, e.ctx.path);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -189,7 +200,7 @@ export function activate(): () => void {
|
||||
const e = activeEditor();
|
||||
if (!e || e.ctx.language !== SQL_LANGUAGE) return;
|
||||
if (!useDatabase.getState().loaded) await loadSources();
|
||||
await explainInView(e.view, e.ctx.path);
|
||||
await (await import("./console")).explainInView(e.view, e.ctx.path);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/** The language id of SQL files and consoles. */
|
||||
export const SQL_LANGUAGE = "sql";
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Variable } from "@/platform/ipc";
|
||||
import { valueText } from "../valueText";
|
||||
|
||||
const v = (name: string, value: string, variablesReference = 0): Variable =>
|
||||
({ name, value, variablesReference, typeName: null, evaluateName: null }) as Variable;
|
||||
|
||||
const fetcher = (tree: Record<number, Variable[]>) => async (ref: number) => tree[ref] ?? [];
|
||||
|
||||
describe("valueText", () => {
|
||||
it("returns a scalar as is", async () => {
|
||||
expect(await valueText({ value: "42", reference: 0 }, fetcher({}))).toBe("42");
|
||||
});
|
||||
|
||||
it("copies a PHP array with its nested elements", async () => {
|
||||
const tree = {
|
||||
1: [v("name", '"Ann"'), v("tags", "array(2)", 2)],
|
||||
2: [v("0", '"a"'), v("1", '"b"')],
|
||||
};
|
||||
expect(await valueText({ value: "array(2)", reference: 1 }, fetcher(tree))).toBe(
|
||||
["array(2)", ' name: "Ann"', " tags: array(2)", ' 0: "a"', ' 1: "b"'].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("stops at a cycle", async () => {
|
||||
const tree = { 1: [v("self", "Node", 1)] };
|
||||
expect(await valueText({ value: "Node", reference: 1 }, fetcher(tree))).toBe("Node\n self: Node");
|
||||
});
|
||||
|
||||
it("truncates past the entry limit", async () => {
|
||||
const tree = { 1: [v("0", "a"), v("1", "b"), v("2", "c")] };
|
||||
const text = await valueText({ value: "arr", reference: 1 }, fetcher(tree), { depth: 8, entries: 2 });
|
||||
expect(text).toBe("arr\n 0: a\n 1: b\n …");
|
||||
});
|
||||
|
||||
it("truncates past the depth limit", async () => {
|
||||
const tree = { 1: [v("a", "x", 2)], 2: [v("b", "y", 3)], 3: [v("c", "z")] };
|
||||
const text = await valueText({ value: "r", reference: 1 }, fetcher(tree), { depth: 2, entries: 100 });
|
||||
expect(text).toBe("r\n a: x\n b: y\n …");
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
* backend), DAP sessions driven by `IdeEvent::Debug`, the Debug tool window,
|
||||
* execution point + inline values, Evaluate / View Breakpoints dialogs.
|
||||
*/
|
||||
import { lazyComponent } from "@/ui";
|
||||
import { lazyComponent, lazyWhen } from "@/ui";
|
||||
import { onIdeEvent } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { selectionValueTooltip, valueTooltip } from "./editor/valueTooltip";
|
||||
@@ -35,9 +35,13 @@ import {
|
||||
stopSession,
|
||||
useDebug,
|
||||
} from "./state";
|
||||
import { DebuggerOverlays } from "./ui/Overlays";
|
||||
import { useOverlays } from "./ui/popoverStore";
|
||||
import { REMOTE_TYPES } from "./remote";
|
||||
|
||||
/** Whether a breakpoint popover, debugger dialog or chooser is open. */
|
||||
const useAnyOverlay = () =>
|
||||
useOverlays((s) => !!(s.popover || s.evaluate || s.breakpoints || s.chooser || s.attach || s.smartStep));
|
||||
|
||||
export function activate(): () => void {
|
||||
useDebugRunSessions.setState({
|
||||
Console: lazyComponent(() => import("./ui/DebugConsole").then((m) => m.DebugServiceConsole)),
|
||||
@@ -62,7 +66,10 @@ export function activate(): () => void {
|
||||
component: lazyComponent(() => import("./ui/DebugToolWindow").then((m) => m.DebugToolWindow)),
|
||||
headerContent: lazyComponent(() => import("./ui/DebugToolWindow").then((m) => m.DebugTabs)),
|
||||
}),
|
||||
registerOverlay({ id: "debugger", component: DebuggerOverlays }),
|
||||
registerOverlay({
|
||||
id: "debugger",
|
||||
component: lazyWhen(useAnyOverlay, () => import("./ui/Overlays").then((m) => m.DebuggerOverlays)),
|
||||
}),
|
||||
registerMenuGroup({
|
||||
id: "debugger.evaluate",
|
||||
menus: ["editor"],
|
||||
|
||||
@@ -11,6 +11,7 @@ import { methodAt } from "../breakpoints/symbols";
|
||||
import { activeSession, addWatch, removeWatch, revealFrame, sessionSupports, setVariableValue } from "../state";
|
||||
import { blurDebugView, focusDebugView } from "./focusedView";
|
||||
import { classifyValue, oneLine, sortVariables } from "../values";
|
||||
import { valueText } from "../valueText";
|
||||
import { workflowSettings } from "@/platform/workflowSettings";
|
||||
import styles from "./VariablesView.module.css";
|
||||
|
||||
@@ -270,6 +271,25 @@ export function VariablesView({
|
||||
const canWatch = (n: VarNode) =>
|
||||
session != null && n.kind === "var" && n.container > 0 && sessionSupports("dataBreakpoints");
|
||||
|
||||
const copyValue = async (node: VarNode) => {
|
||||
let text = node.value;
|
||||
if (session != null && node.reference > 0) {
|
||||
try {
|
||||
text = await valueText(node, (ref) => call(commands.debugVariables(session, ref)));
|
||||
} catch (e) {
|
||||
notifications().notify({
|
||||
group: "run",
|
||||
level: "error",
|
||||
key: "debug.copyValue",
|
||||
title: "Cannot copy the value",
|
||||
body: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
await navigator.clipboard?.writeText(text);
|
||||
};
|
||||
|
||||
const onContextMenu = (node: VarNode | null, _sel: Set<string>, e: React.MouseEvent) => {
|
||||
if (!node || node.kind === "message") return;
|
||||
e.preventDefault();
|
||||
@@ -281,7 +301,7 @@ export function VariablesView({
|
||||
);
|
||||
if (node.kind === "watch") items.push({ label: "Remove Watch", icon: "minus", run: () => removeWatch(node.name) });
|
||||
if (node.kind !== "scope") {
|
||||
items.push({ label: "Copy Value", icon: "copy", run: () => void navigator.clipboard?.writeText(node.value) });
|
||||
items.push({ label: "Copy Value", icon: "copy", run: () => void copyValue(node) });
|
||||
items.push({ label: "Copy Name", run: () => void navigator.clipboard?.writeText(node.name) });
|
||||
if (node.kind === "var" && node.evaluateName)
|
||||
items.push({ label: "Add to Watches", icon: "eye", run: () => addWatch(node.evaluateName!) });
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/** Text for "Copy Value": a structured value (array, object, map) is copied with its contents, not its summary. */
|
||||
import type { Variable } from "@/platform/ipc";
|
||||
|
||||
export interface ValueTextRoot {
|
||||
value: string;
|
||||
reference: number;
|
||||
}
|
||||
|
||||
export interface ValueTextLimits {
|
||||
depth: number;
|
||||
entries: number;
|
||||
}
|
||||
|
||||
export const VALUE_TEXT_LIMITS: ValueTextLimits = { depth: 8, entries: 2000 };
|
||||
|
||||
/**
|
||||
* `value` alone for a scalar; for an expandable value the summary line followed by its children,
|
||||
* one per line and indented by depth. Cycles and anything past the limits end in an ellipsis line.
|
||||
*/
|
||||
export async function valueText(
|
||||
root: ValueTextRoot,
|
||||
fetchChildren: (reference: number) => Promise<Variable[]>,
|
||||
limits: ValueTextLimits = VALUE_TEXT_LIMITS,
|
||||
): Promise<string> {
|
||||
if (root.reference <= 0) return root.value;
|
||||
const lines = [root.value];
|
||||
let budget = limits.entries;
|
||||
const path = new Set<number>([root.reference]);
|
||||
|
||||
const walk = async (reference: number, depth: number): Promise<void> => {
|
||||
const pad = " ".repeat(depth);
|
||||
if (depth > limits.depth) {
|
||||
lines.push(`${pad}…`);
|
||||
return;
|
||||
}
|
||||
for (const child of await fetchChildren(reference)) {
|
||||
if (budget-- <= 0) {
|
||||
lines.push(`${pad}…`);
|
||||
return;
|
||||
}
|
||||
lines.push(`${pad}${child.name}: ${child.value}`);
|
||||
const ref = child.variablesReference;
|
||||
if (ref > 0 && !path.has(ref)) {
|
||||
path.add(ref);
|
||||
await walk(ref, depth + 1);
|
||||
path.delete(ref);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await walk(root.reference, 1);
|
||||
return lines.join("\n");
|
||||
}
|
||||
+49
-117
@@ -10,70 +10,49 @@
|
||||
*/
|
||||
import "./ui/palette.css";
|
||||
import { registerActions, type Action } from "@/platform/actions";
|
||||
import { getContext, setContext } from "@/platform/context";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { setContext } from "@/platform/context";
|
||||
import { lazyComponent } from "@/ui";
|
||||
import {
|
||||
maybeEditorService,
|
||||
registerCloneSource,
|
||||
registerEditorKind,
|
||||
registerGitLogChecks,
|
||||
registerOverlay,
|
||||
registerToolWindow,
|
||||
} from "@/workbench/contributions";
|
||||
import { activateReviewDiff, toggleReviewDiffViewed } from "./diff";
|
||||
import { reloadList } from "./data/list";
|
||||
import { checkoutPullRequest, clearDiffCache, copyUrl, openInBrowser, openInWorktree } from "./data/local";
|
||||
import { deleteMergedBranch } from "./data/operations";
|
||||
import { cachedDetails, loadPullRequestData, reloadPullRequest } from "./data/pullRequest";
|
||||
import { refreshConnection, resetConnection, useGhRepository } from "./data/repository";
|
||||
import { showSubmitReview } from "./details/SubmitReviewPopup";
|
||||
import { activateReviewMode } from "./reviewMode";
|
||||
import { useGithubShell } from "./shell";
|
||||
import { githubHandler, handlerContextKey } from "./toolwindow/handlers";
|
||||
import {
|
||||
openCreatePullRequest,
|
||||
openPullRequest,
|
||||
selectOpenedPullRequest,
|
||||
showPullRequests,
|
||||
WINDOW,
|
||||
} from "./toolwindow/open";
|
||||
import {
|
||||
changeRepository,
|
||||
PullRequestsHeaderActions,
|
||||
PullRequestsTabs,
|
||||
PullRequestsToolWindow,
|
||||
usePullRequestsHeaderMenu,
|
||||
} from "./toolwindow/PullRequestsToolWindow";
|
||||
import { contextPullRequest, resetTabs } from "./toolwindow/store";
|
||||
import {
|
||||
activeTimelinePullRequest,
|
||||
openTimeline,
|
||||
TIMELINE_KIND,
|
||||
timelineIcon,
|
||||
timelineTitle,
|
||||
} from "./toolwindow/timelineInput";
|
||||
import { GithubDialogHost, numberDialog } from "./ui/dialogs";
|
||||
import { GithubDialogHost } from "./ui/dialogs";
|
||||
|
||||
const WINDOW = "pullRequests";
|
||||
const TIMELINE_KIND = "github.timeline";
|
||||
const WHEN = "projectOpen";
|
||||
const CONNECTED = "projectOpen && githubConnected";
|
||||
const IN_WINDOW = `activeToolWindow == '${WINDOW}'`;
|
||||
|
||||
/** The pull request an action applies to: its argument, the timeline editor, the active tab or the list selection. */
|
||||
function targetPullRequest(arg: unknown): number | null {
|
||||
if (typeof arg === "number") return arg;
|
||||
return activeTimelinePullRequest() ?? contextPullRequest();
|
||||
/** The pull requests UI and data load after start-up (or on first use); registrations stay here. */
|
||||
type Runtime = typeof import("./runtime");
|
||||
let runtime: Runtime | null = null;
|
||||
let loading: Promise<Runtime> | null = null;
|
||||
let dispose: (() => void) | null = null;
|
||||
let active = false;
|
||||
|
||||
function load(): Promise<Runtime> {
|
||||
return (loading ??= import("./runtime").then(
|
||||
(m) => {
|
||||
runtime = m;
|
||||
if (active && !dispose) dispose = m.activate();
|
||||
return m;
|
||||
},
|
||||
(e: unknown) => {
|
||||
loading = null; // A failed chunk load may succeed later.
|
||||
throw e;
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
async function withPullRequest(arg: unknown, run: (number: number) => unknown) {
|
||||
const n = targetPullRequest(arg);
|
||||
if (n !== null) await run(n);
|
||||
}
|
||||
type ActionSpec = Omit<Action, "run"> & { run?: Action["run"] };
|
||||
|
||||
async function prUrl(number: number): Promise<string> {
|
||||
return cachedDetails(number)?.url ?? (await loadPullRequestData(number, "details")).url;
|
||||
}
|
||||
|
||||
function actions(): Action[] {
|
||||
function actions(): ActionSpec[] {
|
||||
return [
|
||||
// Git › GitHub
|
||||
{
|
||||
@@ -84,7 +63,6 @@ function actions(): Action[] {
|
||||
icon: "toolwindow-pull-requests",
|
||||
keywords: ["github", "pr", "review", "merge request", "code review"],
|
||||
when: WHEN,
|
||||
run: () => void showPullRequests(),
|
||||
},
|
||||
{
|
||||
// Github.Create.Pull.Request
|
||||
@@ -95,7 +73,6 @@ function actions(): Action[] {
|
||||
keywords: ["github", "pr", "new pull request", "start pull request", "merge request", "code review"],
|
||||
when: WHEN,
|
||||
keyWhen: IN_WINDOW,
|
||||
run: () => openCreatePullRequest(),
|
||||
},
|
||||
{
|
||||
id: "github.checkoutPullRequest",
|
||||
@@ -105,11 +82,6 @@ function actions(): Action[] {
|
||||
icon: "branch",
|
||||
keywords: ["github", "pr"],
|
||||
when: WHEN,
|
||||
run: async ({ arg }) => {
|
||||
if (!(await showPullRequests(false))) return;
|
||||
const n = targetPullRequest(arg) ?? (await numberDialog("Checkout Pull Request"));
|
||||
if (n) await checkoutPullRequest(n);
|
||||
},
|
||||
},
|
||||
{
|
||||
// Github.Open.In.Browser
|
||||
@@ -118,12 +90,6 @@ function actions(): Action[] {
|
||||
category: "GitHub",
|
||||
icon: "external-link",
|
||||
when: WHEN,
|
||||
run: async () => {
|
||||
if (!(await showPullRequests(false))) return;
|
||||
const n = targetPullRequest(undefined);
|
||||
const url = n !== null ? await prUrl(n) : useGhRepository.getState().connection?.repository.url;
|
||||
if (url) await openInBrowser(url);
|
||||
},
|
||||
},
|
||||
{
|
||||
// Github.PullRequest.List.Reload
|
||||
@@ -134,10 +100,6 @@ function actions(): Action[] {
|
||||
keywords: ["refresh list", "reload"],
|
||||
when: WHEN,
|
||||
keyWhen: `${IN_WINDOW} && githubPrTab == 'list'`,
|
||||
run: async () => {
|
||||
await refreshConnection();
|
||||
if (useGhRepository.getState().connection) await reloadList();
|
||||
},
|
||||
},
|
||||
// Tool window
|
||||
{
|
||||
@@ -146,8 +108,6 @@ function actions(): Action[] {
|
||||
title: "Change Repository or Account…",
|
||||
category: "GitHub",
|
||||
when: WHEN,
|
||||
// Not connected yet: the tool window offers the repository selector, or explains how to log in.
|
||||
run: () => (useGhRepository.getState().connection ? changeRepository() : void showPullRequests()),
|
||||
},
|
||||
{
|
||||
// pull.request.select.action
|
||||
@@ -156,7 +116,6 @@ function actions(): Action[] {
|
||||
category: "GitHub",
|
||||
icon: "locate",
|
||||
when: `${CONNECTED} && githubTimelineActive`,
|
||||
run: () => selectOpenedPullRequest(),
|
||||
},
|
||||
// A pull request
|
||||
{
|
||||
@@ -166,7 +125,6 @@ function actions(): Action[] {
|
||||
description: "View pull request information and timeline",
|
||||
category: "GitHub",
|
||||
when: CONNECTED,
|
||||
run: ({ arg }) => withPullRequest(arg, (n) => openPullRequest(n)),
|
||||
},
|
||||
{
|
||||
// Github.PullRequest.Timeline.Show
|
||||
@@ -176,7 +134,6 @@ function actions(): Action[] {
|
||||
category: "GitHub",
|
||||
icon: "github-timeline",
|
||||
when: CONNECTED,
|
||||
run: ({ arg }) => withPullRequest(arg, (n) => openTimeline(n, { focus: true })),
|
||||
},
|
||||
{
|
||||
// Github.PullRequest.Details.Reload
|
||||
@@ -186,7 +143,6 @@ function actions(): Action[] {
|
||||
icon: "refresh",
|
||||
when: CONNECTED,
|
||||
keyWhen: `${IN_WINDOW} && githubPrTab == 'pr'`,
|
||||
run: ({ arg }) => withPullRequest(arg, reloadPullRequest),
|
||||
},
|
||||
{
|
||||
// Github.PullRequest.Timeline.Update
|
||||
@@ -197,7 +153,6 @@ function actions(): Action[] {
|
||||
icon: "refresh",
|
||||
when: `${CONNECTED} && githubTimelineActive`,
|
||||
keyWhen: "editorAreaFocus",
|
||||
run: () => withPullRequest(undefined, reloadPullRequest),
|
||||
},
|
||||
{
|
||||
// Github.PullRequest.Open.Link
|
||||
@@ -207,7 +162,6 @@ function actions(): Action[] {
|
||||
icon: "external-link",
|
||||
keywords: ["open pull request in github"],
|
||||
when: CONNECTED,
|
||||
run: ({ arg }) => withPullRequest(arg, async (n) => openInBrowser(await prUrl(n))),
|
||||
},
|
||||
{
|
||||
// Github.PullRequest.Copy.Link
|
||||
@@ -216,7 +170,6 @@ function actions(): Action[] {
|
||||
category: "GitHub",
|
||||
icon: "copy",
|
||||
when: CONNECTED,
|
||||
run: ({ arg }) => withPullRequest(arg, async (n) => copyUrl(await prUrl(n))),
|
||||
},
|
||||
{
|
||||
// Github.PullRequest.CheckoutBranch
|
||||
@@ -226,7 +179,6 @@ function actions(): Action[] {
|
||||
category: "GitHub",
|
||||
icon: "branch",
|
||||
when: CONNECTED,
|
||||
run: ({ arg }) => withPullRequest(arg, checkoutPullRequest),
|
||||
},
|
||||
{
|
||||
// Github.PullRequest.OpenInWorktree
|
||||
@@ -235,7 +187,6 @@ function actions(): Action[] {
|
||||
description: "Fetch and open the pull request branch in a Git worktree",
|
||||
category: "GitHub",
|
||||
when: CONNECTED,
|
||||
run: ({ arg }) => withPullRequest(arg, openInWorktree),
|
||||
},
|
||||
{
|
||||
// GitHub.Timeline.DeleteMergedBranch
|
||||
@@ -246,11 +197,6 @@ function actions(): Action[] {
|
||||
icon: "trash",
|
||||
hidden: true,
|
||||
when: CONNECTED,
|
||||
run: ({ arg }) =>
|
||||
withPullRequest(arg, async (n) => {
|
||||
const ref = (await loadPullRequestData(n, "details")).headRef;
|
||||
if (ref) await deleteMergedBranch(n, ref.id);
|
||||
}),
|
||||
},
|
||||
// Implemented by other parts (`provideGithubHandler`)
|
||||
{
|
||||
@@ -260,12 +206,6 @@ function actions(): Action[] {
|
||||
category: "GitHub",
|
||||
// The details panel's popup; without it (the branch popup in review mode) one of its own.
|
||||
when: `${CONNECTED} && (${handlerContextKey("submitReview")} || githubCurrentBranchPullRequest)`,
|
||||
run: ({ arg }) =>
|
||||
withPullRequest(arg, (n) => {
|
||||
const handler = githubHandler("submitReview");
|
||||
if (handler) handler(n);
|
||||
else void showSubmitReview(n);
|
||||
}),
|
||||
},
|
||||
{
|
||||
// Github.PullRequest.Changed.MarkViewed.Toggle
|
||||
@@ -275,9 +215,6 @@ function actions(): Action[] {
|
||||
category: "GitHub",
|
||||
// The file of the focused review diff, else the details panel's selection.
|
||||
when: `${CONNECTED} && (${handlerContextKey("toggleViewed")} || githubReviewDiff)`,
|
||||
run: () => {
|
||||
if (!(getContext().diffEditorFocus && toggleReviewDiffViewed())) githubHandler("toggleViewed")?.();
|
||||
},
|
||||
},
|
||||
{
|
||||
// Github.Pull.Request.Show.In.Toolwindow
|
||||
@@ -309,7 +246,13 @@ function actions(): Action[] {
|
||||
];
|
||||
}
|
||||
|
||||
const lazyView = (name: "PullRequestsToolWindow" | "PullRequestsTabs" | "PullRequestsHeaderActions") =>
|
||||
lazyComponent<{ id: string }>(() => import("./toolwindow/PullRequestsToolWindow").then((m) => m[name]));
|
||||
|
||||
const useHeaderMenu = () => useGithubShell((s) => s.headerMenu);
|
||||
|
||||
export function activate(): () => void {
|
||||
active = true;
|
||||
setContext("githubPrTab", "list");
|
||||
const disposers = [
|
||||
registerCloneSource({
|
||||
@@ -327,39 +270,28 @@ export function activate(): () => void {
|
||||
anchor: "left",
|
||||
order: 2,
|
||||
stripe: "onUse",
|
||||
component: PullRequestsToolWindow,
|
||||
headerContent: PullRequestsTabs,
|
||||
headerActions: PullRequestsHeaderActions,
|
||||
headerMenu: usePullRequestsHeaderMenu,
|
||||
component: lazyView("PullRequestsToolWindow"),
|
||||
headerContent: lazyView("PullRequestsTabs"),
|
||||
headerActions: lazyView("PullRequestsHeaderActions"),
|
||||
headerMenu: useHeaderMenu,
|
||||
}),
|
||||
registerEditorKind({
|
||||
kind: TIMELINE_KIND,
|
||||
component: lazyComponent(() => import("./timeline/TimelineEditor").then((m) => m.TimelineEditor)),
|
||||
title: timelineTitle,
|
||||
icon: timelineIcon,
|
||||
title: (input) =>
|
||||
runtime?.timeline.title(input) ?? `#${(input.data as { number?: number } | undefined)?.number ?? "?"}`,
|
||||
icon: (input) => runtime?.timeline.icon(input) ?? "github-timeline",
|
||||
}),
|
||||
registerOverlay({ id: "github.dialogs", component: GithubDialogHost }),
|
||||
registerActions(actions()),
|
||||
useGhRepository.subscribe((s, prev) => {
|
||||
setContext("githubConnected", !!s.connection);
|
||||
setContext("githubTimelineActive", activeTimelinePullRequest() !== null);
|
||||
const a = s.connection?.coordinates;
|
||||
const b = prev.connection?.coordinates;
|
||||
if (a?.owner !== b?.owner || a?.name !== b?.name) {
|
||||
resetTabs();
|
||||
clearDiffCache();
|
||||
}
|
||||
}),
|
||||
useProject.subscribe((s, prev) => {
|
||||
if (s.project?.root !== prev.project?.root) resetConnection();
|
||||
}),
|
||||
activateReviewDiff(),
|
||||
activateReviewMode(),
|
||||
registerActions(actions().map((a) => ({ ...a, run: a.run ?? (async (c) => (await load()).runs[a.id]!(c)) }))),
|
||||
];
|
||||
const editors = maybeEditorService();
|
||||
const stopEditors = editors?.onActiveInputChange?.(() =>
|
||||
setContext("githubTimelineActive", activeTimelinePullRequest() !== null),
|
||||
);
|
||||
if (stopEditors) disposers.push(stopEditors);
|
||||
return () => disposers.reverse().forEach((d) => d());
|
||||
// Review mode follows the current branch: start once the window is up.
|
||||
const idle = typeof requestIdleCallback === "function" ? requestIdleCallback : (f: () => void) => setTimeout(f, 200);
|
||||
idle(() => void load().catch((e: unknown) => console.error("could not load GitHub", e)));
|
||||
return () => {
|
||||
active = false;
|
||||
dispose?.();
|
||||
dispose = null;
|
||||
disposers.reverse().forEach((d) => d());
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* The GitHub integration behind `index.ts`'s registrations, loaded after start-up or on
|
||||
* first use: action implementations, the connection and review-mode tracking, review in
|
||||
* the diff viewer.
|
||||
*/
|
||||
import type { ActionContext } from "@/platform/actions";
|
||||
import { getContext, setContext } from "@/platform/context";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { maybeEditorService, type EditorInput } from "@/workbench/contributions";
|
||||
import { activateReviewDiff, toggleReviewDiffViewed } from "./diff";
|
||||
import { reloadList } from "./data/list";
|
||||
import { checkoutPullRequest, clearDiffCache, copyUrl, openInBrowser, openInWorktree } from "./data/local";
|
||||
import { deleteMergedBranch } from "./data/operations";
|
||||
import { cachedDetails, loadPullRequestData, reloadPullRequest } from "./data/pullRequest";
|
||||
import { refreshConnection, resetConnection, useGhRepository } from "./data/repository";
|
||||
import { showSubmitReview } from "./details/SubmitReviewPopup";
|
||||
import { activateReviewMode } from "./reviewMode";
|
||||
import { useGithubShell } from "./shell";
|
||||
import { githubHandler } from "./toolwindow/handlers";
|
||||
import { openCreatePullRequest, openPullRequest, selectOpenedPullRequest, showPullRequests } from "./toolwindow/open";
|
||||
import { changeRepository, headerMenu } from "./toolwindow/PullRequestsToolWindow";
|
||||
import { contextPullRequest, resetTabs } from "./toolwindow/store";
|
||||
import { activeTimelinePullRequest, openTimeline, timelineIcon, timelineTitle } from "./toolwindow/timelineInput";
|
||||
import { numberDialog } from "./ui/dialogs";
|
||||
|
||||
/** The pull request an action applies to: its argument, the timeline editor, the active tab or the list selection. */
|
||||
function targetPullRequest(arg: unknown): number | null {
|
||||
if (typeof arg === "number") return arg;
|
||||
return activeTimelinePullRequest() ?? contextPullRequest();
|
||||
}
|
||||
|
||||
async function withPullRequest(arg: unknown, run: (number: number) => unknown) {
|
||||
const n = targetPullRequest(arg);
|
||||
if (n !== null) await run(n);
|
||||
}
|
||||
|
||||
async function prUrl(number: number): Promise<string> {
|
||||
return cachedDetails(number)?.url ?? (await loadPullRequestData(number, "details")).url;
|
||||
}
|
||||
|
||||
type Run = (c: ActionContext) => void | Promise<void>;
|
||||
|
||||
/** Implementations of the actions `index.ts` registers, by id. */
|
||||
export const runs: Record<string, Run> = {
|
||||
"github.pullRequests": () => void showPullRequests(),
|
||||
"github.createPullRequest": () => openCreatePullRequest(),
|
||||
"github.checkoutPullRequest": async ({ arg }) => {
|
||||
if (!(await showPullRequests(false))) return;
|
||||
const n = targetPullRequest(arg) ?? (await numberDialog("Checkout Pull Request"));
|
||||
if (n) await checkoutPullRequest(n);
|
||||
},
|
||||
"github.openInBrowser": async () => {
|
||||
if (!(await showPullRequests(false))) return;
|
||||
const n = targetPullRequest(undefined);
|
||||
const url = n !== null ? await prUrl(n) : useGhRepository.getState().connection?.repository.url;
|
||||
if (url) await openInBrowser(url);
|
||||
},
|
||||
"github.refresh": async () => {
|
||||
await refreshConnection();
|
||||
if (useGhRepository.getState().connection) await reloadList();
|
||||
},
|
||||
// Not connected yet: the tool window offers the repository selector, or explains how to log in.
|
||||
"github.changeRepository": () =>
|
||||
useGhRepository.getState().connection ? changeRepository() : void showPullRequests(),
|
||||
"github.selectOpenedPullRequest": () => selectOpenedPullRequest(),
|
||||
"github.pullRequest.show": ({ arg }) => withPullRequest(arg, (n) => openPullRequest(n)),
|
||||
"github.pullRequest.showTimeline": ({ arg }) => withPullRequest(arg, (n) => openTimeline(n, { focus: true })),
|
||||
"github.pullRequest.refresh": ({ arg }) => withPullRequest(arg, reloadPullRequest),
|
||||
"github.pullRequest.timeline.refresh": () => withPullRequest(undefined, reloadPullRequest),
|
||||
"github.pullRequest.openInBrowser": ({ arg }) => withPullRequest(arg, async (n) => openInBrowser(await prUrl(n))),
|
||||
"github.pullRequest.copyUrl": ({ arg }) => withPullRequest(arg, async (n) => copyUrl(await prUrl(n))),
|
||||
"github.pullRequest.checkoutBranch": ({ arg }) => withPullRequest(arg, checkoutPullRequest),
|
||||
"github.pullRequest.openInWorktree": ({ arg }) => withPullRequest(arg, openInWorktree),
|
||||
"github.pullRequest.deleteMergedBranch": ({ arg }) =>
|
||||
withPullRequest(arg, async (n) => {
|
||||
const ref = (await loadPullRequestData(n, "details")).headRef;
|
||||
if (ref) await deleteMergedBranch(n, ref.id);
|
||||
}),
|
||||
// The details panel's popup; without it (the branch popup in review mode) one of its own.
|
||||
"github.pullRequest.submitReview": ({ arg }) =>
|
||||
withPullRequest(arg, (n) => {
|
||||
const handler = githubHandler("submitReview");
|
||||
if (handler) handler(n);
|
||||
else void showSubmitReview(n);
|
||||
}),
|
||||
// The file of the focused review diff, else the details panel's selection.
|
||||
"github.pullRequest.toggleViewed": () => {
|
||||
if (!(getContext().diffEditorFocus && toggleReviewDiffViewed())) githubHandler("toggleViewed")?.();
|
||||
},
|
||||
};
|
||||
|
||||
/** The timeline tab's title and icon once the details are known. */
|
||||
export const timeline = {
|
||||
title: (input: EditorInput) => timelineTitle(input),
|
||||
icon: (input: EditorInput) => timelineIcon(input),
|
||||
};
|
||||
|
||||
export function activate(): () => void {
|
||||
const sync = () => {
|
||||
const s = useGhRepository.getState();
|
||||
setContext("githubConnected", !!s.connection);
|
||||
setContext("githubTimelineActive", activeTimelinePullRequest() !== null);
|
||||
useGithubShell.setState({ headerMenu: headerMenu(s) });
|
||||
};
|
||||
sync();
|
||||
const disposers = [
|
||||
useGhRepository.subscribe((s, prev) => {
|
||||
sync();
|
||||
const a = s.connection?.coordinates;
|
||||
const b = prev.connection?.coordinates;
|
||||
if (a?.owner !== b?.owner || a?.name !== b?.name) {
|
||||
resetTabs();
|
||||
clearDiffCache();
|
||||
}
|
||||
}),
|
||||
useProject.subscribe((s, prev) => {
|
||||
if (s.project?.root !== prev.project?.root) resetConnection();
|
||||
}),
|
||||
activateReviewDiff(),
|
||||
activateReviewMode(),
|
||||
];
|
||||
const stopEditors = maybeEditorService()?.onActiveInputChange?.(() =>
|
||||
setContext("githubTimelineActive", activeTimelinePullRequest() !== null),
|
||||
);
|
||||
if (stopEditors) disposers.push(stopEditors);
|
||||
return () => disposers.reverse().forEach((d) => d());
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** State `index.ts`'s registrations read before `runtime.ts` has loaded. */
|
||||
import { create } from "zustand";
|
||||
import type { MenuItem } from "@/ui";
|
||||
|
||||
export const useGithubShell = create<{ headerMenu: MenuItem[] }>(() => ({ headerMenu: [] }));
|
||||
@@ -280,10 +280,8 @@ export function PullRequestsHeaderActions() {
|
||||
}
|
||||
|
||||
/** The "⋮" menu's own items: Change Repository or Account… (connected, several GitHub remotes). */
|
||||
export function usePullRequestsHeaderMenu(): MenuItem[] {
|
||||
const connected = useGhRepository((s) => !!s.connection);
|
||||
const several = useGhRepository((s) => s.remotes.length > 1);
|
||||
return connected && several ? CHANGE_REPOSITORY : NONE;
|
||||
export function headerMenu(s: Pick<ReturnType<typeof useGhRepository.getState>, "connection" | "remotes">): MenuItem[] {
|
||||
return s.connection && s.remotes.length > 1 ? CHANGE_REPOSITORY : NONE;
|
||||
}
|
||||
|
||||
const NONE: MenuItem[] = [];
|
||||
|
||||
@@ -220,9 +220,6 @@
|
||||
border-radius: var(--radius-small);
|
||||
cursor: pointer;
|
||||
}
|
||||
.runMarker:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Query result and variables: JSON in the editor's syntax colours. */
|
||||
.output :global(.tok-propertyName),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* level and text filters of IDEA's Android plugin.
|
||||
*/
|
||||
import { registerToolWindow } from "@/workbench/contributions";
|
||||
import { LogcatView } from "./LogcatView";
|
||||
import { lazyComponent } from "@/ui";
|
||||
import { resetLogcat } from "./store";
|
||||
|
||||
export function activate(): () => void {
|
||||
@@ -15,7 +15,7 @@ export function activate(): () => void {
|
||||
// Listed under the stripe's "…" until first opened: most projects are not Android ones.
|
||||
stripe: "onUse",
|
||||
order: 26,
|
||||
component: LogcatView,
|
||||
component: lazyComponent(() => import("./LogcatView").then((m) => m.LogcatView)),
|
||||
// A device can be watched without a project open.
|
||||
requiresProject: false,
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* `line-markers` providers (`lineMarkerGutter.ts`).
|
||||
*/
|
||||
import { registerEditorContribution } from "@/editor/api";
|
||||
import { lazyComponent } from "@/ui";
|
||||
import { lazyComponent, lazyWhen } from "@/ui";
|
||||
import { executeAction, registerActions } from "@/platform/actions";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { editorService, registerOverlay, registerSettingsPage } from "@/workbench/contributions";
|
||||
@@ -31,8 +31,7 @@ import { trackIconTheme } from "./iconThemes";
|
||||
import { pluginLineMarkerGutter } from "./lineMarkerGutter";
|
||||
import { defineBuiltinUiPoints } from "./points";
|
||||
import { startViewUi } from "./uiPoints";
|
||||
import { importIntellijTheme, showInstalledIntellijThemes } from "./intellijImport";
|
||||
import { IntellijThemesDialogHost } from "./IntellijThemesDialog";
|
||||
import { showInstalledIntellijThemes, useIntellijThemesDialog } from "./intellijThemesDialogStore";
|
||||
import { startWorkspaceContains } from "./workspaceContains";
|
||||
import { startIndexedMarkers } from "./indexedMarkers";
|
||||
import { startPluginNavigation } from "./navigation";
|
||||
@@ -50,6 +49,11 @@ function registerPluginsPage(): () => void {
|
||||
}
|
||||
|
||||
/** Settings › Plugins for the Settings window: the page plus the plugin theme. */
|
||||
const useIntellijThemesOpen = () => useIntellijThemesDialog((s) => s.open);
|
||||
const IntellijThemesDialogHost = lazyWhen(useIntellijThemesOpen, () =>
|
||||
import("./IntellijThemesDialog").then((m) => m.IntellijThemesDialogHost),
|
||||
);
|
||||
|
||||
export function activateSettingsPages(): () => void {
|
||||
const disposers = [
|
||||
registerPluginsPage(),
|
||||
@@ -111,7 +115,7 @@ export function activate(): () => void {
|
||||
// Installing needs a project's window.
|
||||
when: "projectOpen",
|
||||
run: async ({ arg }) => {
|
||||
await importIntellijTheme(typeof arg === "string" ? arg : undefined);
|
||||
await (await import("./intellijImport")).importIntellijTheme(typeof arg === "string" ? arg : undefined);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
* here (`intellijTheme.ts`), installed through the usual install page. Its
|
||||
* themes join the others; which one to use is chosen in Settings › Appearance.
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { pluginsBackend } from "./backend";
|
||||
import { intellijThemePackage } from "./intellijTheme";
|
||||
@@ -35,19 +34,8 @@ export async function importIntellijTheme(path?: string): Promise<string | null>
|
||||
return installFromDisk(dir);
|
||||
}
|
||||
|
||||
interface DialogState {
|
||||
open: boolean;
|
||||
/** Called with the plugin id after an import from the dialog. */
|
||||
onImported: ((id: string) => void) | null;
|
||||
}
|
||||
|
||||
export const useIntellijThemesDialog = create<DialogState>(() => ({ open: false, onImported: null }));
|
||||
|
||||
/** Shows the themes of the JetBrains IDEs on this machine to pick one to import. */
|
||||
export function showInstalledIntellijThemes(onImported?: (id: string) => void): void {
|
||||
useIntellijThemesDialog.setState({ open: true, onImported: onImported ?? null });
|
||||
}
|
||||
|
||||
export function closeInstalledIntellijThemes(): void {
|
||||
useIntellijThemesDialog.setState({ open: false, onImported: null });
|
||||
}
|
||||
export {
|
||||
closeInstalledIntellijThemes,
|
||||
showInstalledIntellijThemes,
|
||||
useIntellijThemesDialog,
|
||||
} from "./intellijThemesDialogStore";
|
||||
@@ -0,0 +1,18 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
interface DialogState {
|
||||
open: boolean;
|
||||
/** Called with the plugin id after an import from the dialog. */
|
||||
onImported: ((id: string) => void) | null;
|
||||
}
|
||||
|
||||
export const useIntellijThemesDialog = create<DialogState>(() => ({ open: false, onImported: null }));
|
||||
|
||||
/** Shows the themes of the JetBrains IDEs on this machine to pick one to import. */
|
||||
export function showInstalledIntellijThemes(onImported?: (id: string) => void): void {
|
||||
useIntellijThemesDialog.setState({ open: true, onImported: onImported ?? null });
|
||||
}
|
||||
|
||||
export function closeInstalledIntellijThemes(): void {
|
||||
useIntellijThemesDialog.setState({ open: false, onImported: null });
|
||||
}
|
||||
@@ -29,8 +29,8 @@ export const OPTION_LABELS: Array<[ToggleOption, string]> = [
|
||||
["showMembers", "Show Members"],
|
||||
["compact", "Compact Middle Packages"],
|
||||
["foldersFirst", "Sort Folders First"],
|
||||
["autoscrollFromSource", "Always Select Opened File"],
|
||||
["autoscrollToSource", "Autoscroll to Source"],
|
||||
["autoscrollFromSource", "Autoscroll from Source"],
|
||||
["singleClickOpen", "Open Files with Single Click"],
|
||||
["singleClickExpand", "Expand Directories with Single Click"],
|
||||
["previewTab", "Enable Preview Tab"],
|
||||
@@ -38,15 +38,19 @@ export const OPTION_LABELS: Array<[ToggleOption, string]> = [
|
||||
|
||||
/** Header controls of the Project tool window (IDEA: Locate, Expand/Collapse All). */
|
||||
export function ProjectHeaderActions() {
|
||||
// IDEA hides Select Opened File while Always Select Opened File keeps the tree in sync.
|
||||
const alwaysSelect = useProjectTree((s) => s.options.autoscrollFromSource);
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
icon="locate"
|
||||
action="navigate.selectInProjectView"
|
||||
title="Select Opened File"
|
||||
shortcut={shortcutLabel("navigate.selectInProjectView")}
|
||||
size="small"
|
||||
/>
|
||||
{!alwaysSelect && (
|
||||
<IconButton
|
||||
icon="locate"
|
||||
action="navigate.selectInProjectView"
|
||||
title="Select Opened File"
|
||||
shortcut={shortcutLabel("navigate.selectInProjectView")}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
<IconButton icon="expand-all" action="project.expandAll" title="Expand All" size="small" />
|
||||
<IconButton icon="collapse-all" action="project.collapseAll" title="Collapse All" size="small" />
|
||||
</>
|
||||
|
||||
@@ -14,7 +14,8 @@ import { ProjectView } from "./ProjectView";
|
||||
import { beginNew, canPaste } from "./operations";
|
||||
import { initProjectTree, setViewOption, useProjectTree } from "./store";
|
||||
import type { OutlineItem } from "@/bindings";
|
||||
import { _emitEditorEvent } from "@/editor/api";
|
||||
import { _emitEditorEvent, useEditorRegistry } from "@/editor/api";
|
||||
import { ProjectHeaderActions } from "./HeaderActions";
|
||||
import { loadOutline } from "./store";
|
||||
|
||||
const ipc = vi.hoisted(() => ({
|
||||
@@ -220,6 +221,38 @@ describe("ProjectView", () => {
|
||||
act(() => setViewOption("previewTab", false));
|
||||
});
|
||||
|
||||
it("follows tab switches with Always Select Opened File, hiding Select Opened File", async () => {
|
||||
const focusEditor = (path: string) => act(() => useEditorRegistry.setState({ active: { ctx: { path } } } as never));
|
||||
const selected = () =>
|
||||
screen
|
||||
.getAllByRole("treeitem")
|
||||
.filter((r) => r.getAttribute("aria-selected") === "true")
|
||||
.map((r) => r.textContent);
|
||||
try {
|
||||
render(<ProjectHeaderActions />);
|
||||
const { rows } = setup();
|
||||
await waitFor(() => expect(rows()).toContain("README.md"));
|
||||
expect(screen.getByRole("button", { name: "Select Opened File" })).toBeTruthy();
|
||||
focusEditor("/w/acme/src/Util.kt");
|
||||
expect(rows()).not.toContain("Util.kt");
|
||||
|
||||
act(() => setViewOption("autoscrollFromSource", true));
|
||||
await waitFor(() => expect(selected()).toEqual(["Util.kt"]));
|
||||
expect(screen.queryByRole("button", { name: "Select Opened File" })).toBeNull();
|
||||
focusEditor("/w/acme/README.md");
|
||||
await waitFor(() => expect(selected()).toEqual(["README.md"]));
|
||||
focusEditor("/w/acme/src/App.kt");
|
||||
await waitFor(() => expect(selected()).toEqual(["App.kt"]));
|
||||
|
||||
act(() => setViewOption("autoscrollFromSource", false));
|
||||
focusEditor("/w/acme/README.md");
|
||||
expect(selected()).toEqual(["App.kt"]);
|
||||
expect(screen.getByRole("button", { name: "Select Opened File" })).toBeTruthy();
|
||||
} finally {
|
||||
act(() => useEditorRegistry.setState({ active: null }));
|
||||
}
|
||||
});
|
||||
|
||||
it("expands and collapses folders on single click only when enabled", async () => {
|
||||
const { row, rows } = setup();
|
||||
await waitFor(() => expect(rows()).toContain("README.md"));
|
||||
|
||||
@@ -166,7 +166,8 @@ export function registerProjectActions(): () => void {
|
||||
id: `project.option.${key}`,
|
||||
title: tx`Project View: ${label}`,
|
||||
category: "View",
|
||||
keywords: ["project", "tree", "toggle"],
|
||||
// IDEA's former name of Always Select Opened File.
|
||||
keywords: ["project", "tree", "toggle", ...(key === "autoscrollFromSource" ? ["autoscroll from source"] : [])],
|
||||
toggled: () => useProjectTree.getState().options[key],
|
||||
run: () => setViewOption(key, !useProjectTree.getState().options[key]),
|
||||
}));
|
||||
|
||||
@@ -38,7 +38,7 @@ import { projectViewRoots, useProjectViewRoots, type ProjectViewRootContribution
|
||||
import { ancestorsWithin, isSameOrInside, nameOf, type PackageKind } from "./paths";
|
||||
|
||||
export interface ViewOptions extends TreeOptions {
|
||||
/** Select the active editor's file in the tree. */
|
||||
/** Select the active editor's file in the tree (IDEA: Always Select Opened File). */
|
||||
autoscrollFromSource: boolean;
|
||||
/** Open the selected file while navigating the tree (preview, no focus change). */
|
||||
autoscrollToSource: boolean;
|
||||
|
||||
@@ -90,7 +90,7 @@ export function RunWidget() {
|
||||
<IconButton
|
||||
icon="resume"
|
||||
size="large"
|
||||
iconSize={18}
|
||||
iconSize={20}
|
||||
className={styles.widgetButton}
|
||||
title="Resume Program"
|
||||
shortcut={shortcutLabel("debug.resume")}
|
||||
@@ -113,7 +113,7 @@ export function RunWidget() {
|
||||
<IconButton
|
||||
icon="run"
|
||||
size="large"
|
||||
iconSize={18}
|
||||
iconSize={20}
|
||||
className={clsx(styles.widgetButton, styles.widgetRun)}
|
||||
title={
|
||||
selected
|
||||
@@ -131,7 +131,7 @@ export function RunWidget() {
|
||||
<IconButton
|
||||
icon="debug"
|
||||
size="large"
|
||||
iconSize={18}
|
||||
iconSize={20}
|
||||
className={styles.widgetButton}
|
||||
title={selected ? tx`Debug '${selected.name}'` : currentFile ? "Debug Current File" : "Debug"}
|
||||
shortcut={shortcutLabel("run.debug")}
|
||||
@@ -168,7 +168,7 @@ export function RunWidget() {
|
||||
ref={moreRef}
|
||||
icon="more"
|
||||
size="large"
|
||||
iconSize={18}
|
||||
iconSize={20}
|
||||
className={styles.widgetButton}
|
||||
title="More Actions"
|
||||
aria-haspopup="menu"
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { startSampleOutput, useRun } from "./store";
|
||||
|
||||
describe("console output", () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("shows a flood of output at most every 50 ms, a first line on the next frame", async () => {
|
||||
vi.useFakeTimers({
|
||||
toFake: ["setTimeout", "clearTimeout", "requestAnimationFrame", "cancelAnimationFrame", "performance"],
|
||||
});
|
||||
const updates: number[] = [];
|
||||
const stop = useRun.subscribe((s, prev) => {
|
||||
if (s.versions !== prev.versions) updates.push(performance.now());
|
||||
});
|
||||
// 60 frames of output, 10 lines each.
|
||||
const done = startSampleOutput(600, 10);
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
await done;
|
||||
stop();
|
||||
const session = useRun.getState().sessions.at(-1)!;
|
||||
expect(session.status).not.toBe("running");
|
||||
// Creating the session; the first lines (enqueued a frame later) on the next frame; then every ≥ 50 ms.
|
||||
expect(updates[1]! - updates[0]!).toBeLessThanOrEqual(34);
|
||||
const gaps = updates.slice(2, -2).map((t, i) => t - updates[i + 1]!);
|
||||
expect(Math.min(...gaps)).toBeGreaterThanOrEqual(50);
|
||||
expect(updates.length).toBeLessThan(30);
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import { getContext, setContext } from "@/platform/context";
|
||||
import { onIdeEvent, type RunConfig } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { onWorkflowSettingsChange } from "@/platform/workflowSettings";
|
||||
import { showContextMenu, lazyComponent, type MenuItem } from "@/ui";
|
||||
import { showContextMenu, lazyComponent, lazyWhen, type MenuItem } from "@/ui";
|
||||
import {
|
||||
menuGroupItems,
|
||||
useDebugRunSessions,
|
||||
@@ -35,7 +35,6 @@ import { runWithExecutor } from "./executors";
|
||||
import { debugWithAgent, jdwpLaunch } from "./javaDebug";
|
||||
import { RunDialogsOverlay } from "./dialogs";
|
||||
import { registerExternalTools } from "./externalTools";
|
||||
import { EditConfigsOverlay } from "./EditConfigsDialog";
|
||||
import { openImport } from "./importConfigs";
|
||||
import { registerComposerInstall } from "./composerInstall";
|
||||
import { registerGoActions } from "./goActions";
|
||||
@@ -74,6 +73,7 @@ import {
|
||||
selectConfig,
|
||||
selectedConfig,
|
||||
startRun,
|
||||
startSampleOutput,
|
||||
stopSession,
|
||||
trackRecentConfigurations,
|
||||
useRun,
|
||||
@@ -292,8 +292,25 @@ function scheduleReload() {
|
||||
reloadTimer = setTimeout(() => void loadConfigs(), 300);
|
||||
}
|
||||
|
||||
const useEditConfigsOpen = () => useRun((s) => s.editorOpen);
|
||||
|
||||
export function activate(): () => void {
|
||||
const disposers: Array<() => void> = [
|
||||
// The performance harness (`perf/`): a console streaming generated output.
|
||||
import.meta.env.DEV || import.meta.env.MODE === "perf"
|
||||
? registerActions([
|
||||
{
|
||||
id: "run.sampleOutput",
|
||||
title: "Stream Sample Output",
|
||||
category: "Run",
|
||||
hidden: true,
|
||||
run: async ({ arg }) => {
|
||||
const { lines = 20_000, perFrame = 200 } = (arg ?? {}) as { lines?: number; perFrame?: number };
|
||||
await startSampleOutput(lines, perFrame);
|
||||
},
|
||||
},
|
||||
])
|
||||
: () => {},
|
||||
registerToolWindow({
|
||||
id: "run",
|
||||
title: "Run",
|
||||
@@ -311,7 +328,10 @@ export function activate(): () => void {
|
||||
registerGoActions(),
|
||||
registerComposerInstall(),
|
||||
registerOverlay({ id: "run.chooser", component: ConfigChooserOverlay }),
|
||||
registerOverlay({ id: "run.editConfigs", component: EditConfigsOverlay }),
|
||||
registerOverlay({
|
||||
id: "run.editConfigs",
|
||||
component: lazyWhen(useEditConfigsOpen, () => import("./EditConfigsDialog").then((m) => m.EditConfigsOverlay)),
|
||||
}),
|
||||
registerOverlay({ id: "run.dialogs", component: RunDialogsOverlay }),
|
||||
registerServiceProvider(runServiceProvider),
|
||||
registerRunningProcessesProvider({
|
||||
|
||||
@@ -97,8 +97,13 @@ interface SessionRuntime {
|
||||
/** Machine-readable stdout (`go test -json`, `--teamcity`, the Ruby reporter): fed to the parser, shown decoded. */
|
||||
stdout: TestStdout | null;
|
||||
queue: OutputChunk[];
|
||||
/** rAF id while the page is visible, timeout id while hidden (rAF pauses in background tabs). */
|
||||
/**
|
||||
* rAF id while the page is visible, timeout id while hidden (rAF pauses in
|
||||
* background tabs) or while waiting out `FLUSH_INTERVAL`.
|
||||
*/
|
||||
flushHandle: { raf: number } | { timeout: number } | null;
|
||||
/** When output was last shown (`performance.now()`). */
|
||||
flushedAt: number;
|
||||
/** Bumped per start so output from a previous run in the same tab is ignored. */
|
||||
generation: number;
|
||||
/** Called with every complete output line (e.g. waiting for a JDWP agent). */
|
||||
@@ -499,6 +504,7 @@ function flush(sessionId: string) {
|
||||
if (!rt) return;
|
||||
cancelFlush(rt);
|
||||
if (rt.queue.length === 0) return;
|
||||
rt.flushedAt = performance.now();
|
||||
const chunks = rt.queue.splice(0);
|
||||
for (const c of chunks) {
|
||||
const text = c.kind === "stdout" && rt.stdout ? rt.stdout.feed(c.text) : c.text;
|
||||
@@ -519,12 +525,27 @@ function enqueue(sessionId: string, chunk: OutputChunk) {
|
||||
// rAF pauses in background tabs; a timeout keeps consuming output so it doesn't
|
||||
// pile up until the console is shown again (issue #319: the console "kept
|
||||
// scrolling" the previous run's backlog after switching back to it).
|
||||
const wait = FLUSH_INTERVAL - (performance.now() - rt.flushedAt);
|
||||
rt.flushHandle = document.hidden
|
||||
? { timeout: window.setTimeout(() => flush(sessionId), 100) }
|
||||
: { raf: requestAnimationFrame(() => flush(sessionId)) };
|
||||
: wait > 0
|
||||
? {
|
||||
timeout: window.setTimeout(
|
||||
() => (rt.flushHandle = { raf: requestAnimationFrame(() => flush(sessionId)) }),
|
||||
wait,
|
||||
),
|
||||
}
|
||||
: { raf: requestAnimationFrame(() => flush(sessionId)) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortest time between console updates. A line after a quiet spell shows on the
|
||||
* next frame; a flood is shown at ~20 Hz instead of every frame, which kept
|
||||
* typing elsewhere from fitting in a frame (35% missed while 12k lines/s stream).
|
||||
*/
|
||||
const FLUSH_INTERVAL = 50;
|
||||
|
||||
function cancelFlush(rt: SessionRuntime) {
|
||||
if (rt.flushHandle === null) return;
|
||||
if ("raf" in rt.flushHandle) cancelAnimationFrame(rt.flushHandle.raf);
|
||||
@@ -595,6 +616,7 @@ function newRuntime(config: RunConfig): SessionRuntime {
|
||||
}),
|
||||
queue: [],
|
||||
flushHandle: null,
|
||||
flushedAt: -Infinity,
|
||||
generation: 0,
|
||||
lineListeners: [],
|
||||
input: "",
|
||||
@@ -1314,3 +1336,59 @@ export function modifyTemporary(config: RunConfig) {
|
||||
editDirty: adopted.detected === false,
|
||||
});
|
||||
}
|
||||
|
||||
const SAMPLE_LINES = [
|
||||
(i: number) =>
|
||||
`2026-11-05 10:42:${String(i % 60).padStart(2, "0")}.${String(i % 1000).padStart(3, "0")} INFO 4242 --- [nio-8080-exec-${i % 10}] c.a.orders.service.OrderService : processed order #${i} in ${i % 97} ms`,
|
||||
(i: number) => `\tat com.acme.orders.service.OrderService.process(OrderService.kt:${(i % 300) + 1})`,
|
||||
(i: number) =>
|
||||
`2026-11-05 10:42:01.${String(i % 1000).padStart(3, "0")} DEBUG 4242 --- [ main] o.s.web.servlet.DispatcherServlet : GET "/api/orders/${i}", parameters={}`,
|
||||
];
|
||||
|
||||
/**
|
||||
* Development and the performance harness only: a session whose console gets
|
||||
* `lines` generated log lines (stack frames among them), `perFrame` per
|
||||
* animation frame, as from a chatty process. Resolves once all are shown.
|
||||
*/
|
||||
export async function startSampleOutput(lines: number, perFrame: number): Promise<void> {
|
||||
const config: RunConfig = {
|
||||
id: `sample-output-${sessionSeq + 1}`,
|
||||
name: "Sample output",
|
||||
kind: "shell",
|
||||
command: "",
|
||||
args: [],
|
||||
cwd: null,
|
||||
env: {},
|
||||
detected: false,
|
||||
isTest: false,
|
||||
debugAdapter: null,
|
||||
};
|
||||
const session: RunSession = {
|
||||
id: `run${++sessionSeq}`,
|
||||
config,
|
||||
runId: null,
|
||||
status: "running",
|
||||
exitCode: null,
|
||||
startedAt: Date.now(),
|
||||
endedAt: null,
|
||||
stdinClosed: true,
|
||||
};
|
||||
runtimes.set(session.id, newRuntime(config));
|
||||
useRun.setState((s) => ({ sessions: [...s.sessions, session], activeSession: session.id }));
|
||||
bump(session.id);
|
||||
try {
|
||||
toolWindowService().show("run");
|
||||
} catch {
|
||||
/* no workbench (tests) */
|
||||
}
|
||||
for (let i = 0; i < lines;) {
|
||||
await new Promise(requestAnimationFrame);
|
||||
for (const end = Math.min(lines, i + perFrame); i < end; i++) {
|
||||
const line = SAMPLE_LINES[i % 20 === 19 ? 1 : i % 7 === 6 ? 2 : 0]!(i);
|
||||
enqueue(session.id, { kind: i % 101 === 100 ? "stderr" : "stdout", text: `${line}\n` });
|
||||
}
|
||||
}
|
||||
await new Promise(requestAnimationFrame);
|
||||
flush(session.id);
|
||||
finishSession(session.id, 0);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* Recent Files / Recent Locations, Find / Replace in Files + the Find tool
|
||||
* window, the Structure tool window + File Structure popup, and bookmarks.
|
||||
*/
|
||||
import { lazyComponent } from "@/ui";
|
||||
import { lazyComponent, lazyWhen } from "@/ui";
|
||||
import { activeEditor, registerEditorContribution } from "@/editor/api";
|
||||
import { executeAction, registerActions } from "@/platform/actions";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
@@ -33,15 +33,12 @@ import {
|
||||
import { openLocation } from "./common/open";
|
||||
import { maybeToolWindowService } from "./common/services";
|
||||
import { installTracking, resetTracking } from "./common/tracking";
|
||||
import { SearchEverywhereOverlay } from "./everywhere/SearchEverywhere";
|
||||
import { hideSearchEverywhere, showSearchEverywhere } from "./everywhere/store";
|
||||
import { FindInFilesOverlay } from "./find/FindInFiles";
|
||||
import { hideSearchEverywhere, showSearchEverywhere, useSearchEverywhere } from "./everywhere/store";
|
||||
import { ScopesDialogOverlay, showScopesDialog } from "./find/ScopesDialog";
|
||||
import { hideFindPopup, parseFindArg, runFindPopupCommand, showFindPopup, useFindPopup } from "./find/store";
|
||||
import { disposeFindWindow, openEditorResults, rerunActiveFindTab, useFindHeaderMenu } from "./find/windowStore";
|
||||
import { RecentPopupsOverlay } from "./recent/RecentPopups";
|
||||
import { cycleSwitcher, hideRecentPopup, showRecentPopup } from "./recent/store";
|
||||
import { FileStructureOverlay, useFileStructurePopup } from "./structure/StructureView";
|
||||
import { cycleSwitcher, hideRecentPopup, showRecentPopup, useRecentPopup } from "./recent/store";
|
||||
import { useFileStructurePopup } from "./structure/popupStore";
|
||||
import { useOutline, useStructureOptions } from "./structure/store";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
@@ -115,6 +112,11 @@ function jumpBookmark(dir: 1 | -1) {
|
||||
if (next) void openLocation(next.b.path, { line: next.b.line, col: 0 });
|
||||
}
|
||||
|
||||
const useEverywhereOpen = () => useSearchEverywhere((s) => s.open);
|
||||
const useRecentOpen = () => useRecentPopup((s) => s.open !== null);
|
||||
const useFindOpen = () => useFindPopup((s) => s.open);
|
||||
const useFileStructureOpen = () => useFileStructurePopup((s) => s.open);
|
||||
|
||||
export function activate(): void | (() => void) {
|
||||
const se = (tab: Parameters<typeof showSearchEverywhere>[0]) => () => {
|
||||
closeAllPopups("everywhere");
|
||||
@@ -125,11 +127,27 @@ export function activate(): void | (() => void) {
|
||||
installTracking(),
|
||||
provideFindResults(openEditorResults),
|
||||
|
||||
registerOverlay({ id: "search.everywhere", component: SearchEverywhereOverlay }),
|
||||
registerOverlay({ id: "search.recent", component: RecentPopupsOverlay }),
|
||||
registerOverlay({ id: "search.findInFiles", component: FindInFilesOverlay }),
|
||||
registerOverlay({
|
||||
id: "search.everywhere",
|
||||
component: lazyWhen(useEverywhereOpen, () =>
|
||||
import("./everywhere/SearchEverywhere").then((m) => m.SearchEverywhereOverlay),
|
||||
),
|
||||
}),
|
||||
registerOverlay({
|
||||
id: "search.recent",
|
||||
component: lazyWhen(useRecentOpen, () => import("./recent/RecentPopups").then((m) => m.RecentPopupsOverlay)),
|
||||
}),
|
||||
registerOverlay({
|
||||
id: "search.findInFiles",
|
||||
component: lazyWhen(useFindOpen, () => import("./find/FindInFiles").then((m) => m.FindInFilesOverlay)),
|
||||
}),
|
||||
registerOverlay({ id: "search.scopes", component: ScopesDialogOverlay }),
|
||||
registerOverlay({ id: "search.fileStructure", component: FileStructureOverlay }),
|
||||
registerOverlay({
|
||||
id: "search.fileStructure",
|
||||
component: lazyWhen(useFileStructureOpen, () =>
|
||||
import("./structure/StructureView").then((m) => m.FileStructureOverlay),
|
||||
),
|
||||
}),
|
||||
registerOverlay({ id: "search.bookmarks", component: BookmarkPopupsOverlay }),
|
||||
|
||||
registerToolWindow({
|
||||
|
||||
@@ -319,7 +319,8 @@ function StructureToolbar() {
|
||||
// ---------------------------------------------------------------------------
|
||||
// ⌘F12 File Structure popup
|
||||
|
||||
export const useFileStructurePopup = create<{ open: boolean }>(() => ({ open: false }));
|
||||
import { useFileStructurePopup } from "./popupStore";
|
||||
export { useFileStructurePopup };
|
||||
|
||||
export function FileStructureOverlay() {
|
||||
const open = useFileStructurePopup((s) => s.open);
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
/** Whether the File Structure popup is open. */
|
||||
export const useFileStructurePopup = create<{ open: boolean }>(() => ({ open: false }));
|
||||
@@ -160,9 +160,6 @@
|
||||
border-radius: var(--radius-small);
|
||||
cursor: pointer;
|
||||
}
|
||||
.marker:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.fieldRow {
|
||||
display: flex;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import { registerActions, type Action } from "@/platform/actions";
|
||||
import { call, commands, isTauri } from "@/platform/ipc";
|
||||
import { isPopupOpen, lazyComponent } from "@/ui";
|
||||
import { isPopupOpen, lazyComponent, lazyWhen } from "@/ui";
|
||||
import { loadSettings, useProject } from "@/platform/project";
|
||||
import { logError } from "@/platform/errorLog";
|
||||
import { restoreRemote } from "@/platform/remote";
|
||||
@@ -35,7 +35,7 @@ import { zoomActions } from "./appearanceRuntime";
|
||||
import { activateFileTypes } from "./fileTypesRuntime";
|
||||
import { trackRuntimeSettings } from "./runtime";
|
||||
import { installActionProxies, installSettingsHost } from "./window/bridge";
|
||||
import { AssignShortcutOverlay, showAssignShortcut } from "./keymap/AssignShortcut";
|
||||
import { showAssignShortcut, useAssignShortcut } from "./keymap/assignShortcutStore";
|
||||
import { settingsSearchEntries } from "./searchIndex";
|
||||
import { activatePythonStatus } from "./languages/PythonStatus";
|
||||
import { activatePhpStatus } from "./languages/PhpStatus";
|
||||
@@ -105,6 +105,8 @@ async function showSettings(page?: string, newProjects = false) {
|
||||
openSettings(page, { newProjects });
|
||||
}
|
||||
|
||||
const useAssignShortcutOpen = () => useAssignShortcut((s) => s.actionId !== null);
|
||||
|
||||
export function activate(): () => void {
|
||||
const disposers = [
|
||||
trackRuntimeSettings(),
|
||||
@@ -116,7 +118,12 @@ export function activate(): () => void {
|
||||
registerActions(zoomActions()),
|
||||
registerActions(settingsActions((page, newProjects) => void showSettings(page, newProjects))),
|
||||
activateFileTypes(),
|
||||
registerOverlay({ id: "settings.assignShortcut", component: AssignShortcutOverlay }),
|
||||
registerOverlay({
|
||||
id: "settings.assignShortcut",
|
||||
component: lazyWhen(useAssignShortcutOpen, () =>
|
||||
import("./keymap/AssignShortcut").then((m) => m.AssignShortcutOverlay),
|
||||
),
|
||||
}),
|
||||
registerActions([
|
||||
{
|
||||
// Search Everywhere's ⌥↵ on an action (arg: the action id).
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
* active keymap (overrides of a bundled keymap, or the user keymap itself).
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
import { create } from "zustand";
|
||||
import { useActions } from "@/platform/actions";
|
||||
import { call, commands, isTauri } from "@/platform/ipc";
|
||||
import type { Keymap } from "@/platform/keymap";
|
||||
@@ -21,11 +20,8 @@ import {
|
||||
} from "./model";
|
||||
import { RecordShortcutDialog } from "./RecordShortcutDialog";
|
||||
|
||||
export const useAssignShortcut = create<{ actionId: string | null }>(() => ({ actionId: null }));
|
||||
|
||||
export function showAssignShortcut(actionId: string) {
|
||||
useAssignShortcut.setState({ actionId });
|
||||
}
|
||||
import { useAssignShortcut } from "./assignShortcutStore";
|
||||
export { showAssignShortcut, useAssignShortcut } from "./assignShortcutStore";
|
||||
|
||||
interface KeymapSettings {
|
||||
keymap: string;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
/** The action whose shortcut the Assign Shortcut dialog is editing. */
|
||||
export const useAssignShortcut = create<{ actionId: string | null }>(() => ({ actionId: null }));
|
||||
|
||||
export function showAssignShortcut(actionId: string) {
|
||||
useAssignShortcut.setState({ actionId });
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { enclosingTag, expandAt, syntaxAt, updateTagEdits, type TextEdit } from "../emmet/model";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { enclosingTag, expandAt, loadEmmet, syntaxAt, updateTagEdits, type TextEdit } from "../emmet/model";
|
||||
|
||||
beforeAll(() => loadEmmet());
|
||||
|
||||
const apply = (text: string, edits: TextEdit[]) =>
|
||||
[...edits].sort((a, b) => b.from - a.from).reduce((t, e) => t.slice(0, e.from) + e.insert + t.slice(e.to), text);
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
import { usePostfixSettings } from "./postfix/store";
|
||||
import { showChooser, type ChooserItem } from "./popup/store";
|
||||
import { expandEmmetAtCaret } from "./emmet/editor";
|
||||
import { hasEmmet, loadEmmet } from "./emmet/model";
|
||||
|
||||
export interface TemplateDocInfo {
|
||||
path: string;
|
||||
@@ -557,6 +558,7 @@ export const templateCompletionSource: CompletionSource = (ctx: CompletionContex
|
||||
// Extension
|
||||
|
||||
export function templateEditorExtension(ctx: EditorDocContext): Extension {
|
||||
if (hasEmmet(ctx.language)) void loadEmmet().catch(() => {});
|
||||
return [
|
||||
templateDoc.of({ path: ctx.path, language: ctx.language }),
|
||||
fieldCompletion,
|
||||
|
||||
@@ -4,7 +4,22 @@
|
||||
* with Emmet" on the enclosing tag. Pure functions over document text; the
|
||||
* editor glue lives in `./editor`.
|
||||
*/
|
||||
import expand, { extract, type UserConfig } from "emmet";
|
||||
import type { UserConfig } from "emmet";
|
||||
|
||||
type Emmet = typeof import("emmet");
|
||||
let emmet: Emmet | null = null;
|
||||
let loading: Promise<Emmet> | null = null;
|
||||
|
||||
/** Loads the Emmet engine, kept out of start-up; expansion finds nothing until it has. */
|
||||
export function loadEmmet(): Promise<Emmet> {
|
||||
return (loading ??= import("emmet").then(
|
||||
(m) => (emmet = m),
|
||||
(e: unknown) => {
|
||||
loading = null;
|
||||
throw e;
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
export interface EmmetSyntax {
|
||||
type: "markup" | "stylesheet";
|
||||
@@ -15,6 +30,9 @@ export interface EmmetSyntax {
|
||||
const MARKUP_LANGUAGES = new Set(["html", "vue", "svelte"]);
|
||||
const STYLESHEET_LANGUAGES: Record<string, string> = { css: "css", scss: "scss", less: "less", sass: "sass" };
|
||||
|
||||
/** Whether `language` files expand Emmet abbreviations. */
|
||||
export const hasEmmet = (language: string) => MARKUP_LANGUAGES.has(language) || language in STYLESHEET_LANGUAGES;
|
||||
|
||||
/** Text before the caret that is scanned for context. */
|
||||
const CONTEXT_LIMIT = 200_000;
|
||||
|
||||
@@ -96,14 +114,18 @@ export interface Expansion {
|
||||
|
||||
/** The abbreviation ending at `col` of `line` expanded, or null when there is none. */
|
||||
export function expandAt(line: string, col: number, syntax: EmmetSyntax): Expansion | null {
|
||||
const found = extract(line, col, { type: syntax.type, lookAhead: true });
|
||||
if (!emmet) {
|
||||
void loadEmmet().catch(() => {});
|
||||
return null;
|
||||
}
|
||||
const found = emmet.extract(line, col, { type: syntax.type, lookAhead: true });
|
||||
if (!found || !found.abbreviation.trim()) return null;
|
||||
const abbr = found.abbreviation;
|
||||
// A bare number or a closing-tag fragment is never meant as an abbreviation.
|
||||
if (/^\d/.test(abbr) || abbr.startsWith("/")) return null;
|
||||
let snippet: string;
|
||||
try {
|
||||
snippet = expand(abbr, config(syntax, true));
|
||||
snippet = emmet.default(abbr, config(syntax, true));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -194,9 +216,10 @@ export interface TextEdit {
|
||||
export function updateTagEdits(tag: TagRange, abbreviation: string): TextEdit[] | null {
|
||||
const abbr = abbreviation.trim();
|
||||
if (!abbr || /[>+^*{]/.test(abbr.replace(/\[[^\]]*\]/g, ""))) return null;
|
||||
if (!emmet) return null;
|
||||
let html: string;
|
||||
try {
|
||||
html = expand(abbr, config({ type: "markup", syntax: "html" }, false));
|
||||
html = emmet.default(abbr, config({ type: "markup", syntax: "html" }, false));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { fileTemplates, useFileTemplates, watchTemplateProject } from "./files/s
|
||||
import type { FileTemplate } from "./files/model";
|
||||
import { syncTemplatesFromStorage } from "./sync";
|
||||
import { tagAtCaret, updateTag } from "./emmet/editor";
|
||||
import { loadEmmet } from "./emmet/model";
|
||||
import { promptAbbreviation, UpdateTagDialogHost } from "./emmet/UpdateTagDialog";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
@@ -35,7 +36,7 @@ async function updateTagWithEmmet() {
|
||||
notifications().notify({ level: "info", title: "No tag at the caret", key: "emmet-update-tag" });
|
||||
return;
|
||||
}
|
||||
const abbreviation = await promptAbbreviation(tag);
|
||||
const [abbreviation] = await Promise.all([promptAbbreviation(tag), loadEmmet()]);
|
||||
editor.view.focus();
|
||||
if (abbreviation && !updateTag(editor.view, abbreviation))
|
||||
notifications().notify({
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Icon, Tree, fileIconName } from "@/ui";
|
||||
import { DiffEditor } from "./DiffEditor";
|
||||
import { Icon, Tree, fileIconName, lazyComponent } from "@/ui";
|
||||
import { diffInput, openDiff, type DiffData } from "./diffInput";
|
||||
import { dirKeys, type FileTreeNode } from "./fileTree";
|
||||
import styles from "./common.module.css";
|
||||
|
||||
const DiffEditor = lazyComponent(() => import("./DiffEditor").then((m) => m.DiffEditor));
|
||||
|
||||
/** Shared changes tree and on-demand diff surface for revisions and disk folders. */
|
||||
export function ComparisonTree<F>({
|
||||
tree,
|
||||
|
||||
@@ -161,6 +161,8 @@
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
outline: none;
|
||||
/* Rows coming and going while scrolling relay out the list alone, not the workbench. */
|
||||
contain: strict;
|
||||
}
|
||||
.row {
|
||||
position: absolute;
|
||||
|
||||
+152
-89
@@ -6,6 +6,7 @@ import { branchesByRepo, commitKey, commitRepo, graphCommit, type RepoCommit } f
|
||||
import { IssueText } from "./issueLinks";
|
||||
import { BranchFilterPopup } from "./BranchFilterPopup";
|
||||
import {
|
||||
Fragment,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
@@ -43,7 +44,9 @@ import {
|
||||
type TreeHandle,
|
||||
T,
|
||||
copyText,
|
||||
hideNativeScrollbar,
|
||||
lazyComponent,
|
||||
Scrollbar,
|
||||
} from "@/ui";
|
||||
import { hasPrimaryModifier } from "@/platform/keymap";
|
||||
import { laneColor, reachableFrom, type GraphRow, type GraphSegment } from "./graph";
|
||||
@@ -77,6 +80,7 @@ import {
|
||||
toggleRepository,
|
||||
updateLogOptions,
|
||||
useGitLog,
|
||||
PAGE,
|
||||
} from "./logStore";
|
||||
import { logOptions, refLabels, useLogOptions, type LogOptions } from "./logOptions";
|
||||
import { KIND_COLOR, baseName, dirName, fileCount, formatTime, relTo, type EffectiveKind } from "./model";
|
||||
@@ -1397,17 +1401,22 @@ function CommitList() {
|
||||
count: commits.length + (finished ? 0 : 1),
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => ROW,
|
||||
overscan: 20,
|
||||
overscan: 8,
|
||||
scrollMargin: ROW,
|
||||
});
|
||||
const items = virtualizer.getVirtualItems();
|
||||
const last = items[items.length - 1];
|
||||
|
||||
// The next page is requested while a page of rows is still ahead, so scrolling
|
||||
// quickly doesn't reach the end before `git log` answers.
|
||||
useEffect(() => {
|
||||
if (!finished && !loading && last && last.index >= commits.length - 40) void loadMore(scope ?? undefined);
|
||||
if (!finished && !loading && last && last.index >= commits.length - PAGE) void loadMore(scope ?? undefined);
|
||||
}, [last, commits.length, finished, loading, active, scope]);
|
||||
|
||||
const selectedIndex = selected ? commits.findIndex((c) => commitKey(c) === selected) : -1;
|
||||
const selectedIndex = useMemo(
|
||||
() => (selected ? commits.findIndex((c) => commitKey(c) === selected) : -1),
|
||||
[commits, selected],
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
if (selectedIndex >= 0) virtualizer.scrollToIndex(selectedIndex, { align: "center" });
|
||||
// Only on explicit reveal requests.
|
||||
@@ -1424,6 +1433,28 @@ function CommitList() {
|
||||
else if (e.metaKey || e.ctrlKey) toggleCommitSelection(id);
|
||||
else selectCommit(id);
|
||||
}, []);
|
||||
// CommitRow is memoized: rows get handlers that stay the same across renders and
|
||||
// call this render's (assigned before rendering).
|
||||
const rowEvents = useRef<RowEvents | null>(null);
|
||||
const rowHandlers = useMemo<RowEvents>(
|
||||
() => ({
|
||||
onHover: (edge) => rowEvents.current?.onHover(edge),
|
||||
onFragment: (from, target, expand) => rowEvents.current?.onFragment(from, target, expand),
|
||||
onNavigate: (index, key) => rowEvents.current?.onNavigate(index, key),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
// Called while a row renders its graph, so it changes with what it reads.
|
||||
const targetTitle = useCallback(
|
||||
(index: number, key?: string) => {
|
||||
const target = commits[index];
|
||||
return target
|
||||
? `${target.shortId} ${target.summary} — ${target.author}`
|
||||
: (key && boundaryTitles.get(key)) ||
|
||||
`${tx("Commit is outside the loaded history")} · ${key?.split("\0").at(-1)?.slice(0, 8) ?? ""}`;
|
||||
},
|
||||
[commits, boundaryTitles],
|
||||
);
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (!e.ctrlKey && !e.metaKey && !e.altKey && (e.key.length === 1 || e.key === "Backspace" || e.key === "Escape")) {
|
||||
@@ -1595,6 +1626,38 @@ function CommitList() {
|
||||
}
|
||||
};
|
||||
|
||||
rowEvents.current = {
|
||||
onHover: (edge) => {
|
||||
setHoverColor(edge);
|
||||
if (!edge) return;
|
||||
const source = displayedGraph.find((c) => c.parents.some((p) => `${c.id}\0${p}` === edge));
|
||||
const target = source?.parents.find((p) => `${source.id}\0${p}` === edge);
|
||||
if (!source || !target || commits.some((c) => commitKey(c) === target) || boundaryRequests.current.has(target))
|
||||
return;
|
||||
const original = all.find((c) => commitKey(c) === source.id);
|
||||
if (!original) return;
|
||||
boundaryRequests.current.add(target);
|
||||
const split = target.lastIndexOf("\0"),
|
||||
id = target.slice(split + 1);
|
||||
void loadLogDetails({ ...original, id })
|
||||
.then((details) =>
|
||||
setBoundaryTitles((current) =>
|
||||
new Map(current).set(target, `${id.slice(0, 8)} ${details.message.split("\n")[0]}`),
|
||||
),
|
||||
)
|
||||
.catch(() => undefined);
|
||||
},
|
||||
onFragment: fragment,
|
||||
onNavigate: (index, key) => {
|
||||
const target = commits[index];
|
||||
if (target) selectCommit(commitKey(target), true);
|
||||
else if (key) {
|
||||
const split = key.lastIndexOf("\0");
|
||||
void goToRef(key.slice(split + 1), split >= 0 ? key.slice(0, split) : currentRepo());
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.listWrap}>
|
||||
{earlier && (
|
||||
@@ -1612,9 +1675,10 @@ function CommitList() {
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<Scrollbar target={scrollRef} contentHeight={virtualizer.getTotalSize()} />
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={styles.list}
|
||||
className={clsx(styles.list, hideNativeScrollbar)}
|
||||
role="listbox"
|
||||
aria-label={tx("Commits")}
|
||||
aria-multiselectable
|
||||
@@ -1673,7 +1737,9 @@ function CommitList() {
|
||||
);
|
||||
return (
|
||||
<CommitRow
|
||||
key={commitKey(c)}
|
||||
// Keyed by slot, not commit: a scrollbar drag that jumps past every mounted
|
||||
// row then updates the same DOM nodes instead of rebuilding them.
|
||||
key={v.index % items.length}
|
||||
id={`commit-${scope ?? "log"}-${v.index}`}
|
||||
commit={c}
|
||||
graph={rows[v.index]}
|
||||
@@ -1683,54 +1749,18 @@ function CommitList() {
|
||||
mine={opts.highlightMine && isMine(c, user)}
|
||||
onBranch={!!onBranch?.has(commitKey(c))}
|
||||
opts={opts}
|
||||
remoteNames={new Set((c as RepoCommit).remoteNames ?? remoteNames)}
|
||||
remoteNames={rowRemoteNames((c as RepoCommit).remoteNames, remoteNames)}
|
||||
onPick={pick}
|
||||
hoverColor={hoverColor}
|
||||
highlighted={highlighted}
|
||||
highlightedNodes={highlightedNodes}
|
||||
onHover={(edge) => {
|
||||
setHoverColor(edge);
|
||||
if (!edge) return;
|
||||
const source = displayedGraph.find((c) => c.parents.some((p) => `${c.id}\0${p}` === edge));
|
||||
const target = source?.parents.find((p) => `${source.id}\0${p}` === edge);
|
||||
if (
|
||||
!source ||
|
||||
!target ||
|
||||
commits.some((c) => commitKey(c) === target) ||
|
||||
boundaryRequests.current.has(target)
|
||||
)
|
||||
return;
|
||||
const original = all.find((c) => commitKey(c) === source.id);
|
||||
if (!original) return;
|
||||
boundaryRequests.current.add(target);
|
||||
const split = target.lastIndexOf("\0"),
|
||||
id = target.slice(split + 1);
|
||||
void loadLogDetails({ ...original, id })
|
||||
.then((details) =>
|
||||
setBoundaryTitles((current) =>
|
||||
new Map(current).set(target, `${id.slice(0, 8)} ${details.message.split("\n")[0]}`),
|
||||
),
|
||||
)
|
||||
.catch(() => undefined);
|
||||
}}
|
||||
onFragment={fragment}
|
||||
onNavigate={(index, key) => {
|
||||
const target = commits[index];
|
||||
if (target) selectCommit(commitKey(target), true);
|
||||
else if (key) {
|
||||
const split = key.lastIndexOf("\0");
|
||||
void goToRef(key.slice(split + 1), split >= 0 ? key.slice(0, split) : currentRepo());
|
||||
}
|
||||
}}
|
||||
targetTitle={(index, key) => {
|
||||
const target = commits[index];
|
||||
return target
|
||||
? `${target.shortId} ${target.summary} — ${target.author}`
|
||||
: (key && boundaryTitles.get(key)) ||
|
||||
`${tx("Commit is outside the loaded history")} · ${key?.split("\0").at(-1)?.slice(0, 8) ?? ""}`;
|
||||
}}
|
||||
onHover={rowHandlers.onHover}
|
||||
onFragment={rowHandlers.onFragment}
|
||||
onNavigate={rowHandlers.onNavigate}
|
||||
targetTitle={targetTitle}
|
||||
columns={columns}
|
||||
bookmarked={!!bookmarks[commitKey(c)]}
|
||||
interactive={!virtualizer.isScrolling}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -1840,7 +1870,23 @@ function LogColumnHeader({
|
||||
);
|
||||
}
|
||||
|
||||
const CommitRow = memo(function CommitRow({
|
||||
type RowEvents = {
|
||||
onHover: (edge: string | null) => void;
|
||||
onFragment: (from: string, target: string, expand: boolean) => void;
|
||||
onNavigate: (index: number, key?: string) => void;
|
||||
};
|
||||
|
||||
const remoteNameSets = new WeakMap<readonly string[], ReadonlySet<string>>();
|
||||
|
||||
/** A commit's own remote names (multi-root logs) as one set per array, else the shared one. */
|
||||
function rowRemoteNames(own: readonly string[] | undefined, shared: ReadonlySet<string>): ReadonlySet<string> {
|
||||
if (!own) return shared;
|
||||
let set = remoteNameSets.get(own);
|
||||
if (!set) remoteNameSets.set(own, (set = new Set(own)));
|
||||
return set;
|
||||
}
|
||||
|
||||
function CommitRowView({
|
||||
id,
|
||||
commit: c,
|
||||
graph,
|
||||
@@ -1861,6 +1907,7 @@ const CommitRow = memo(function CommitRow({
|
||||
targetTitle,
|
||||
columns,
|
||||
bookmarked,
|
||||
interactive,
|
||||
}: {
|
||||
id: string;
|
||||
commit: Commit;
|
||||
@@ -1882,6 +1929,8 @@ const CommitRow = memo(function CommitRow({
|
||||
targetTitle: (index: number, key?: string) => string;
|
||||
columns: LogColumn[];
|
||||
bookmarked: boolean;
|
||||
/** False for rows rendered mid-scroll: their graph is drawn without hit targets. */
|
||||
interactive: boolean;
|
||||
}) {
|
||||
const merge = opts.highlightMerges && c.parents.length > 1;
|
||||
const subjectRef = useRef<HTMLSpanElement>(null);
|
||||
@@ -1945,6 +1994,7 @@ const CommitRow = memo(function CommitRow({
|
||||
targetTitle={targetTitle}
|
||||
onFragment={(target, expand, source) => onFragment(source ?? commitKey(c), target, expand)}
|
||||
onNode={() => onFragment(commitKey(c), commitKey(c), false)}
|
||||
interactive={interactive}
|
||||
/>
|
||||
)}
|
||||
{bookmarked && <Icon name="bookmark" size={12} />}
|
||||
@@ -1993,6 +2043,16 @@ const CommitRow = memo(function CommitRow({
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A row that is already interactive stays so when scrolling starts; only rows
|
||||
// whose content changes mid-scroll are drawn without hit targets.
|
||||
const CommitRow = memo(CommitRowView, (prev, next) => {
|
||||
for (const key in next) {
|
||||
const k = key as keyof typeof next;
|
||||
if (prev[k] !== next[k] && !(k === "interactive" && prev.interactive)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
function x(lane: number) {
|
||||
@@ -2015,6 +2075,7 @@ export function GraphCell({
|
||||
onFragment,
|
||||
targetTitle,
|
||||
onNode,
|
||||
interactive = true,
|
||||
}: {
|
||||
row: GraphRow;
|
||||
head?: boolean;
|
||||
@@ -2026,9 +2087,27 @@ export function GraphCell({
|
||||
onFragment?: (target: string, expand: boolean, source?: string) => void;
|
||||
targetTitle?: (index: number, key?: string) => string;
|
||||
onNode?: () => void;
|
||||
interactive?: boolean;
|
||||
}) {
|
||||
const cy = ROW / 2;
|
||||
const draw = (s: GraphSegment, i: number, up: boolean) => {
|
||||
const fromX = x(s.fromX ?? s.from);
|
||||
const toX = x(s.toX ?? s.to);
|
||||
const path =
|
||||
seg(fromX, up ? 0 : cy, toX, up ? cy : ROW) +
|
||||
(s.arrow ? arrowHead(up ? fromX : toX, up ? 0 : ROW, up ? -1 : 1) : "");
|
||||
const line = (
|
||||
<path
|
||||
key={interactive ? undefined : `${up ? "u" : "d"}${i}`}
|
||||
d={path}
|
||||
stroke={laneColor(s.color)}
|
||||
strokeWidth={s.edge != null && (activeColor === s.edge || highlighted?.has(s.edge)) ? 2.8 : 1.4}
|
||||
pointerEvents="none"
|
||||
strokeLinecap={s.dashed ? "butt" : "square"}
|
||||
strokeDasharray={s.dashed ? "2 2" : undefined}
|
||||
/>
|
||||
);
|
||||
if (!interactive) return line;
|
||||
const target = (up ? row.upTargets : row.downTargets)?.[i];
|
||||
const canFragment =
|
||||
!!target &&
|
||||
@@ -2050,50 +2129,34 @@ export function GraphCell({
|
||||
if (canNavigate) onNavigate?.(s.targetRow!, target);
|
||||
else if (canFragment) onFragment?.(target!, !!s.dashed, s.source);
|
||||
};
|
||||
const fromX = x(s.fromX ?? s.from);
|
||||
const toX = x(s.toX ?? s.to);
|
||||
const path =
|
||||
seg(fromX, up ? 0 : cy, toX, up ? cy : ROW) +
|
||||
(s.arrow ? arrowHead(up ? fromX : toX, up ? 0 : ROW, up ? -1 : 1) : "");
|
||||
return (
|
||||
<g
|
||||
key={`${up ? "u" : "d"}${i}`}
|
||||
role={actionable ? "button" : undefined}
|
||||
tabIndex={actionable ? 0 : undefined}
|
||||
aria-label={actionable ? label : undefined}
|
||||
onMouseEnter={() => onHover?.(s.edge ?? `${s.source ?? ""}\0${target ?? ""}`)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
activate();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (actionable && (e.key === "Enter" || e.key === " ")) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
activate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<title>{label}</title>
|
||||
<rect
|
||||
x={Math.min(fromX, toX) - 5}
|
||||
y={up ? 0 : cy}
|
||||
width={Math.max(10, Math.abs(fromX - toX) + 10)}
|
||||
height={cy}
|
||||
fill="transparent"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
<path d={path} stroke="transparent" strokeWidth={10} style={{ pointerEvents: "stroke" }} />
|
||||
<Fragment key={`${up ? "u" : "d"}${i}`}>
|
||||
<path
|
||||
d={path}
|
||||
stroke={laneColor(s.color)}
|
||||
strokeWidth={s.edge != null && (activeColor === s.edge || highlighted?.has(s.edge)) ? 2.8 : 1.4}
|
||||
pointerEvents="none"
|
||||
strokeLinecap={s.dashed ? "butt" : "square"}
|
||||
strokeDasharray={s.dashed ? "2 2" : undefined}
|
||||
/>
|
||||
</g>
|
||||
stroke="transparent"
|
||||
strokeWidth={10}
|
||||
style={{ pointerEvents: "stroke" }}
|
||||
role={actionable ? "button" : undefined}
|
||||
tabIndex={actionable ? 0 : undefined}
|
||||
aria-label={actionable ? label : undefined}
|
||||
onMouseEnter={() => onHover?.(s.edge ?? `${s.source ?? ""}\0${target ?? ""}`)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
activate();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (actionable && (e.key === "Enter" || e.key === " ")) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
activate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<title>{label}</title>
|
||||
</path>
|
||||
{line}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
return (
|
||||
|
||||
@@ -84,7 +84,7 @@ describe("disk directory comparison", () => {
|
||||
it("previews on selection and opens the existing diff on double click, including missing sides", async () => {
|
||||
setup();
|
||||
fireEvent.mouseDown(await screen.findByText("中文.txt"), { button: 0 });
|
||||
expect(screen.getByTestId("diff")).toHaveTextContent("/p/left/nested/中文.txt");
|
||||
expect(await screen.findByTestId("diff")).toHaveTextContent("/p/left/nested/中文.txt");
|
||||
fireEvent.doubleClick(screen.getByText("中文.txt"));
|
||||
expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ path: "/p/right/nested/中文.txt" }));
|
||||
expect(directoryDiff("/l", "/r", rows[2]!)?.right.type).toBe("text");
|
||||
@@ -122,7 +122,7 @@ describe("disk directory comparison", () => {
|
||||
? ["/p/right", "/p/left", "nested/中文.txt"]
|
||||
: ["/p/left", "/p/right", "nested/中文.txt"],
|
||||
);
|
||||
expect(screen.getByTestId("diff")).toBeInTheDocument();
|
||||
expect(await screen.findByTestId("diff")).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ describe("directory comparison", () => {
|
||||
expect(screen.getByText("−1")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("diff")).toBeNull();
|
||||
fireEvent.mouseDown(screen.getByText("app.ts"), { button: 0 });
|
||||
expect(screen.getByTestId("diff")).toHaveTextContent('"rev":"aaa"');
|
||||
expect(await screen.findByTestId("diff")).toHaveTextContent('"rev":"aaa"');
|
||||
expect(screen.getByTestId("diff")).toHaveTextContent("/workspace/client/src/app.ts");
|
||||
});
|
||||
|
||||
|
||||
@@ -67,7 +67,6 @@ beforeEach(() => {
|
||||
logTabs: [],
|
||||
filters: emptyFilters(),
|
||||
commits: [],
|
||||
rows: [],
|
||||
selection: [],
|
||||
selected: null,
|
||||
done: false,
|
||||
|
||||
@@ -82,7 +82,6 @@ beforeEach(() => {
|
||||
tab: "log",
|
||||
logTabs: [],
|
||||
commits: [],
|
||||
rows: [],
|
||||
filters: emptyFilters(),
|
||||
selection: [],
|
||||
selected: null,
|
||||
|
||||
@@ -42,7 +42,6 @@ beforeEach(() => {
|
||||
tab: "log",
|
||||
logTabs: [],
|
||||
commits: [],
|
||||
rows: [],
|
||||
filters: emptyFilters(),
|
||||
selection: [],
|
||||
selected: null,
|
||||
|
||||
@@ -224,12 +224,10 @@ describe("Git log background refresh", () => {
|
||||
const refresh = reloadLog({ preserve: true });
|
||||
await vi.waitFor(() => expect(finish).toBeDefined());
|
||||
expect(useGitLog.getState().commits).toBe(before.commits);
|
||||
expect(useGitLog.getState().rows).toBe(before.rows);
|
||||
expect(useGitLog.getState().loading).toBe(true);
|
||||
finish({ status: "ok", data: [commit("new"), commit("old")] });
|
||||
await refresh;
|
||||
expect(useGitLog.getState().commits.map((c) => c.id)).toEqual(["new", "old"]);
|
||||
expect(useGitLog.getState().rows).toHaveLength(2);
|
||||
expect(useGitLog.getState().selected).toBe(commitKey(before.commits[0]!));
|
||||
expect(useGitLog.getState().loading).toBe(false);
|
||||
});
|
||||
@@ -290,7 +288,6 @@ describe("Git log background refresh", () => {
|
||||
log.mockRejectedValue(new Error("unavailable"));
|
||||
await reloadLog({ preserve: true });
|
||||
expect(useGitLog.getState().commits).toBe(before.commits);
|
||||
expect(useGitLog.getState().rows).toBe(before.rows);
|
||||
expect(useGitLog.getState().error).toContain("unavailable");
|
||||
expect(useGitLog.getState().loading).toBe(false);
|
||||
});
|
||||
|
||||
+38
-31
@@ -458,7 +458,14 @@ const LONG_EDGE_STUB = 2;
|
||||
|
||||
const onLane = (i: number) => (s: GraphSegment) => s.from === i && s.to === i;
|
||||
|
||||
const passes = (row: GraphRow, i: number) => row.lane !== i && row.up.some(onLane(i)) && row.down.some(onLane(i));
|
||||
/** Lanes a row's vertical lines pass straight through (other than its node's). */
|
||||
function passingLanes(row: GraphRow): Set<number> {
|
||||
const out = new Set<number>();
|
||||
for (const s of row.up)
|
||||
if (s.from === s.to && s.from !== row.lane && row.down.some((d) => d.from === s.from && d.to === s.from))
|
||||
out.add(s.from);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show Long Edges off: the middle of a straight run of a lane through `minLength` or
|
||||
@@ -466,41 +473,41 @@ const passes = (row: GraphRow, i: number) => row.lane !== i && row.up.some(onLan
|
||||
*/
|
||||
export function hideLongEdges(rows: readonly GraphRow[], minLength = LONG_EDGE): GraphRow[] {
|
||||
const out = rows.slice();
|
||||
const width = rows.reduce((max, row) => Math.max(max, row.width), 0);
|
||||
const edit = (r: number, fn: (row: GraphRow) => GraphRow) => {
|
||||
out[r] = fn(out[r]!);
|
||||
};
|
||||
for (let i = 0; i < width; i++) {
|
||||
const hide = (i: number, start: number, end: number) => {
|
||||
const lane = onLane(i);
|
||||
let start = -1;
|
||||
for (let r = 0; r <= rows.length; r++) {
|
||||
if (r < rows.length && passes(rows[r]!, i)) {
|
||||
if (start < 0) start = r;
|
||||
continue;
|
||||
}
|
||||
if (start >= 0 && r - start >= minLength) {
|
||||
const from = start + LONG_EDGE_STUB;
|
||||
const to = r - 1 - LONG_EDGE_STUB;
|
||||
for (let k = from; k <= to; k++) {
|
||||
edit(k, (row) => ({
|
||||
...row,
|
||||
up: row.up.filter((s) => !lane(s)),
|
||||
down: row.down.filter((s) => !lane(s)),
|
||||
upTargets: row.upTargets?.filter((_, i) => !lane(row.up[i]!)),
|
||||
downTargets: row.downTargets?.filter((_, i) => !lane(row.down[i]!)),
|
||||
}));
|
||||
}
|
||||
edit(from - 1, (row) => ({
|
||||
...row,
|
||||
down: row.down.map((s) => (lane(s) ? { ...s, arrow: "down", targetRow: r } : s)),
|
||||
}));
|
||||
edit(to + 1, (row) => ({
|
||||
...row,
|
||||
up: row.up.map((s) => (lane(s) ? { ...s, arrow: "up", targetRow: start - 1 } : s)),
|
||||
}));
|
||||
}
|
||||
start = -1;
|
||||
const from = start + LONG_EDGE_STUB;
|
||||
const to = end - 1 - LONG_EDGE_STUB;
|
||||
for (let k = from; k <= to; k++) {
|
||||
edit(k, (row) => ({
|
||||
...row,
|
||||
up: row.up.filter((s) => !lane(s)),
|
||||
down: row.down.filter((s) => !lane(s)),
|
||||
upTargets: row.upTargets?.filter((_, i) => !lane(row.up[i]!)),
|
||||
downTargets: row.downTargets?.filter((_, i) => !lane(row.down[i]!)),
|
||||
}));
|
||||
}
|
||||
edit(from - 1, (row) => ({
|
||||
...row,
|
||||
down: row.down.map((s) => (lane(s) ? { ...s, arrow: "down", targetRow: end } : s)),
|
||||
}));
|
||||
edit(to + 1, (row) => ({
|
||||
...row,
|
||||
up: row.up.map((s) => (lane(s) ? { ...s, arrow: "up", targetRow: start - 1 } : s)),
|
||||
}));
|
||||
};
|
||||
// One pass over the rows, following every lane's current run at once.
|
||||
const runs = new Map<number, number>();
|
||||
for (let r = 0; r <= rows.length; r++) {
|
||||
const through = r < rows.length ? passingLanes(rows[r]!) : new Set<number>();
|
||||
for (const [lane, start] of runs) {
|
||||
if (through.has(lane)) continue;
|
||||
if (r - start >= minLength) hide(lane, start, r);
|
||||
runs.delete(lane);
|
||||
}
|
||||
for (const lane of through) if (!runs.has(lane)) runs.set(lane, r);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -60,7 +60,6 @@ import {
|
||||
unshelve,
|
||||
} from "./shelf";
|
||||
import { currentRepo, focusRepo, useVcs } from "./store";
|
||||
import { openExternalLog } from "./RevisionBrowser";
|
||||
|
||||
const VCS = "vcsAvailable";
|
||||
const IN_GIT_LOG = "(vcsAvailable || projectOpen) && (activeToolWindow == git || vcsLogFocus)";
|
||||
@@ -250,7 +249,7 @@ export function registerHistoryActions(): () => void {
|
||||
id: "vcs.log.openExternal",
|
||||
title: "Open External Git Log…",
|
||||
category: "Git Log",
|
||||
run: openExternalLog,
|
||||
run: async () => (await import("./RevisionBrowser")).openExternalLog(),
|
||||
},
|
||||
{
|
||||
id: "vcs.log.goToRef",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Git log tool window state: filters, paged commits, graph, selection. */
|
||||
import { branchesByRepo, commitKey, commitRepo, graphCommit, mergeRepoLogs, type RepoCommit } from "./multiRoot";
|
||||
import { branchesByRepo, commitKey, commitRepo, mergeRepoLogs, type RepoCommit } from "./multiRoot";
|
||||
import { create } from "zustand";
|
||||
import { useContext } from "react";
|
||||
import { LogIdContext } from "./logContext";
|
||||
@@ -8,7 +8,6 @@ import { maybeEditorService } from "@/workbench/contributions";
|
||||
import type { Branch, BranchInfo, Commit, LogQuery } from "@/bindings";
|
||||
import { call, commands, nextRequestId, type RequestId } from "@/platform/ipc";
|
||||
import { workflowSettings } from "@/platform/workflowSettings";
|
||||
import { computeGraph, emptyGraphState, type GraphRow, type GraphState } from "./graph";
|
||||
import { logOptions, parseLogOptions, setLogOptions, useLogOptions, type LogOptions } from "./logOptions";
|
||||
import { clearConsole, errorMessage } from "./ops";
|
||||
import { currentRepo, currentRepoRoot, repoName, repoOf, useVcs } from "./store";
|
||||
@@ -48,8 +47,6 @@ export interface LogFilters {
|
||||
interface LogState {
|
||||
filters: LogFilters;
|
||||
commits: RepoCommit[];
|
||||
rows: GraphRow[];
|
||||
graph: GraphState;
|
||||
loading: boolean;
|
||||
done: boolean;
|
||||
error: string | null;
|
||||
@@ -179,8 +176,6 @@ const initialLog = (): Omit<LogState, "generation" | "viewsVersion"> => ({
|
||||
filters: emptyFilters(),
|
||||
seek: null,
|
||||
commits: [],
|
||||
rows: [],
|
||||
graph: emptyGraphState(),
|
||||
loading: false,
|
||||
done: false,
|
||||
error: null,
|
||||
@@ -293,8 +288,6 @@ type LogView = Pick<
|
||||
| "filters"
|
||||
| "seek"
|
||||
| "commits"
|
||||
| "rows"
|
||||
| "graph"
|
||||
| "loading"
|
||||
| "done"
|
||||
| "error"
|
||||
@@ -306,13 +299,11 @@ type LogView = Pick<
|
||||
> & { options: LogOptions };
|
||||
|
||||
function freshView(filters: LogFilters): LogView {
|
||||
const { commits, rows, graph, loading, done, error, selected, selection, revealTick, fragments } = initialLog();
|
||||
const { commits, loading, done, error, selected, selection, revealTick, fragments } = initialLog();
|
||||
return {
|
||||
filters,
|
||||
seek: null,
|
||||
commits,
|
||||
rows,
|
||||
graph,
|
||||
loading,
|
||||
done,
|
||||
error,
|
||||
@@ -329,26 +320,11 @@ function freshView(filters: LogFilters): LogView {
|
||||
const parked = new Map<string, LogView>();
|
||||
|
||||
function currentView(): LogView {
|
||||
const {
|
||||
filters,
|
||||
commits,
|
||||
rows,
|
||||
graph,
|
||||
done,
|
||||
error,
|
||||
selected,
|
||||
selection,
|
||||
revealTick,
|
||||
fragments,
|
||||
loading,
|
||||
generation,
|
||||
seek,
|
||||
} = useGitLog.getState();
|
||||
const { filters, commits, done, error, selected, selection, revealTick, fragments, loading, generation, seek } =
|
||||
useGitLog.getState();
|
||||
return {
|
||||
filters,
|
||||
commits,
|
||||
rows,
|
||||
graph,
|
||||
loading,
|
||||
seek,
|
||||
generation,
|
||||
@@ -764,7 +740,7 @@ export function reloadLog(
|
||||
cancelLogRequests(id);
|
||||
const next = nextGeneration();
|
||||
setViewState(id, () => ({
|
||||
...(preserve ? {} : { commits: [], rows: [], graph: emptyGraphState(), done: false }),
|
||||
...(preserve ? {} : { commits: [], done: false }),
|
||||
loading: true,
|
||||
error: null,
|
||||
generation: next,
|
||||
@@ -1027,11 +1003,8 @@ function loadPage(replace: boolean, id: string, before = false): Promise<void> {
|
||||
]),
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
);
|
||||
const { rows, state } = computeGraph(commits.map(graphCommit));
|
||||
setViewState(id, {
|
||||
commits,
|
||||
rows,
|
||||
graph: state,
|
||||
done: before
|
||||
? cur.done
|
||||
: commits.length >= cap
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Welcome screen, recent projects (File › Open Recent) and reopening the last project on startup. */
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import { lazyComponent } from "@/ui";
|
||||
import { provideProjectPopup, provideWelcomeView, registerOverlay } from "@/workbench/contributions";
|
||||
import { ProjectPopup } from "./ProjectPopup";
|
||||
import { RecentProjectsPopup, showRecentProjectsPopup } from "./RecentProjectsPopup";
|
||||
import { reopenLastProject, trackSession } from "./session";
|
||||
import { WelcomeView } from "./WelcomeView";
|
||||
@@ -10,7 +10,7 @@ export function activate(): () => void {
|
||||
const disposers = [
|
||||
trackSession(),
|
||||
provideWelcomeView(WelcomeView),
|
||||
provideProjectPopup(ProjectPopup),
|
||||
provideProjectPopup(lazyComponent(() => import("./ProjectPopup").then((m) => m.ProjectPopup))),
|
||||
registerOverlay({ id: "welcome.recentProjects", component: RecentProjectsPopup }),
|
||||
registerActions([
|
||||
{
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_DATE_FORMAT, formatClock, formatDay, formatPattern } from "./dateFormat";
|
||||
|
||||
describe("dateFormat", () => {
|
||||
const d = new Date(2026, 10, 5, 9, 7);
|
||||
const system = { ...DEFAULT_DATE_FORMAT, overrideSystem: false };
|
||||
const long = { year: "numeric", month: "short", day: "numeric" } as const;
|
||||
|
||||
it("formats like the locale methods it replaces", () => {
|
||||
expect(formatDay(d, system)).toBe(d.toLocaleDateString(undefined, long));
|
||||
expect(formatDay(d, system, true)).toBe(
|
||||
d.toLocaleDateString(undefined, { year: "2-digit", month: "2-digit", day: "2-digit" }),
|
||||
);
|
||||
expect(formatClock(d, system)).toBe(d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }));
|
||||
expect(formatPattern(d, "MMMM MMM dd.MM.yyyy")).toBe("November Nov 05.11.2026");
|
||||
});
|
||||
|
||||
it("formats another date with the same cached formatter", () => {
|
||||
const e = new Date(2025, 0, 31);
|
||||
formatDay(d, system);
|
||||
expect(formatDay(e, system)).toBe(e.toLocaleDateString(undefined, long));
|
||||
});
|
||||
});
|
||||
@@ -34,13 +34,23 @@ export function dateFormatOptions(): DateFormatOptions {
|
||||
|
||||
const pad = (n: number, w = 2) => String(n).padStart(w, "0");
|
||||
|
||||
// `toLocale*String` with options builds a new `Intl.DateTimeFormat` on every call,
|
||||
// far too slow for lists that format a date per row.
|
||||
const formatters = new Map<string, Intl.DateTimeFormat>();
|
||||
function formatter(locale: string | undefined, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
|
||||
const key = `${locale ?? ""}|${JSON.stringify(options)}`;
|
||||
let f = formatters.get(key);
|
||||
if (!f) formatters.set(key, (f = new Intl.DateTimeFormat(locale, options)));
|
||||
return f;
|
||||
}
|
||||
|
||||
/** Formats the date part with a `dd.MM.yyyy`-style pattern. */
|
||||
export function formatPattern(d: Date, pattern: string): string {
|
||||
const tokens: Record<string, () => string> = {
|
||||
yyyy: () => String(d.getFullYear()),
|
||||
yy: () => pad(d.getFullYear() % 100),
|
||||
MMMM: () => d.toLocaleString("en", { month: "long" }),
|
||||
MMM: () => d.toLocaleString("en", { month: "short" }),
|
||||
MMMM: () => formatter("en", { month: "long" }).format(d),
|
||||
MMM: () => formatter("en", { month: "short" }).format(d),
|
||||
MM: () => pad(d.getMonth() + 1),
|
||||
M: () => String(d.getMonth() + 1),
|
||||
dd: () => pad(d.getDate()),
|
||||
@@ -50,7 +60,7 @@ export function formatPattern(d: Date, pattern: string): string {
|
||||
}
|
||||
|
||||
export function formatClock(d: Date, o: DateFormatOptions = dateFormatOptions()): string {
|
||||
if (!o.overrideSystem) return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
||||
if (!o.overrideSystem) return formatter(undefined, { hour: "2-digit", minute: "2-digit" }).format(d);
|
||||
if (o.use24Hour) return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
const h = d.getHours() % 12 || 12;
|
||||
return `${h}:${pad(d.getMinutes())} ${d.getHours() < 12 ? "AM" : "PM"}`;
|
||||
@@ -60,8 +70,8 @@ export function formatClock(d: Date, o: DateFormatOptions = dateFormatOptions())
|
||||
export function formatDay(d: Date, o: DateFormatOptions = dateFormatOptions(), short = false): string {
|
||||
if (o.overrideSystem) return formatPattern(d, o.pattern);
|
||||
return short
|
||||
? d.toLocaleDateString(undefined, { year: "2-digit", month: "2-digit", day: "2-digit" })
|
||||
: d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
||||
? formatter(undefined, { year: "2-digit", month: "2-digit", day: "2-digit" }).format(d)
|
||||
: formatter(undefined, { year: "numeric", month: "short", day: "numeric" }).format(d);
|
||||
}
|
||||
|
||||
/** Timestamp for lists: pretty ("Today 10:42", "5 min ago") when enabled, else date + time. */
|
||||
|
||||
@@ -58,5 +58,13 @@
|
||||
"No preview": "No preview",
|
||||
"Running in the background": "Running in the background",
|
||||
"Couldn't stop the background task": "Couldn't stop the background task",
|
||||
"Branch '{name}'": "Branch '{name}'"
|
||||
"Copy Markdown": "Copy Markdown",
|
||||
"Couldn't copy the reply": "Couldn't copy the reply",
|
||||
"Quote Reply": "Quote Reply",
|
||||
"Regenerate Response": "Regenerate Response",
|
||||
"Branch Conversation from Here": "Branch Conversation from Here",
|
||||
"Couldn't regenerate the response": "Couldn't regenerate the response",
|
||||
"Reasoning": "Reasoning",
|
||||
"Branch '{name}'": "Branch '{name}'",
|
||||
"Cannot copy the value": "Cannot copy the value"
|
||||
}
|
||||
@@ -227,6 +227,7 @@
|
||||
"Allow placing breakpoints in any file": "Permitir colocar puntos de interrupción en cualquier archivo",
|
||||
"Also expand in comments and strings": "Expandir también en comentarios y cadenas",
|
||||
"Always": "Siempre",
|
||||
"Always Select Opened File": "Seleccionar siempre el archivo abierto",
|
||||
"Always show full path in window header": "Mostrar siempre la ruta completa en el encabezado de la ventana",
|
||||
"Amend": "Enmendar",
|
||||
"Analysing…": "Analizando…",
|
||||
|
||||
@@ -175,6 +175,7 @@
|
||||
"Allow placing breakpoints in any file": "Autoriser les points d'arrêt dans n'importe quel fichier",
|
||||
"Also expand in comments and strings": "Développer aussi dans les commentaires et les chaînes",
|
||||
"Always": "Toujours",
|
||||
"Always Select Opened File": "Toujours sélectionner le fichier ouvert",
|
||||
"Always show full path in window header": "Toujours afficher le chemin complet dans l'en-tête de la fenêtre",
|
||||
"Amend": "Amender",
|
||||
"Analysing…": "Analyse…",
|
||||
|
||||
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user