mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-11 23:56:12 -10:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b91fb396a | ||
|
|
4792647d06 | ||
|
|
0262812d9b | ||
|
|
33957ed34f | ||
|
|
de9c8db74e | ||
|
|
e1f41093ec | ||
|
|
53d5d66520 | ||
|
|
0eb68afd40 |
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, _)| {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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**.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
@@ -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"
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)": "다른 이의 변경 사항 수락(캐럿 위치의 충돌)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in new issue
Block a user