Compare commits

..
Author SHA1 Message Date
arookieofc 83c2d80791 fix(debugger): use the same console font as the Run window
DebugConsole rendered with var(--font-mono) and var(--fs-ui), ignoring
the terminal/editor font settings that the Run console resolves via
resolveMonoFont. Now it applies the same font family, size and row height.

Closes #2844
2026-11-12 14:13:13 +08:00
44 changed files with 107 additions and 477 deletions

No files matched your search

+4 -12
View File
@@ -1368,20 +1368,12 @@ 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))) {
// 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)));
}
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,33 +3055,6 @@ 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,28 +124,6 @@ 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 }
+3 -6
View File
@@ -105,12 +105,9 @@ 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), or on macOS use the one found by xcrun -f lldb-dap",
),
"lldb-dap" => {
cfg("lldb-dap", &[], "stdio", "Install LLVM's lldb-dap (brew install llvm; on macOS `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,
+2 -30
View File
@@ -153,24 +153,6 @@ 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();
@@ -283,10 +265,6 @@ 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),
@@ -387,15 +365,9 @@ 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 => match self.confined(&local).await {
Ok(()) => self.fs.download(&remote, &local).await,
Err(e) => Err(e),
},
SyncAction::Download => self.fs.download(&remote, &local).await,
SyncAction::DeleteRemote => self.fs.remove(&remote).await,
SyncAction::DeleteLocal => match self.confined(&local).await {
Ok(()) => tokio::fs::remove_file(&local).await.map_err(IdeError::from),
Err(e) => Err(e),
},
SyncAction::DeleteLocal => std::fs::remove_file(&local).map_err(IdeError::from),
};
match r {
Ok(()) => report.files += 1,
-5
View File
@@ -104,11 +104,6 @@ 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.
pub(crate) async fn stays_inside(root: &Path, path: &Path) -> bool {
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,44 +320,6 @@ 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();
+4 -2
View File
@@ -781,12 +781,14 @@ impl Repo {
) -> IdeResult<Option<String>> {
let encoding = encoding.into();
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
Ok(self.blob_of(path, rev.unwrap_or("HEAD"))?.map(|b| text_encoding::decode(&b, encoding).into_owned()))
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()))
}
/// 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));
self.blob_of(path, rev.unwrap_or("HEAD"))
repo::blob_at(&self.gix()?, &self.rel_path(path)?, rev.unwrap_or("HEAD"))
}
/// Stage selected decoded hunks, retaining the index encoding, BOM and line endings.
pub fn stage_hunks(
+1 -22
View File
@@ -128,27 +128,6 @@ 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())))?
@@ -168,7 +147,7 @@ impl Repo {
_ => return Err(IdeError::invalid(format!("invalid path {}", path.display()))),
}
}
Ok(parts.join("/"))
Ok(self.index_spelling(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 color**: the UI's accent color.
- **Accent colour**: the UI's accent colour.
- **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 color** picked in **Settings › Appearance & Behavior › Appearance** (other
- an **Accent colour** 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 color**. |
| `accent` | `"#3871e1"` | **Accent colour**. |
| `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), or on macOS use the one found by xcrun -f lldb-dap"
install-hint = "Install LLVM's lldb-dap (brew install llvm; on macOS also `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), or on macOS use the one found by xcrun -f lldb-dap"
install-hint = "Install LLVM's lldb-dap (brew install llvm; on macOS also `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 put serve-d on PATH"
install-hint = "Install serve-d (https://github.com/symmetryinvestments/serve-d) and expose it as `serve-d`"
+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 put language_server.sh on PATH as elixir-ls"
install-hint = "Install ElixirLS (https://github.com/elixir-lsp/elixir-ls/releases) and expose language_server.sh as `elixir-ls`"
[debugger]
adapter = "elixir-ls-debugger"
command = "elixir-ls-debugger"
install-hint = "Install ElixirLS and put debug_adapter.sh on PATH as elixir-ls-debugger"
install-hint = "Install ElixirLS and expose debug_adapter.sh 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 put it on PATH as groovy-language-server"
install-hint = "Build https://github.com/GroovyLanguageServer/groovy-language-server and expose it 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), or on macOS use the one found by xcrun -f lldb-dap"
install-hint = "Install LLVM's lldb-dap (brew install llvm; on macOS also `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), or on macOS use the one found by xcrun -f lldb-dap"
install-hint = "Install LLVM's lldb-dap (brew install llvm; on macOS also `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 it is found by xcrun -f lldb-dap"
install-hint = "Ships with the Swift toolchain (swift 6+) or LLVM; on macOS `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) and put the lemminx binary on PATH"
install-hint = "Install Eclipse LemMinX (https://github.com/redhat-developer/vscode-xml/releases, binary `lemminx`)"
+28 -4
View File
@@ -428,6 +428,33 @@ 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();
@@ -603,10 +630,7 @@ 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) => {
const caret = e.currentTarget.selectionStart;
setCompletion(composing.current ? null : completionAt(text, caret));
}}
onSelect={(e) => setCompletion(composing.current ? null : completionAt(text, e.currentTarget.selectionStart))}
onCompositionStart={() => {
composing.current = true;
setCompletion(null);
+7 -55
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FILES_DRAG, dragSession } from "@/ui";
@@ -150,77 +150,29 @@ describe("Composer @ folders", () => {
});
describe("Composer tag editing", () => {
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) => {
it.each(["@src/a.ts", "/review"])("deletes %s as a unit", 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 ${tag.slice(0, -1)} next`);
expect(box.selectionStart).toBe(3 + tag.length);
expect(box.value).toBe("see next");
await waitFor(() => expect(box.selectionStart).toBe(4));
});
it.each(["@src/a.ts", "/review"])("moves within %s with the arrow keys", async (tag) => {
it.each(["@src/a.ts", "/review"])("moves across %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(5);
expect(box.selectionStart).toBe(4 + tag.length);
await user.keyboard("{ArrowLeft}");
expect(box.selectionStart).toBe(4);
await user.keyboard("{Delete}");
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`);
expect(box.value).toBe("see next");
});
it("leaves IME keys and composition text to the input method", () => {
+10 -1
View File
@@ -15,6 +15,8 @@ import { EmptyState, IconButton, showContextMenu, T, type MenuItem } from "@/ui"
import { formatBinding, hasPrimaryModifier, hostBinding, registerKeyClaim } from "@/platform/keymap";
import { tx } from "@/platform/i18n";
import { useWorkflowSettings } from "@/platform/workflowSettings";
import { useProject } from "@/platform/project";
import { resolveMonoFont } from "@/platform/fonts";
import { spanStyle } from "@/ui/ansi";
import { ConsoleFindBar } from "@/ui/ConsoleFindBar";
import { DEFAULT_FIND_OPTIONS, findInLines, type FindOptions, type FindResult } from "@/ui/consoleFind";
@@ -117,6 +119,12 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
const [follow, setFollow] = useState(true);
const stick = useRef(true);
stick.current = follow;
// The debug console uses the same font as the Run console: Tools › Terminal ›
// Font (which can follow Editor › Font).
const terminalFont = useWorkflowSettings((s) => s.terminal);
const editorFont = useProject((s) => s.settings?.editor);
const consoleFont = resolveMonoFont(terminalFont, editorFont);
const rowHeight = Math.max(16, Math.round(consoleFont.size * 1.5));
// Settings › Console › Use soft wraps is the default; the toggle overrides it for this console.
const defaultWrap = useWorkflowSettings((s) => s.console.softWrap);
const [wrapOverride, setWrapOverride] = useState<boolean | null>(null);
@@ -140,7 +148,7 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
const virtualizer = useVirtualizer({
count: lines?.length ?? 0,
getScrollElement: () => scroller.current,
estimateSize: () => 18,
estimateSize: () => rowHeight,
overscan: 30,
getItemKey: (i) => lines?.[i]?.id ?? i,
});
@@ -429,6 +437,7 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
<div
ref={scroller}
className={clsx(styles.consoleOutput, !softWrap && styles.noWrap)}
style={{ fontFamily: consoleFont.family, fontSize: `${consoleFont.size}px`, lineHeight: `${rowHeight}px` }}
onScroll={(e) => {
if (programmatic.current) return;
const el = e.currentTarget;
@@ -121,43 +121,6 @@ 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 });
+6 -13
View File
@@ -28,13 +28,11 @@ import {
fileItem,
folderItem,
matchActions,
matchOutsideFiles,
matchSettings,
mergeActionResults,
mergeSymbols,
sectionsFor,
textItems,
type OutsideFile,
type SeItem,
type SeTab,
type SectionId,
@@ -45,8 +43,6 @@ 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[]>;
@@ -264,18 +260,15 @@ 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 [indexed, dirs, outside] = await Promise.all([
const [fetched, dirs] = await Promise.all([
this.deps.findFiles(text, want),
withDirs ? this.deps.findDirs!(text, want).catch(() => []) : [],
text && this.deps.outsideFiles ? this.deps.outsideFiles().catch(() => []) : [],
]);
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 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 matches = keep ? merged.filter(({ m }) => keep(m.path)) : merged;
this.set(
id,
+1 -26
View File
@@ -2,21 +2,17 @@ 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, useProjectViewRoots } from "@/workbench/contributions";
import { searchEndpoints, searchableSettings } 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) => {
@@ -51,24 +47,3 @@ 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,7 +8,6 @@ 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";
@@ -172,51 +171,6 @@ 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,7 +79,14 @@ describe("page tree", () => {
);
const appearance = BEHAVIOR_PAGES.find((p) => p.id === "appearance")!;
expect(specLabels(appearance)).toEqual(
expect.arrayContaining(["Accent color", "High Contrast", "UI font size", "Zoom", "Compact mode", "Antialiasing"]),
expect.arrayContaining([
"Accent colour",
"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 color">
<ColorSwatches value={accent} presets={ACCENT_PRESETS} onChange={setAccent} aria-label="Accent color" />
<Row label="Accent colour">
<ColorSwatches value={accent} presets={ACCENT_PRESETS} onChange={setAccent} aria-label="Accent colour" />
</Row>
</Indented>
);
@@ -745,7 +745,7 @@ export const BEHAVIOR_PAGES: PageSpec[] = [
keywords: ["icons", "file icons", "icon theme"],
component: FileIconThemeRow,
},
{ kind: "custom", label: "Accent color", keywords: ["accent colour", "highlight"], component: AccentRow },
{ kind: "custom", label: "Accent colour", keywords: ["accent color", "highlight"], component: AccentRow },
{ kind: "custom", label: "Different tool window background", component: ToolWindowBackgroundRow },
],
},
@@ -241,9 +241,7 @@ const SAMPLES: Record<string, CodeSample> = {
["black ", "ansiBlack"],
["red ", "ansiRed"],
["green ", "ansiGreen"],
["yellow", "ansiYellow"],
],
[
["yellow ", "ansiYellow"],
["blue ", "ansiBlue"],
["magenta ", "ansiMagenta"],
["cyan ", "ansiCyan"],
@@ -253,9 +251,7 @@ 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,8 +160,6 @@
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={`${tx(commitAndPushLabel(amend))} (⌥⌘↩)`}
title="⌥⌘↩"
>
<span>
<T>{commitAndPushLabel(amend)}</T>
+1
View File
@@ -169,6 +169,7 @@
"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,6 +123,7 @@
"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,6 +204,7 @@
"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,6 +161,7 @@
"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,6 +161,7 @@
"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,6 +534,7 @@
"Absolute Path": "绝对路径",
"Absolute path of the current file": "当前文件的绝对路径",
"Accent color": "强调色",
"Accent colour": "强调色",
"Accept Both (Conflict at Caret)": "全部接受(光标处的冲突)",
"Accept Theirs": "接受他们的",
"Accept Theirs (Conflict at Caret)": "接受他们的(光标处的冲突)",
+2 -43
View File
@@ -455,15 +455,7 @@ 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;
/** The text selection the menu was opened on (see `restoreSelection`). */
selection: Range[] | null;
} | null;
menu: { x: number; y: number; items: MenuItem[]; numbers: boolean; id: number } | null;
}
export const useContextMenuStore = create<ContextMenuState>(() => ({ menu: null }));
@@ -476,32 +468,7 @@ 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, 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);
useContextMenuStore.setState({ menu: { x, y, items, numbers: !!options?.numbers, id: ++menuSeq } });
}
export function hideContextMenu() {
@@ -511,14 +478,6 @@ 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,31 +193,6 @@ 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
+4 -35
View File
@@ -7,26 +7,12 @@ 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) return;
if (!selection?.anchorNode || !selection.focusNode || selection.isCollapsed) return;
const anchorIn = root.contains(selection.anchorNode);
const focusIn = root.contains(selection.focusNode);
if (anchorIn || focusIn) repaint();
if (selection.isCollapsed || anchorIn === focusIn) return;
if (anchorIn === focusIn) return;
const bounds = doc.createRange();
bounds.selectNodeContents(root);
@@ -40,24 +26,10 @@ 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) return;
html.setAttribute("data-selection-lock", "");
clampBeforePaint();
};
const unlock = () => {
if (!html.hasAttribute("data-selection-lock")) return;
html.removeAttribute("data-selection-lock");
clampBeforePaint();
if (e.button === 0) html.setAttribute("data-selection-lock", "");
};
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) => {
@@ -79,9 +51,6 @@ 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]);