Compare commits

...
Author SHA1 Message Date
l0ng-ai 6d0b644e20 fix(editor): remove remaining gutter icon hover backgrounds 2026-11-07 14:06:39 +08:00
arookieofc 6efff739dd fix(vcs): reopen the repository when the pack directory changes (#1920)
Repo::gix() reopened the cached gitoxide handle only when config or packed-refs changed. An external git fetch/pull/repack writes new packs under objects/pack and moves refs, but never touches those files — so the ODB kept serving the snapshot taken at open, and refs whose tip lived only in the new pack failed to peel: branches vanished and status warned "Could not peel reference". Watch the pack directory's mtime too and reopen when it changes, so externally added objects become visible (issue #1919).

Fixes #1919
2026-11-07 13:32:05 +08:00
Jaw 8ca6e4bc01 fix(debugger): copy structured variable values with their contents (#1904) (#1917) 2026-11-07 13:15:27 +08:00
arookieofc 8d909598d5 fix(vfs): warn when the inotify watch limit leaves folders unwatched (#1918)
* fix(vfs): warn when the inotify watch limit leaves folders unwatched

When watch() hits the OS watch limit mid-tree (inotify ENOSPC/EMFILE), the affected directories were silently never watched: the project stayed stale with no hint. Record the failure on DirState and surface it through the new Watcher::unwatched_reason() — the host emits the 'sync may be slow' warning once the project opens and once on the first event batch after a late failure (deferred walk / update_watches).

Fixes #1915

* fix(vfs): latch the startup watch-limit warning so batches don't repeat it
2026-11-07 12:54:07 +08:00
l0ng-ai b99fe1704b fix(vcs): keep Git Log rows painted while dragging the scrollbar (#1914)
Dragging the Git Log scrollbar through a long history stuttered and showed
blank rows: the native scrollbar scrolls asynchronously in WebKit, so the
virtual list rendered rows a frame or more after the new offset was painted.

The list now uses a scrollbar drawn in the DOM (`Scrollbar` in `src/ui`) that
sets the offset from pointer events and notifies scroll listeners at once, so
the virtualizer renders the matching rows before the next paint. Rows are
keyed by slot so a large jump updates the mounted rows instead of rebuilding
them, overscan is lowered, and rows rendered mid-scroll draw their graph
without edge hit targets, which come back once scrolling stops.

Adds a `gitLogScrollbar` perf scenario with budgets.
2026-11-07 12:14:09 +08:00
l0ng-ai 159c2d6fd2 fix(lsp): find Go usages in build-constrained files and list their callers (#1911)
gopls builds a module for the host's GOOS/GOARCH only, so usages in files such as foo_windows.go were missing from Find Usages on macOS, and renaming left them behind, breaking the build for that platform. Those files of the searched modules are now resolved the way other modules are: each mention of the name in its own view (gopls builds one for the file's platform), added after gopls' answer and included in Rename.

Incoming Calls now also lists the callers found this way, in other modules and in build-constrained files: the declaration containing each call site.
2026-11-07 11:45:06 +08:00
l0ng-aiandtiube00001 5084388a68 fix(lsp): find Go usages without reopening gopls' files, and in other modules (#1909)
Finding usages of a common Go name such as New opened every project file that mentions it and asked gopls again, which took up to seconds and found nothing more: gopls answers from the view of the file it is asked about, and opened files only get views of their own. Go searches now skip that widening.

Usages in other modules of the project that use the declaration's module through a local replace (a multi-module repository without go.work) are found the way GoLand finds them: each mention of the name there is resolved in its own module's view, and those resolving to the declaration are added after gopls' own answer. Rename edits them too.

Supersedes #1749.

Co-authored-by: tiube00001 <[email protected]>
2026-11-07 11:29:53 +08:00
l0ng-ai 68342b32bf test(run): synchronize process shutdown tests to fix flaky CI (#1908) 2026-11-07 11:25:54 +08:00
Jaw 352efc02a7 feat(angular): highlight inline templates and auto-close tags (#1897)
Angular component templates in @Component({ template }) were parsed as TypeScript strings, so template highlighting and tag closing never applied. TypeScript files in Angular projects (angular.json or @angular/core) now use an Angular TypeScript mode that mounts the Angular template parser on literal template strings; other strings and non-Angular projects are unchanged. Inline and external templates both close tags on /, with multiple cursors and undo.

Closes #1895

Co-authored-by: jaw52 <[email protected]>
2026-11-07 11:13:09 +08:00
LU XIANGRUIandwildcatDownstairs 24ffb9f16e fix(workbench): even out the tool window stripes' spacing and draw their icons at 20px (#1741)
Stripe icon buttons were 28px with a 2px gap, plus an extra gap at each group boundary, and their 20px icons were drawn at 18px, off the pixel grid. Stripe buttons are now 30px and repeat at the title bar's height (40px, 32px compact), so the title bar's Settings button reads as the right stripe's first one; the empty drop slot after a group no longer adds a gap. Stripe, title bar and Run widget icons render at 20px.

Co-authored-by: wildcatDownstairs <[email protected]>
2026-11-07 11:13:00 +08:00
Jaw 8ebf839d8e feat(project): rename Autoscroll from Source to Always Select Opened File (#1694)
Following the active editor already worked, but the option kept IDEA's pre-2023 name and sat second under Behavior, so people didn't find it (#1666). It is now Always Select Opened File, listed first, and the header's Select Opened File button hides while it's on. Find Action still matches the old name; the setting key is unchanged.

Closes #1666

Co-authored-by: jaw52 <[email protected]>
2026-11-07 11:12:50 +08:00
Kamal ElFadlandelfadl 14a31fe8a1 feat(editor): add syntax highlighting for go.mod and go.work files (#1695)
go.mod and go.work opened as plain text: their language definitions had no ui-mode, so no CodeMirror language loaded. Add a GoMod mode (a small stream parser): directives as keywords, module paths as strings, versions as numbers, // comments; shared by go.mod and go.work.

Co-authored-by: elfadl <[email protected]>
2026-11-07 11:12:41 +08:00
Ilya Landmushketer888 1af1744290 feat(editor): highlight Arduino sketches as C++ (#1874)
Open .ino sketches with C++ highlighting and the C++ file icon. Sketches get their own Arduino language with no language server: clangd can't compile a sketch on its own (implicit Arduino.h, generated prototypes) and reported "Unable to handle compilation" on line 1 of every sketch.

Co-authored-by: mushketer888 <[email protected]>
2026-11-07 11:12:32 +08:00
l0ng-ai ee6786d573 fix(agents): improve chat copying, selection and scrolling (#1905)
* fix(agents): improve chat transcript interactions

* fix(agents): recover images when regeneration fails

* test(agents): use the native copy shortcut on every host
2026-11-07 10:57:18 +08:00
l0ng-ai 5804fcad16 perf: speed up Angular typing, Git Log, console output and start-up; measure more interactions (#1901)
* perf(project-tree): measure row widths once per frame

The horizontal scroll range was measured in a layout effect on every render:
each pass forced layout, its setState re-rendered synchronously and measured
again, and the ResizeObserver was recreated each time, firing once more.
Expanding a 10k-entry folder went from ~26 ms to ~63 ms.

Keep one observer per tree and coalesce renders and resizes into a single
measurement on the next animation frame. Expanding is back to ~30 ms and
arrow-key p50 drops from 3.3 ms to 1.7 ms; the widths behave as before.

* perf(github): load the pull requests integration after start-up

Actions, the tool window and the timeline editor stay registered at start-up;
their implementations, the connection and review-mode tracking load on idle
or on first use. Takes ~60 KB gzip off the initial bundle.

* perf(templates): load Emmet when an HTML or stylesheet editor opens

The Emmet engine (~70 KB minified) was part of the initial bundle; it now loads
with the first editor that can expand abbreviations, or with Update Tag with Emmet.

* perf(search, run): load popups and Edit Configurations when first shown

Search Everywhere, Find in Files, the recent files/locations popups, File
Structure and the Run/Debug Configurations dialog were part of the initial
bundle although each only renders while open. `lazyWhen` mounts a lazy view
only while its condition holds; the idle prefetch still loads them right after
start-up, so opening one stays as fast.

* perf: load dialogs, the SQL console and diff views on first use

- Database dialogs, Assign Shortcut and the Logcat tool window load when shown.
- The SQL console extensions load with the first SQL editor
  (`lazyEditorExtension` reconfigures the editor once they arrive).
- The changes tree loads the diff viewer when a file is selected, and
  "Open Repository Log" loads the revision browser when run.

* perf: load plugin, debugger and project popups when first shown

The IntelliJ theme import, the debugger's popovers and dialogs and the title
bar's project popup now load when first opened. `lazyWhen` keeps a view
mounted once shown, so its own open/closed handling is unchanged.

* perf(angular): parse only text with Angular syntax as Angular

Every text node of a template got its own nested Angular parse, including
the whitespace between tags, which is most of them. Text without a `{`, `}`
or `@` holds no interpolation or block, so it stays HTML text. A full parse
of a 12k-line template takes about a fifth less work.

* perf(angular): reuse nested trees of unchanged text and attributes

An edit makes the mixed parser recreate many text and attribute nodes it
does not keep: the document's own text and the nodes up to the viewport. It
reuses nested trees only of nodes it kept, so each of those was parsed again
as Angular on every keystroke (~170 nested parses per key in a 12k-line
template). Their parse depends only on their text, so the trees are now
remembered by text, up to about 2M characters.

Typing in the middle of a 12k-line template: main thread per key 57 -> 22 ms,
p50 latency 30 -> 19.5 ms, no long tasks.

* perf: budget typing in a large Angular template

* perf: measure code completion over a 2,000-item list

The harness's probe can answer completion with generated items through the
command route; language server requests go through a route when one is
installed, not only under Tauri. The scenario opens the popup by typing,
narrows it letter by letter and moves the selection, with budgets for each.

* perf(run): show a flood of console output at most every 50 ms

While a process printed thousands of lines a second, the Run console
appended, re-rendered and laid out on every frame, so typing in an editor
meanwhile missed the next frame 35% of the time. A line after a quiet spell
still shows on the next frame; a flood now updates the console at ~20 Hz,
and typing during it misses 2-5% (p50 16 -> 9.7 ms) with the output done as
fast as before.

The performance harness gets a scenario for it: 50,000 log lines at 200 a
frame (a dev and perf build action, run.sampleOutput) while typing.

* perf(vcs): keep Git Log rows from re-rendering on every selection change

Commit rows were memoized but received a new Set and new closures on every
render, so moving the selection re-rendered every visible row, and each row
built a new Intl.DateTimeFormat for its date. Rows now get stable handlers and
shared remote-name sets, and date formatting reuses cached formatters.

* perf: measure the Git Log and splitter dragging

gitLog shows 20k generated commits served page by page through a fake vcs_log,
walks the selection with the arrow keys and wheel-scrolls the list.
resizePanels drags the side and bottom splitters with an editor, the Project
tree and the Log open. The perf probe's fake backends can now be combined.

* perf: translate the sample output action and keep fake completion details out of the text catalog

* perf(vcs): keep Git Log scrolling smooth as pages load

Each page of the log recomputed hidden long edges by scanning every row once per
lane, and the store laid out the whole graph a second time for fields nothing
read. Long edges are now found in one pass over the rows, the unused store graph
is gone, and the next page is requested while a page of rows is still ahead so
fast scrolling doesn't reach the end before git answers. The commit list also
contains its layout, so rows entering while scrolling no longer relay out the
workbench.

* perf: load the Git Log scenario's pages with git's latency and scroll across several
2026-11-07 10:56:59 +08:00
119 changed files with 4431 additions and 545 deletions

No files matched your search

+38 -2
View File
@@ -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,
+11 -2
View File
@@ -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"),
+354
View File
@@ -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)),
]
);
}
}
+38 -2
View File
@@ -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
View File
@@ -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.
+159
View File
@@ -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())]);
+62 -30
View File
@@ -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");
+16 -2
View File
@@ -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}"),
}
+24
View File
@@ -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();
+87 -5
View File
@@ -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: &notify::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
View File
@@ -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);
});
+113
View File
@@ -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());
}
});
}
+62
View File
@@ -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);
});
+106
View File
@@ -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();
});
+10
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+4
View File
@@ -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
+33
View File
@@ -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,
+234
View File
@@ -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,
+41
View File
@@ -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;
+17 -7
View File
@@ -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` });
+34 -1
View File
@@ -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 &amp; Jerry</li>\n <li>{{ a }} &amp; {{ 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"), {
+118 -9
View File
@@ -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: ["[", "{", '"'] },
+161
View File
@@ -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();
}
});
});
+47
View File
@@ -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]);
}
+73
View File
@@ -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" },
]);
});
});
+98
View File
@@ -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);
+14
View File
@@ -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();
}
});
});
+13 -1
View File
@@ -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,
+14
View File
@@ -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 {
+26
View File
@@ -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();
});
});
+17 -1
View File
@@ -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 -1
View File
@@ -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);
}
+74
View File
@@ -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();
});
+77
View File
@@ -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>
);
}
+19
View File
@@ -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>',
+14 -8
View File
@@ -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();
};
}
+98 -24
View File
@@ -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 {
+41 -8
View File
@@ -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>
)}
+2
View File
@@ -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",
+42 -1
View File
@@ -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 };
+43
View File
@@ -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));
+159
View File
@@ -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;
});
+101
View File
@@ -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));
},
},
];
+5 -2
View File
@@ -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;
}
+2 -1
View File
@@ -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). */
+19 -8
View File
@@ -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);
},
},
{
+2
View File
@@ -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 …");
});
});
+10 -3
View File
@@ -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"],
+21 -1
View File
@@ -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!) });
+53
View File
@@ -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
View File
@@ -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());
};
}
+127
View File
@@ -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());
}
+5
View File
@@ -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[] = [];
-3
View File
@@ -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),
+2 -2
View File
@@ -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,
});
+8 -4
View File
@@ -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 -17
View File
@@ -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 });
}
+12 -8
View File
@@ -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" />
</>
+34 -1
View File
@@ -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"));
+2 -1
View File
@@ -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]),
}));
+1 -1
View File
@@ -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;
+4 -4
View File
@@ -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"
+28
View File
@@ -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);
});
});
+23 -3
View File
@@ -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({
+80 -2
View File
@@ -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);
}
+29 -11
View File
@@ -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;
+10 -3
View File
@@ -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);
+2
View File
@@ -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,
+27 -4
View File
@@ -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;
}
+2 -1
View File
@@ -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({
+3 -2
View File
@@ -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,
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+1 -2
View File
@@ -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",
+5 -32
View File
@@ -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
+2 -2
View File
@@ -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([
{
+23
View File
@@ -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));
});
});
+15 -5
View File
@@ -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. */
+9 -1
View File
@@ -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"
}
+1
View File
@@ -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…",
+1
View File
@@ -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