Compare commits

...
Author SHA1 Message Date
l0ng-ai 6b91fb396a fix(ui): stop selection stripes and flashes in reading panes
Repaint the pane when its selection changes, so a shrinking selection
leaves no stripe between blocks, and clamp a double or triple click
selection to the pane before it is painted into the next field.
2026-11-12 17:49:58 +08:00
l0ng-ai 4792647d06 fix(ui): keep the text selection a context menu was opened on 2026-11-12 17:49:57 +08:00
l0ng-ai 0262812d9b fix(search): find scratch files in Search Everywhere's Files results 2026-11-12 17:09:52 +08:00
l0ng-ai 33957ed34f fix(ui): show full labels in the commit panel, console preview and install hints (#2916)
- Commit panel: the action row wraps at narrow widths, so "Commit and Push…"
  moves to its own line instead of being cut to "Commit ..."; its tooltip
  names the action as well as the shortcut.
- Console Colors preview: four short lines, so the bright colors no longer
  run past the preview's right edge.
- Language server and debugger install hints are plain text: drop the
  Markdown backticks that showed up literally.
- Appearance: "Accent color", matching the rest of the UI.
2026-11-12 16:13:56 +08:00
l0ng-ai de9c8db74e fix(agents): edit composer references as ordinary text 2026-11-12 15:49:09 +08:00
l0ng-ai e1f41093ec fix(agents): delete a reference being typed character by character in the composer
Backspace, Delete and the arrow keys treated any @path or /command under the caret
as one tag, so backspacing while typing a path removed the whole path. The tag being
typed (or a folder entered with Tab) now edits character by character; a completed
reference still deletes as a unit.
2026-11-12 15:23:02 +08:00
l0ng-ai 53d5d66520 fix(agent): roll back renames to their old path (#2909)
Rolling back a task's rename restored only the new path, so the file at its old
path stayed gone. Rollback now also restores the rename source.

A case-only rename on a case-insensitive disk lost the file entirely: revision
lookups used the index's spelling of the path, which no longer matches the
revision's tree, so the old file read as absent. Revision lookups now try the
path as given first and fall back to the index's spelling.
2026-11-12 15:00:56 +08:00
l0ng-ai 0eb68afd40 fix(deploy): don't download or delete through links that leave the mapped folder (#2907)
Download from Server and Sync with Deployed wrote remote files to their mapped
local paths without checking for symbolic links, so a linked folder or file in
the project sent the download, or a local delete, outside the mapped folder.
Both now refuse a path that resolves outside its mapping.
2026-11-12 14:54:15 +08:00
43 changed files with 476 additions and 97 deletions

No files matched your search

+12 -4
View File
@@ -1368,12 +1368,20 @@ impl AgentManager {
let restamped = blocking(move || {
let changes = at.changes(&before)?;
let rev = at.rev()?;
let sources = match rev.as_deref() {
Some(rev) => worktree::rename_sources(at.cwd(), rev)?,
None => Default::default(),
};
let mut restamped = Vec::new();
for change in changes.iter().filter(|c| rel_paths.as_ref().is_none_or(|p| p.contains(&c.rel_path))) {
worktree::restore(at.cwd(), rev.as_deref(), change, &before, saved.as_deref())?;
// Back to the user's own edits: not a change of the task any more.
if before.contains_key(&change.rel_path) {
restamped.push((change.rel_path.clone(), worktree::stamp(&change.path.0)));
// A rename goes back to its old path too; the new one is taken away first, so a
// case-only rename on a case-insensitive disk ends as the old file.
for change in std::iter::once(change).chain(sources.get(&change.rel_path)) {
worktree::restore(at.cwd(), rev.as_deref(), change, &before, saved.as_deref())?;
// Back to the user's own edits: not a change of the task any more.
if before.contains_key(&change.rel_path) {
restamped.push((change.rel_path.clone(), worktree::stamp(&change.path.0)));
}
}
}
Ok(restamped)
+27
View File
@@ -3055,6 +3055,33 @@ async fn commits_made_during_a_local_task_are_kept_by_its_rollback() {
}
}
#[tokio::test(flavor = "multi_thread")]
async fn rolling_back_a_rename_brings_the_old_file_back() {
for eol in EOLS {
let repo = Checkout::new(eol);
repo.write("Old.java", "class Old {}\n");
repo.write("Case.java", "class Case {}\n");
repo.commit("init");
let (mgr, task) = repo.task().await;
repo.git(&["mv", "Old.java", "New.java"]);
repo.git(&["mv", "Case.java", "CASE.java"]);
// Renames list their new path only.
let listed = changed(&mgr, &task.id).await;
assert_eq!(listed, ["CASE.java", "New.java"], "{eol:?}");
mgr.rollback_changes(&task.id, Some(listed)).await.unwrap();
assert_eq!(repo.read("Old.java"), "class Old {}\n", "{eol:?}");
assert!(!repo.dir.join("New.java").exists(), "{eol:?}");
assert_eq!(repo.read("Case.java"), "class Case {}\n", "{eol:?}");
let names: Vec<String> = std::fs::read_dir(&repo.dir)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.filter(|n| n.ends_with(".java"))
.collect();
assert!(!names.contains(&"CASE.java".to_owned()), "{eol:?}: {names:?}");
}
}
#[tokio::test(flavor = "multi_thread")]
async fn a_local_task_in_a_repository_without_commits_lists_and_rolls_back_its_changes() {
for eol in EOLS {
+22
View File
@@ -124,6 +124,28 @@ pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult<Vec<AgentChange>> {
Ok(out)
}
/// Files renamed since `rev`, by their new repository-relative path: the old path, as a deletion
/// to roll back along with the rename.
pub(crate) fn rename_sources(cwd: &Path, rev: &str) -> IdeResult<BTreeMap<String, AgentChange>> {
let Some(repo) = discover(cwd)? else { return Ok(BTreeMap::new()) };
let root = repo.root().to_path_buf();
Ok(repo
.diff_with_worktree(rev)?
.into_iter()
.filter_map(|f| {
let old = f.old_path.filter(|old| *old != f.path)?;
let source = AgentChange {
path: IpcPath(root.join(&old)),
rel_path: old,
kind: ChangeKind::Deleted,
added: 0,
removed: 0,
};
Some((f.path, source))
})
.collect())
}
fn added(path: IpcPath, rel_path: String) -> AgentChange {
let added = std::fs::read_to_string(&*path).map_or(0, |t| t.lines().count() as u32);
AgentChange { path, rel_path, kind: ChangeKind::Added, added, removed: 0 }
+6 -3
View File
@@ -105,9 +105,12 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
"stdio",
"Install netcoredbg (.NET debugger) in Settings › Languages & Frameworks › Language Servers",
),
"lldb-dap" => {
cfg("lldb-dap", &[], "stdio", "Install LLVM's lldb-dap (brew install llvm; on macOS `xcrun -f lldb-dap`)")
}
"lldb-dap" => cfg(
"lldb-dap",
&[],
"stdio",
"Install LLVM's lldb-dap (brew install llvm), or on macOS use the one found by xcrun -f lldb-dap",
),
// GDB's own DAP interpreter, stdio transport. The DAP support it needs shipped in GDB 14
// (`prepare_gdb` refuses older ones with the required version in the message).
"gdb" => cfg(
+1 -1
View File
@@ -773,7 +773,7 @@ impl LspManager {
Err(LaunchError::Missing) => {
let cfg = server.effective_config();
let message =
cfg.install_hint.clone().unwrap_or_else(|| format!("`{}` was not found on PATH", cfg.command));
cfg.install_hint.clone().unwrap_or_else(|| format!("{} was not found on PATH", cfg.command));
if server.fail_start(
generation,
Conn::Missing,
+30 -2
View File
@@ -153,6 +153,24 @@ impl Deployment<'_> {
self.exclude.local(local.strip_prefix(self.map.project_root()).unwrap_or(local))
}
/// Fails when writing or deleting `local` would go through a symbolic link that
/// leaves its mapped folder.
async fn confined(&self, local: &Path) -> IdeResult<()> {
let Some(root) = self.map.local_root(local) else {
return Err(IdeError::invalid(format!("{} is not under a mapped folder", local.display())));
};
// A missing mapped folder has nothing inside it to follow.
let Ok(root) = tokio::fs::canonicalize(root).await else { return Ok(()) };
if crate::service::stays_inside(&root, local).await {
Ok(())
} else {
Err(IdeError::invalid(format!(
"{} leads outside the mapped folder through a symbolic link",
local.display()
)))
}
}
/// Local files under `paths` (folders recursively), excluded names left out.
fn local_files(&self, paths: &[PathBuf], report: &mut TransferReport) -> Vec<PathBuf> {
let mut out = Vec::new();
@@ -265,6 +283,10 @@ impl Deployment<'_> {
}
let rel = self.rel(local);
progress(&rel, i, jobs.len());
if let Err(e) = self.confined(local).await {
report.fail(rel, e);
continue;
}
match self.fs.download(remote, local).await {
Ok(()) => report.files += 1,
Err(e) => report.fail(rel, e),
@@ -365,9 +387,15 @@ impl Deployment<'_> {
progress(&step.path, i, steps.len());
let r = match step.action {
SyncAction::Upload => self.fs.upload(&local, &remote, self.preserve_time).await,
SyncAction::Download => self.fs.download(&remote, &local).await,
SyncAction::Download => match self.confined(&local).await {
Ok(()) => self.fs.download(&remote, &local).await,
Err(e) => Err(e),
},
SyncAction::DeleteRemote => self.fs.remove(&remote).await,
SyncAction::DeleteLocal => std::fs::remove_file(&local).map_err(IdeError::from),
SyncAction::DeleteLocal => match self.confined(&local).await {
Ok(()) => tokio::fs::remove_file(&local).await.map_err(IdeError::from),
Err(e) => Err(e),
},
};
match r {
Ok(()) => report.files += 1,
+5
View File
@@ -104,6 +104,11 @@ impl Mappings {
&self.root
}
/// The local folder of the mapping that holds `local`.
pub fn local_root(&self, local: &Path) -> Option<&Path> {
self.entries.iter().find_map(|(l, _, _)| local.starts_with(l).then_some(l.as_path()))
}
/// The remote path of a local file or folder (`None`: not under any mapping).
pub fn remote(&self, local: &Path) -> Option<String> {
self.entries.iter().find_map(|(l, r, _)| {
+1 -1
View File
@@ -605,7 +605,7 @@ async fn remote_tree(
/// Whether writing `path` stays in the download folder `root` (canonical): its closest
/// existing ancestor resolves inside it, and `path` itself is not a symbolic link, so a link
/// already in the folder can't send a download elsewhere.
async fn stays_inside(root: &Path, path: &Path) -> bool {
pub(crate) async fn stays_inside(root: &Path, path: &Path) -> bool {
if tokio::fs::symlink_metadata(path).await.is_ok_and(|m| m.file_type().is_symlink()) {
return false;
}
+38
View File
@@ -320,6 +320,44 @@ async fn browser_mappings_resolve_symlinks_and_download_to_the_project() {
assert_eq!(std::fs::read_to_string(local).unwrap(), "from the server");
}
#[cfg(unix)]
#[tokio::test]
async fn deployment_downloads_never_write_through_links_out_of_the_mapping() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
let target = tmp.path().join("mounted");
let outside = tmp.path().join("outside");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(target.join("app/config")).unwrap();
std::fs::create_dir_all(&outside).unwrap();
std::fs::write(target.join("app/config/keys"), "from the server").unwrap();
std::fs::write(target.join("app/note.txt"), "from the server").unwrap();
std::fs::write(outside.join("keys"), "kept").unwrap();
std::fs::write(outside.join("other"), "kept").unwrap();
std::os::unix::fs::symlink(&outside, root.join("config")).unwrap();
std::os::unix::fs::symlink(outside.join("other"), root.join("note.txt")).unwrap();
let service = service(tmp.path());
let settings = settings(&target);
let cancel = CancellationToken::new();
let report = service
.download(&settings, &root, None, std::slice::from_ref(&root), cancel.clone(), &mut |_, _, _| {})
.await
.unwrap();
assert_eq!(report.files, 0, "{report:?}");
assert_eq!(report.errors.len(), 2, "{report:?}");
let steps = [
SyncStep { path: "config/keys".into(), action: SyncAction::Download },
SyncStep { path: "note.txt".into(), action: SyncAction::Download },
SyncStep { path: "config/keys".into(), action: SyncAction::DeleteLocal },
];
let report = service.sync(&settings, &root, None, &steps, cancel, &mut |_, _, _| {}).await.unwrap();
assert_eq!(report.files, 0, "{report:?}");
assert_eq!(report.errors.len(), 3, "{report:?}");
assert_eq!(std::fs::read_to_string(outside.join("keys")).unwrap(), "kept");
assert_eq!(std::fs::read_to_string(outside.join("other")).unwrap(), "kept");
}
#[tokio::test]
async fn download_into_copies_only_the_selected_items() {
let tmp = tempfile::tempdir().unwrap();
+2 -4
View File
@@ -781,14 +781,12 @@ impl Repo {
) -> IdeResult<Option<String>> {
let encoding = encoding.into();
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
let rel = self.rel_path(path)?;
let repo = self.gix()?;
Ok(repo::blob_at(&repo, &rel, rev.unwrap_or("HEAD"))?.map(|b| text_encoding::decode(&b, encoding).into_owned()))
Ok(self.blob_of(path, rev.unwrap_or("HEAD"))?.map(|b| text_encoding::decode(&b, encoding).into_owned()))
}
/// Raw revision bytes, for encoding detection when the working file is absent.
pub fn file_bytes_at(&self, path: &Path, rev: Option<&str>) -> IdeResult<Option<Vec<u8>>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
repo::blob_at(&self.gix()?, &self.rel_path(path)?, rev.unwrap_or("HEAD"))
self.blob_of(path, rev.unwrap_or("HEAD"))
}
/// Stage selected decoded hunks, retaining the index encoding, BOM and line endings.
pub fn stage_hunks(
+22 -1
View File
@@ -128,6 +128,27 @@ impl Repo {
/// worktree or paths already relative to it.
pub(crate) fn rel_path(&self, path: &Path) -> IdeResult<String> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
Ok(self.index_spelling(self.given_rel_path(path)?))
}
/// The blob of `path` at `rev`. A revision may spell a file differently from the index (a
/// case-only rename staged or made since it): the spelling given wins when the revision has
/// it, the index's spelling is the fallback.
pub(crate) fn blob_of(&self, path: &Path, rev: &str) -> IdeResult<Option<Vec<u8>>> {
let repo = self.gix()?;
let given = self.given_rel_path(path)?;
let indexed = self.index_spelling(given.clone());
if !rev.starts_with(':')
&& indexed != given
&& let Some(bytes) = blob_at(&repo, &given, rev)?
{
return Ok(Some(bytes));
}
blob_at(&repo, &indexed, rev)
}
/// `path` relative to the worktree, spelled as given.
fn given_rel_path(&self, path: &Path) -> IdeResult<String> {
let rel = if path.is_absolute() {
self.strip_root(path)
.ok_or_else(|| IdeError::invalid(format!("{} is outside the repository", path.display())))?
@@ -147,7 +168,7 @@ impl Repo {
_ => return Err(IdeError::invalid(format!("invalid path {}", path.display()))),
}
}
Ok(self.index_spelling(parts.join("/")))
Ok(parts.join("/"))
}
/// `rel` as the index spells it. On a case-insensitive worktree (`core.ignoreCase`) the
+1 -1
View File
@@ -23,7 +23,7 @@ Themes from installed plugins appear in the same list, under the plugin's name.
The same page also has:
- **Accent colour**: the UI's accent colour.
- **Accent color**: the UI's accent color.
- **Different tool window background**: set tool windows apart from the editor.
- **Accessibility**: **Use contrast scrollbars** and **Adjust colors for red-green vision deficiency** (for protanopia and deuteranopia).
- **Compact mode** under **UI Options**.
+1 -1
View File
@@ -99,7 +99,7 @@ The selected theme is injected as one stylesheet on `:root` that outranks the bu
theme blocks, and `data-theme` is switched to the theme's `kind`. Three things still layer on
top of it:
- an **Accent colour** picked in **Settings › Appearance & Behavior › Appearance** (other
- an **Accent color** picked in **Settings › Appearance & Behavior › Appearance** (other
than the default),
- an explicit scheme or attribute overrides in **Settings › Editor › Color Scheme**,
- the appearance options that change only metrics (compact mode).
+1 -1
View File
@@ -99,7 +99,7 @@ Settings › Appearance & Behavior › Appearance, unless noted.
| `theme` | `"light"` | `light` (Islands Light), `dark` (Islands Dark), `dark-hc` (High Contrast), `solarized-light`, `solarized-dark`, `one-light`, `one-dark`, or a plugin theme as `plugin:<plugin-id>/<theme-id>`. |
| `syncWithOs` | `false` | **Sync with OS**. |
| `preferredLightTheme` / `preferredDarkTheme` | `"light"` / `"dark"` | Themes used with **Sync with OS**. |
| `accent` | `"#3871e1"` | **Accent colour**. |
| `accent` | `"#3871e1"` | **Accent color**. |
| `fileIconTheme` | `""` | A plugin's file icon theme as `<plugin-id>/<theme-id>`; empty uses the built-in icons. |
| `zoom` | `100` | **Zoom**, in percent (50 to 300). |
| `useCustomFont` | `false` | **Use custom font:** apply `uiFontFamily` and `uiFontSize`. |
+1 -1
View File
@@ -25,4 +25,4 @@ install-hint = "Install clangd (LLVM): brew install llvm, apt install clangd, or
[debugger]
adapter = "lldb-dap"
command = "lldb-dap"
install-hint = "Install LLVM's lldb-dap (brew install llvm; on macOS also `xcrun -f lldb-dap`)"
install-hint = "Install LLVM's lldb-dap (brew install llvm), or on macOS use the one found by xcrun -f lldb-dap"
+1 -1
View File
@@ -25,4 +25,4 @@ install-hint = "Install clangd (LLVM): brew install llvm, apt install clangd, or
[debugger]
adapter = "lldb-dap"
command = "lldb-dap"
install-hint = "Install LLVM's lldb-dap (brew install llvm; on macOS also `xcrun -f lldb-dap`)"
install-hint = "Install LLVM's lldb-dap (brew install llvm), or on macOS use the one found by xcrun -f lldb-dap"
+1 -1
View File
@@ -10,4 +10,4 @@ ui-mode = "D"
name = "serve-d"
command = "serve-d"
root-markers = ["dub.json", "dub.sdl", "dub.selections.json"]
install-hint = "Install serve-d (https://github.com/symmetryinvestments/serve-d) and expose it as `serve-d`"
install-hint = "Install serve-d (https://github.com/symmetryinvestments/serve-d) and put serve-d on PATH"
+2 -2
View File
@@ -11,9 +11,9 @@ tree-sitter = "elixir"
name = "elixir-ls"
command = "elixir-ls"
root-markers = ["mix.exs"]
install-hint = "Install ElixirLS (https://github.com/elixir-lsp/elixir-ls/releases) and expose language_server.sh as `elixir-ls`"
install-hint = "Install ElixirLS (https://github.com/elixir-lsp/elixir-ls/releases) and put language_server.sh on PATH as elixir-ls"
[debugger]
adapter = "elixir-ls-debugger"
command = "elixir-ls-debugger"
install-hint = "Install ElixirLS and expose debug_adapter.sh as `elixir-ls-debugger`"
install-hint = "Install ElixirLS and put debug_adapter.sh on PATH as elixir-ls-debugger"
+1 -1
View File
@@ -12,4 +12,4 @@ ui-mode = "Groovy"
name = "groovy-language-server"
command = "groovy-language-server"
root-markers = ["build.gradle", "settings.gradle", "pom.xml"]
install-hint = "Build https://github.com/GroovyLanguageServer/groovy-language-server and expose it as `groovy-language-server`"
install-hint = "Build https://github.com/GroovyLanguageServer/groovy-language-server and put it on PATH as groovy-language-server"
+1 -1
View File
@@ -16,4 +16,4 @@ install-hint = "Install clangd (LLVM): brew install llvm"
[debugger]
adapter = "lldb-dap"
command = "lldb-dap"
install-hint = "Install LLVM's lldb-dap (brew install llvm; on macOS also `xcrun -f lldb-dap`)"
install-hint = "Install LLVM's lldb-dap (brew install llvm), or on macOS use the one found by xcrun -f lldb-dap"
+1 -1
View File
@@ -16,4 +16,4 @@ install-hint = "Install clangd (LLVM): brew install llvm"
[debugger]
adapter = "lldb-dap"
command = "lldb-dap"
install-hint = "Install LLVM's lldb-dap (brew install llvm; on macOS also `xcrun -f lldb-dap`)"
install-hint = "Install LLVM's lldb-dap (brew install llvm), or on macOS use the one found by xcrun -f lldb-dap"
+1 -1
View File
@@ -16,4 +16,4 @@ install-hint = "Ships with the Swift toolchain / Xcode (xcrun sourcekit-lsp); ht
[debugger]
adapter = "lldb-dap"
command = "lldb-dap"
install-hint = "Ships with the Swift toolchain (swift 6+) or LLVM; on macOS `xcrun -f lldb-dap`"
install-hint = "Ships with the Swift toolchain (swift 6+) or LLVM; on macOS it is found by xcrun -f lldb-dap"
+1 -1
View File
@@ -11,4 +11,4 @@ tree-sitter = "xml"
[[language-servers]]
name = "lemminx"
command = "lemminx"
install-hint = "Install Eclipse LemMinX (https://github.com/redhat-developer/vscode-xml/releases, binary `lemminx`)"
install-hint = "Install Eclipse LemMinX (https://github.com/redhat-developer/vscode-xml/releases) and put the lemminx binary on PATH"
+4 -28
View File
@@ -428,33 +428,6 @@ export function Composer(props: ComposerProps) {
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (composing.current || isImeKey(e.nativeEvent)) return;
const el = e.currentTarget;
if (!e.altKey && !e.metaKey && !e.ctrlKey && el.selectionStart === el.selectionEnd) {
const backward = e.key === "Backspace" || e.key === "ArrowLeft";
const forward = e.key === "Delete" || e.key === "ArrowRight";
if (backward || forward) {
let offset = 0;
for (const token of tokens) {
const end = offset + token.value.length;
const caret = el.selectionStart;
if (token.kind !== "text" && (backward ? caret > offset && caret <= end : caret >= offset && caret < end)) {
e.preventDefault();
if (e.key === "Backspace" || e.key === "Delete") {
setText(text.slice(0, offset) + text.slice(end), offset);
} else {
const next = backward ? offset : end;
el.setSelectionRange(
e.shiftKey ? Math.min(caret, next) : next,
e.shiftKey ? Math.max(caret, next) : next,
);
}
setCompletion(null);
return;
}
offset = end;
}
}
}
if (open) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
@@ -630,7 +603,10 @@ export function Composer(props: ComposerProps) {
onChange={(e) => setText(e.target.value, e.target.selectionStart, false)}
// The controlled text, not the event's value: closing the completion popup can emit a
// stale select event with the old text.
onSelect={(e) => setCompletion(composing.current ? null : completionAt(text, e.currentTarget.selectionStart))}
onSelect={(e) => {
const caret = e.currentTarget.selectionStart;
setCompletion(composing.current ? null : completionAt(text, caret));
}}
onCompositionStart={() => {
composing.current = true;
setCompletion(null);
+55 -7
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FILES_DRAG, dragSession } from "@/ui";
@@ -150,29 +150,77 @@ describe("Composer @ folders", () => {
});
describe("Composer tag editing", () => {
it.each(["@src/a.ts", "/review"])("deletes %s as a unit", async (tag) => {
it.each(["@src/ag", "/rev"])("deletes %s character by character while it is typed", async (tag) => {
const box = renderComposer();
const user = userEvent.setup();
await user.type(box, `see ${tag}`);
await user.keyboard("{Backspace}");
expect(box.value).toBe(`see ${tag.slice(0, -1)}`);
await user.keyboard("{Backspace}");
expect(box.value).toBe(`see ${tag.slice(0, -2)}`);
});
it("deletes inside a folder entered with Tab character by character", async () => {
const box = renderComposer();
const user = userEvent.setup();
await user.type(box, "@agen");
await screen.findAllByRole("option");
await user.keyboard("{Tab}");
expect(box.value).toBe("@src/agents/");
await user.keyboard("{Backspace}");
expect(box.value).toBe("@src/agents");
});
it("edits a completed reference character by character after its space", async () => {
const box = renderComposer();
const user = userEvent.setup();
await user.type(box, "see @agen");
await screen.findAllByRole("option");
await user.keyboard("{Enter}");
expect(box.value).toBe("see @src/agents/ ");
await user.keyboard("{Backspace}{Backspace}");
expect(box.value).toBe("see @src/agents");
});
it.each(["@src/a.ts", "/review"])("edits %s after moving away and returning", async (tag) => {
const box = renderComposer();
const user = userEvent.setup();
fireEvent.change(box, { target: { value: `see ${tag} next` } });
box.focus();
box.setSelectionRange(0, 0);
fireEvent.select(box);
box.blur();
box.focus();
box.setSelectionRange(4 + tag.length, 4 + tag.length);
fireEvent.select(box);
await user.keyboard("{Backspace}");
expect(box.value).toBe("see next");
await waitFor(() => expect(box.selectionStart).toBe(4));
expect(box.value).toBe(`see ${tag.slice(0, -1)} next`);
expect(box.selectionStart).toBe(3 + tag.length);
});
it.each(["@src/a.ts", "/review"])("moves across %s with the arrow keys", async (tag) => {
it.each(["@src/a.ts", "/review"])("moves within %s with the arrow keys", async (tag) => {
const box = renderComposer();
const user = userEvent.setup();
fireEvent.change(box, { target: { value: `see ${tag} next` } });
box.focus();
box.setSelectionRange(4, 4);
await user.keyboard("{ArrowRight}");
expect(box.selectionStart).toBe(4 + tag.length);
expect(box.selectionStart).toBe(5);
await user.keyboard("{ArrowLeft}");
expect(box.selectionStart).toBe(4);
await user.keyboard("{Delete}");
expect(box.value).toBe("see next");
expect(box.value).toBe(`see ${tag.slice(1)} next`);
});
it.each(["@src/a.ts", "/review"])("selects and deletes only part of %s", async (tag) => {
const box = renderComposer();
const user = userEvent.setup();
fireEvent.change(box, { target: { value: `see ${tag} next` } });
box.focus();
box.setSelectionRange(5, 7);
fireEvent.select(box);
await user.keyboard("{Backspace}");
expect(box.value).toBe(`see ${tag.slice(0, 1)}${tag.slice(3)} next`);
});
it("leaves IME keys and composition text to the input method", () => {
@@ -121,6 +121,43 @@ describe("SeController", () => {
).toBe(true);
});
it("lists scratch files with the indexed files, ranked by name", async () => {
const outsideFiles = vi.fn(async () => [
{ path: "/data/scratches/scratch_3.md", relPath: "Scratches/scratch_3.md" },
{ path: "/data/scratches/notes.txt", relPath: "Scratches/notes.txt" },
]);
const { c, last } = setup({
findFiles: async () => [{ ...fm("tests/test_scratch_dir.py"), score: (1 << 20) + 50 }],
outsideFiles,
inFileSet: (set, path) => path.startsWith("/p/") && (set === "all" || set === "test"),
});
c.run({ query: "scratch_3", tab: "files", includeNonProject: false, extra: {} });
await flush();
expect(last().sections.get("files")!.items[0]).toMatchObject({
key: "f:/data/scratches/scratch_3.md",
name: "scratch_3.md",
dir: "Scratches",
nameIndices: [0, 1, 2, 3, 4, 5, 6, 7, 8],
});
c.run({ query: "scratch", tab: "files", includeNonProject: false, extra: {} });
await flush();
expect(
last()
.sections.get("files")!
.items.map((i) => i.key),
).toEqual(["f:/data/scratches/scratch_3.md", "f:/p/tests/test_scratch_dir.py"]);
// Production / test file sets are project scopes.
c.run({ query: "scratch", tab: "files", includeNonProject: false, fileSet: "test", extra: {} });
await flush();
expect(
last()
.sections.get("files")!
.items.map((i) => i.key),
).not.toContain("f:/data/scratches/scratch_3.md");
});
it("parses File.kt:42 and opens files at the line", async () => {
const findFiles = vi.fn(async () => [fm("File.kt")]);
const { c, last } = setup({ findFiles });
+13 -6
View File
@@ -28,11 +28,13 @@ import {
fileItem,
folderItem,
matchActions,
matchOutsideFiles,
matchSettings,
mergeActionResults,
mergeSymbols,
sectionsFor,
textItems,
type OutsideFile,
type SeItem,
type SeTab,
type SectionId,
@@ -43,6 +45,8 @@ export interface SeDeps {
findFiles(query: string, limit: number): Promise<FileMatch[]>;
/** Directories by name or path (`relPath` ending in `/`), listed with the files. */
findDirs?(query: string, limit: number): Promise<FileMatch[]>;
/** Files outside the project index listed with its matches (Scratches). */
outsideFiles?(): Promise<OutsideFile[]>;
findSymbols(query: string, filter: SymbolFilter, limit: number): Promise<SymbolMatch[]>;
/** Optional language-server symbols; rejects with `unavailable` when no server. */
workspaceSymbols?(request: RequestId, query: string): Promise<WorkspaceSymbol[]>;
@@ -260,15 +264,18 @@ export class SeController {
// Directories rank with the files (equal scores: the file first); a file type filter
// and a `File.kt:42` query ask for files only.
const withDirs = !!this.deps.findDirs && !!text && !filter && q.line === null;
const [fetched, dirs] = await Promise.all([
const [indexed, dirs, outside] = await Promise.all([
this.deps.findFiles(text, want),
withDirs ? this.deps.findDirs!(text, want).catch(() => []) : [],
text && this.deps.outsideFiles ? this.deps.outsideFiles().catch(() => []) : [],
]);
const merged = dirs.length
? [...fetched.map((m) => ({ m, folder: false })), ...dirs.map((m) => ({ m, folder: true }))].sort(
(a, b) => b.m.score - a.m.score || Number(a.folder) - Number(b.folder),
)
: fetched.map((m) => ({ m, folder: false }));
const fetched = outside.length ? [...indexed, ...matchOutsideFiles(outside, text)] : indexed;
const merged =
dirs.length || outside.length
? [...fetched.map((m) => ({ m, folder: false })), ...dirs.map((m) => ({ m, folder: true }))].sort(
(a, b) => b.m.score - a.m.score || Number(a.folder) - Number(b.folder),
)
: fetched.map((m) => ({ m, folder: false }));
const matches = keep ? merged.filter(({ m }) => keep(m.path)) : merged;
this.set(
id,
+26 -1
View File
@@ -2,17 +2,21 @@ import { allActions, type Action } from "@/platform/actions";
import { evaluateWhen, type ContextKeys } from "@/platform/context";
import { call, commands, nextRequestId, type LanguageInfo } from "@/platform/ipc";
import { inFileSet } from "@/platform/projectRoots";
import { searchEndpoints, searchableSettings } from "@/workbench/contributions";
import { searchEndpoints, searchableSettings, useProjectViewRoots } from "@/workbench/contributions";
import { projectRoot } from "../common/open";
import { cancelRequest, streamSearch } from "@/platform/textSearch";
import type { SeDeps } from "./controller";
import type { OutsideFile } from "./model";
import { recentActions } from "./store";
/** Backend-backed sources; action enablement is judged against `ctx` (captured on open). */
export function ipcDeps(ctx: ContextKeys): SeDeps {
let outside: Promise<OutsideFile[]> | null = null;
return {
findFiles: (q, limit) => call(commands.findFiles(q, limit)),
findDirs: (q, limit) => call(commands.findDirs(q, limit)),
// Listed once per popup: scratch files are few and change only through the IDE.
outsideFiles: () => (outside ??= listScratchFiles()),
findSymbols: (q, filter, limit) => call(commands.findSymbols(q, filter, limit)),
workspaceSymbols: (request, q) => call(commands.lspWorkspaceSymbols(request, q)),
searchText: async (request, query, onFile) => {
@@ -47,3 +51,24 @@ export function loadLanguages(): Promise<LanguageInfo[]> {
return [];
}));
}
const MAX_OUTSIDE_FILES = 2000;
const MAX_OUTSIDE_DEPTH = 4;
/** Files under the project view's Scratches roots, as `<root label>/<relative path>`. */
async function listScratchFiles(): Promise<OutsideFile[]> {
const out: OutsideFile[] = [];
const walk = async (dir: string, rel: string, depth: number) => {
const entries = await call(commands.fsReadDir(dir)).catch(() => []);
for (const e of entries) {
if (out.length >= MAX_OUTSIDE_FILES) return;
const path = e.path;
if (e.kind === "dir") {
if (depth < MAX_OUTSIDE_DEPTH) await walk(path, `${rel}/${e.name}`, depth + 1);
} else out.push({ path, relPath: `${rel}/${e.name}` });
}
};
for (const root of useProjectViewRoots.getState().roots.values())
if (root.scope === "scratches" && root.path) await walk(root.path, root.label, 0);
return out;
}
+46
View File
@@ -8,6 +8,7 @@ import type { SettingsSearchEntry } from "@/workbench/contributions";
import { tx } from "@/platform/i18n";
import { basename, isTypeKind, relDir, symbolDisplayName } from "../common/open";
import { byScore, matchText } from "../common/rank";
import { wordMatch } from "@/ui";
export type SeTab = "all" | "classes" | "files" | "symbols" | "actions" | "text";
export type SectionId = "recent" | "recentActions" | "classes" | "files" | "symbols" | "endpoints" | "actions" | "text";
@@ -171,6 +172,51 @@ export function fileItem(
};
}
/** File outside the project index (a scratch file); `relPath` is shown under its root label. */
export interface OutsideFile {
path: string;
relPath: string;
}
// The project index's file scores (`ide-index` matching.rs): a name match ranks in a tier
// above path-only matches, with exact / exact-stem / prefix boosts.
const NAME_TIER = 1 << 20;
function nameBoost(name: string, q: string): number {
const n = name.toLowerCase();
if (n === q) return 4000;
const dot = n.indexOf(".");
if (dot >= 0 && n.slice(0, dot) === q) return 3000;
return n.startsWith(q) ? 600 : 0;
}
/** Files outside the index matching a Files query by name (or by path for `dir/name`), scored like the index's. */
export function matchOutsideFiles(files: readonly OutsideFile[], query: string): FileMatch[] {
const q = query
.trim()
.replace(/\\/g, "/")
.replace(/^\.?\/+/, "")
.replace(/\s+/g, "")
.toLowerCase();
if (!q) return [];
const byPath = q.includes("/");
const out: FileMatch[] = [];
for (const f of files) {
const name = basename(f.relPath);
const nameStart = f.relPath.length - name.length;
const m = byPath ? wordMatch(f.relPath, q) : wordMatch(name, q);
if (!m) continue;
const boost = nameBoost(name, byPath ? q.slice(q.lastIndexOf("/") + 1) : q);
out.push({
path: f.path,
relPath: f.relPath,
score: (byPath ? 0 : NAME_TIER) + boost,
indices: byPath ? m : m.map((i) => i + nameStart),
});
}
return out;
}
/** A directory match (`relPath` ends in `/`), listed with the files as IDEA's Go to File does. */
export function folderItem(m: Pick<FileMatch, "path" | "relPath" | "indices">): FileItem {
const rel = m.relPath.replace(/\/$/, "");
@@ -79,14 +79,7 @@ describe("page tree", () => {
);
const appearance = BEHAVIOR_PAGES.find((p) => p.id === "appearance")!;
expect(specLabels(appearance)).toEqual(
expect.arrayContaining([
"Accent colour",
"High Contrast",
"UI font size",
"Zoom",
"Compact mode",
"Antialiasing",
]),
expect.arrayContaining(["Accent color", "High Contrast", "UI font size", "Zoom", "Compact mode", "Antialiasing"]),
);
});
});
+3 -3
View File
@@ -196,8 +196,8 @@ function AccentRow() {
const [accent, setAccent] = useSetting<string>("appearance.accent");
return (
<Indented>
<Row label="Accent colour">
<ColorSwatches value={accent} presets={ACCENT_PRESETS} onChange={setAccent} aria-label="Accent colour" />
<Row label="Accent color">
<ColorSwatches value={accent} presets={ACCENT_PRESETS} onChange={setAccent} aria-label="Accent color" />
</Row>
</Indented>
);
@@ -745,7 +745,7 @@ export const BEHAVIOR_PAGES: PageSpec[] = [
keywords: ["icons", "file icons", "icon theme"],
component: FileIconThemeRow,
},
{ kind: "custom", label: "Accent colour", keywords: ["accent color", "highlight"], component: AccentRow },
{ kind: "custom", label: "Accent color", keywords: ["accent colour", "highlight"], component: AccentRow },
{ kind: "custom", label: "Different tool window background", component: ToolWindowBackgroundRow },
],
},
@@ -241,7 +241,9 @@ const SAMPLES: Record<string, CodeSample> = {
["black ", "ansiBlack"],
["red ", "ansiRed"],
["green ", "ansiGreen"],
["yellow ", "ansiYellow"],
["yellow", "ansiYellow"],
],
[
["blue ", "ansiBlue"],
["magenta ", "ansiMagenta"],
["cyan ", "ansiCyan"],
@@ -251,7 +253,9 @@ const SAMPLES: Record<string, CodeSample> = {
["dark ", "ansiBrightBlack"],
["bright-red ", "ansiBrightRed"],
["bright-green ", "ansiBrightGreen"],
["bright-yellow ", "ansiBrightYellow"],
["bright-yellow", "ansiBrightYellow"],
],
[
["bright-blue ", "ansiBrightBlue"],
["bright-magenta ", "ansiBrightMagenta"],
["bright-cyan ", "ansiBrightCyan"],
+2
View File
@@ -160,6 +160,8 @@
display: flex;
align-items: center;
gap: 8px;
/* A narrow panel moves Commit and Push… to its own row rather than cutting its label. */
flex-wrap: wrap;
padding: 8px 6px 10px 10px;
white-space: nowrap;
}
+1 -1
View File
@@ -1167,7 +1167,7 @@ function CommitPanel({ included }: { included: number }) {
className={styles.pushButton}
disabled={!canCommit || !!remoteBusy}
onClick={() => void runner.run(true)}
title="⌥⌘↩"
title={`${tx(commitAndPushLabel(amend))} (⌥⌘↩)`}
>
<span>
<T>{commitAndPushLabel(amend)}</T>
-1
View File
@@ -169,7 +169,6 @@
"Absolute Path": "Ruta absoluta",
"Absolute path of the current file": "Ruta absoluta del archivo actual",
"Accent color": "Color de acento",
"Accent colour": "Color de acento",
"Accept Both (Conflict at Caret)": "Aceptar ambos (conflicto en el cursor)",
"Accept Theirs": "Aceptar los suyos",
"Accept Theirs (Conflict at Caret)": "Aceptar los suyos (conflicto en el cursor)",
-1
View File
@@ -123,7 +123,6 @@
"Absolute Path": "Chemin absolu",
"Absolute path of the current file": "Chemin absolu du fichier actuel",
"Accent color": "Couleur d'accentuation",
"Accent colour": "Couleur d'accentuation",
"Accept Both (Conflict at Caret)": "Accepter les deux (conflit au curseur)",
"Accept Theirs": "Accepter leur version",
"Accept Theirs (Conflict at Caret)": "Accepter leur version (conflit au curseur)",
-1
View File
@@ -204,7 +204,6 @@
"Absolute Path": "절대 경로",
"Absolute path of the current file": "현재 파일의 절대 경로",
"Accent color": "강조 색상",
"Accent colour": "강조 색상",
"Accept Both (Conflict at Caret)": "둘 다 수락(캐럿 위치의 충돌)",
"Accept Theirs": "다른 이의 변경 사항 수락",
"Accept Theirs (Conflict at Caret)": "다른 이의 변경 사항 수락(캐럿 위치의 충돌)",
-1
View File
@@ -161,7 +161,6 @@
"Absolute Path": "Ścieżka bezwzględna",
"Absolute path of the current file": "Ścieżka bezwzględna bieżącego pliku",
"Accent color": "Kolor akcentu",
"Accent colour": "Kolor akcentu",
"Accept Both (Conflict at Caret)": "Zaakceptuj oba (konflikt przy kursorze)",
"Accept Theirs": "Zaakceptuj ich (ze zmian)",
"Accept Theirs (Conflict at Caret)": "Zaakceptuj ich (konflikt przy kursorze)",
-1
View File
@@ -161,7 +161,6 @@
"Absolute Path": "Caminho absoluto",
"Absolute path of the current file": "Caminho absoluto do arquivo atual",
"Accent color": "Cor de destaque",
"Accent colour": "Cor de destaque",
"Accept Both (Conflict at Caret)": "Aceitar ambos (conflito no cursor)",
"Accept Theirs": "Aceitar deles",
"Accept Theirs (Conflict at Caret)": "Aceitar deles (conflito no cursor)",
-1
View File
@@ -534,7 +534,6 @@
"Absolute Path": "绝对路径",
"Absolute path of the current file": "当前文件的绝对路径",
"Accent color": "强调色",
"Accent colour": "强调色",
"Accept Both (Conflict at Caret)": "全部接受(光标处的冲突)",
"Accept Theirs": "接受他们的",
"Accept Theirs (Conflict at Caret)": "接受他们的(光标处的冲突)",
+43 -2
View File
@@ -455,7 +455,15 @@ export function Menu({ open, onClose, anchor, items, placement = "bottom-start",
// Context menus
interface ContextMenuState {
menu: { x: number; y: number; items: MenuItem[]; numbers: boolean; id: number } | null;
menu: {
x: number;
y: number;
items: MenuItem[];
numbers: boolean;
id: number;
/** The text selection the menu was opened on (see `restoreSelection`). */
selection: Range[] | null;
} | null;
}
export const useContextMenuStore = create<ContextMenuState>(() => ({ menu: null }));
@@ -468,7 +476,32 @@ export function showContextMenu(
) {
const x = "clientX" in at ? at.clientX : at.x;
const y = "clientY" in at ? at.clientY : at.y;
useContextMenuStore.setState({ menu: { x, y, items, numbers: !!options?.numbers, id: ++menuSeq } });
useContextMenuStore.setState({
menu: { x, y, items, numbers: !!options?.numbers, id: ++menuSeq, selection: textSelection() },
});
}
/** The page's text selection, unless it is in an editable element (editors keep their own). */
function textSelection(): Range[] | null {
const sel = document.getSelection();
if (!sel || sel.isCollapsed || !sel.rangeCount) return null;
const node = sel.anchorNode;
const el = node instanceof Element ? node : node?.parentElement;
if (!el || el.closest("input, textarea, [contenteditable]:not([contenteditable='false'])")) return null;
return Array.from({ length: sel.rangeCount }, (_, i) => sel.getRangeAt(i).cloneRange());
}
/**
* WebKit drops a page's text selection when focus moves to another element, as when the menu
* focuses its first row and when it gives focus back: put the selection the menu was opened on
* back, so it stays highlighted while its Copy / Quote apply to it.
*/
function restoreSelection(ranges: readonly Range[]) {
const sel = document.getSelection();
if (!sel || !sel.isCollapsed) return;
if (!ranges.every((r) => r.startContainer.isConnected && r.endContainer.isConnected)) return;
sel.removeAllRanges();
for (const r of ranges) sel.addRange(r);
}
export function hideContextMenu() {
@@ -478,6 +511,14 @@ export function hideContextMenu() {
/** Renders the active context menu; mount once (the workbench does). */
export function ContextMenuHost() {
const menu = useContextMenuStore((s) => s.menu);
const selection = menu?.selection ?? null;
// After the rows' layout effects: the first row has taken focus.
useLayoutEffect(() => {
if (!selection) return;
restoreSelection(selection);
// After the popup has given focus back; a click elsewhere selects afresh after this.
return () => queueMicrotask(() => restoreSelection(selection));
}, [selection]);
if (!menu) return null;
return (
<Popup
+25
View File
@@ -193,6 +193,31 @@ describe("Menu action enablement", () => {
expect(input).toHaveFocus();
});
it("keeps the text selection a context menu was opened on", async () => {
render(
<>
<p>Selected text</p>
<ContextMenuHost />
</>,
);
// WebKit drops the page's selection whenever focus moves.
const drop = () => document.getSelection()!.removeAllRanges();
document.addEventListener("focusin", drop);
try {
const text = screen.getByText("Selected text").firstChild!;
document.getSelection()!.setBaseAndExtent(text, 0, text, 8);
act(() => showContextMenu({ x: 0, y: 0 }, [{ label: "Copy", run: () => undefined }]));
expect(screen.getByRole("menuitem", { name: /Copy/ })).toHaveFocus();
expect(document.getSelection()!.toString()).toBe("Selected");
act(() => hideContextMenu());
await act(async () => {});
expect(document.getSelection()!.toString()).toBe("Selected");
} finally {
document.removeEventListener("focusin", drop);
document.getSelection()!.removeAllRanges();
}
});
it("highlights the row under the pointer without moving focus", () => {
render(
<MenuList
+35 -4
View File
@@ -7,12 +7,26 @@ export function useSelectionBoundary(ref: RefObject<HTMLElement | null>): void {
const root = ref.current;
if (!root) return;
const doc = root.ownerDocument;
// WebKit's repaint of a shrinking selection misses the gap fills between blocks by a device
// pixel at fractional positions, leaving a stripe; repaint the pane once per frame instead.
let frame = 0;
const repaint = () => {
if (frame) return;
frame = requestAnimationFrame(() => {
root.style.opacity = "0.9999";
frame = requestAnimationFrame(() => {
frame = 0;
root.style.removeProperty("opacity");
});
});
};
const clamp = () => {
const selection = doc.getSelection();
if (!selection?.anchorNode || !selection.focusNode || selection.isCollapsed) return;
if (!selection?.anchorNode || !selection.focusNode) return;
const anchorIn = root.contains(selection.anchorNode);
const focusIn = root.contains(selection.focusNode);
if (anchorIn === focusIn) return;
if (anchorIn || focusIn) repaint();
if (selection.isCollapsed || anchorIn === focusIn) return;
const bounds = doc.createRange();
bounds.selectNodeContents(root);
@@ -26,10 +40,24 @@ export function useSelectionBoundary(ref: RefObject<HTMLElement | null>): void {
// While a drag starts in the pane, make everything outside it unselectable so the
// browser never extends the selection there (see global.css).
const html = doc.documentElement;
// A double or triple click selects a word or line at once and can reach past the pane's
// last text into the next field (the composer). `selectionchange` comes after a paint, which
// flashes that selection; a frame callback clamps it before.
let clampFrame = 0;
const clampBeforePaint = () => {
cancelAnimationFrame(clampFrame);
clampFrame = requestAnimationFrame(clamp);
};
const lock = (e: MouseEvent) => {
if (e.button === 0) html.setAttribute("data-selection-lock", "");
if (e.button !== 0) return;
html.setAttribute("data-selection-lock", "");
clampBeforePaint();
};
const unlock = () => {
if (!html.hasAttribute("data-selection-lock")) return;
html.removeAttribute("data-selection-lock");
clampBeforePaint();
};
const unlock = () => html.removeAttribute("data-selection-lock");
// Select All in the pane selects its text; the browser's would select across the whole
// window (Ctrl+A reaches the webview directly; ⌘A goes to the native Select All).
const selectAll = (e: KeyboardEvent) => {
@@ -51,6 +79,9 @@ export function useSelectionBoundary(ref: RefObject<HTMLElement | null>): void {
doc.removeEventListener("mouseup", unlock, true);
doc.removeEventListener("dragend", unlock, true);
doc.removeEventListener("selectionchange", clamp);
cancelAnimationFrame(frame);
cancelAnimationFrame(clampFrame);
root.style.removeProperty("opacity");
unlock();
};
}, [ref]);