Порівняти коміти

..
Автор SHA1 Повідомлення Дата
Gabriel Gondim e8bc577e9d test(deployment): cover Remote Host Download to… wiring (#2763)
Covers Download to… in the Remote Host view: the Project view selection preselects the folder picker (its parent folder for a selected file), cancelling the picker downloads nothing, and the chosen folder receives the remote paths.

Co-authored-by: GGVidal <[email protected]>
2026-11-12 02:18:54 +08:00
rohitkumardandaтаrohitdanda b1348ee03a fix(lsp): open network-share files and upper-case drive letters from language servers (#2794)
On Windows `uri_to_path` rejected any `file:` URI host other than `localhost`, so network-share results (`file://server/share/a.ts`) from language servers were dropped from diagnostics and renames and opened as read-only virtual documents; such hosts now map to `\\server\share\...`, and `path_to_uri` writes UNC paths in the same RFC 8089 form. Lower-case or escaped drive letters (`file:///c%3A/x`) are upper-cased in `uri_to_path` and `to_ipc`, so the UI no longer treats `c:/x` and `C:/x` as different files. Non-Windows behaviour is unchanged.

Co-authored-by: rohitdanda <[email protected]>
2026-11-12 02:17:35 +08:00
rohitkumardandaтаrohitdanda e99d05d7a8 fix(vfs): retry saving on Windows while another process briefly holds the file (#2791)
The atomic save renames a temp file over the target, and Windows refuses that while another process (antivirus or indexer scan, sync client, a program reading its config) has the file open without delete sharing, so the save failed with "Access is denied" or a sharing violation. The rename is now retried for up to about a second on Windows errors 5, 32 and 33; other errors and other platforms fail at once.

Co-authored-by: rohitdanda <[email protected]>
2026-11-12 02:14:29 +08:00
rohitkumardandaтаrohitdanda 0e4cf1059a fix(workspace): keep repeated inserts at one position in workspace edits (#2793)
`WorkspaceEdit::deduplicate` dropped any edit already present for the file, including repeats inside one `TextDocumentEdit`, so a server edit inserting the same text twice at one position (two closing brackets, two blank lines) lost one insert. It now only drops edits an earlier alias entry for the same file already contributed and keeps every edit of one entry; the result stays idempotent.

Co-authored-by: rohitdanda <[email protected]>
2026-11-12 02:13:19 +08:00
rohitkumardandaтаrohitdanda b4bcd8cce7 fix(editor): keep a chosen file encoding when the file changes on disk (#2792)
Reloads after an external change (`reload_from_disk`) and after undoing a workspace edit (`reload_resource_buffers`) always re-ran encoding detection, so a file the user had reloaded or converted to a legacy encoding came back as mojibake and later saves used the guessed encoding. Documents now record that the encoding was chosen and both reload paths decode with it; files whose encoding was never chosen are still detected again.

Co-authored-by: rohitdanda <[email protected]>
2026-11-12 02:12:13 +08:00
rohitkumardandaтаrohitdanda cb5e7081fa fix(platform): keep delivering IDE events after one handler throws (#2790)
Broadcasts called their listeners in a bare loop, so one throwing handler kept the rest (including the catch-all "*" IDE event handlers) from seeing the event. The IDE event bus, local diagnostics, endpoint-change and navigation-modifier broadcasts now go through `notifyEach`, which logs a throwing listener and moves on. `onIdeEvent` wraps each subscription so unsubscribing one of two identical handlers keeps the other.

Co-authored-by: rohitdanda <[email protected]>
2026-11-12 02:09:24 +08:00
rohitkumardandaтаrohitdanda 034b2b9f65 fix(app): keep the window responsive while local history, shelves and run files load (#2789)
Tauri runs commands declared without `async` on the main thread, so Local History, shelf, change-list and run-configuration reads and writes, `.env` loading and the line diff of two arbitrary texts blocked input, painting and every other IPC call while they ran. They are now async and run on the blocking pool; IPC signatures are unchanged. `vcs_changelists_save` stays synchronous so its fire-and-forget saves land in order. A guard test keeps these commands async.

Co-authored-by: rohitdanda <[email protected]>
2026-11-12 02:06:22 +08:00
l0ng-ai 7ea3c59d05 fix(vcs): list local changes when the work tree has a directory git can't read (#2800)
gitoxide's status stops at the first IO error, so one unreadable directory failed the whole status and the Commit window showed no changes, where git warns and lists the rest. Status now asks git (porcelain v2) when gitoxide fails; a test checks the parser against gitoxide on a full status matrix.

Fixes #2769
2026-11-12 01:59:27 +08:00
l0ng-ai 6717b0948a fix(vcs): open a log commit's file diffs in one reusable preview tab (#2798)
Double-click, Enter or Show Diff on a file in the Git log's changes opened a new editor tab each time. It now opens the editor's preview tab, as the Commit window does, and while that tab shows one of the pane's diffs, selecting another file shows it there. Pinned tabs are left alone.

Fixes #2768
2026-11-12 01:54:10 +08:00
l0ng-ai 8302cd1d6f fix(debugger): clean up Delve's build and hide Run/Debug when their last tab closes (#2797)
Delve in DAP mode builds the debuggee as __debug_bin<digits> on every platform; the post-session sweep only accepted that form on Windows, so the binary stayed in the project on macOS and Linux. Closing the last session tab also left an empty Debug (or Run) tool window open; both now hide with their last tab.

Fixes #2766
2026-11-12 01:49:21 +08:00
l0ng-ai c05670f4fd test: add framework_files to the LanguageConfig literals in ide-dap and ide-lsp tests (#2796)
LanguageConfig gained framework_files in #2781; the test files that spell the struct out no longer compiled.
2026-11-12 01:42:20 +08:00
l0ng-ai 2f9658c367 fix(vcs): smooth-scroll again when moving between differences (#2795)
Next/Previous Difference also dispatched a scrollIntoView effect, which set scrollTop in the same measure as the smooth scroll and cut the animation off. The smooth scroll already centres the caret, so the effect is removed.

Fixes #2764
2026-11-12 01:38:03 +08:00
l0ng-ai 40aa7e4899 fix(java): resolve navigation and usages against the text just typed (#2759)
Right after typing in a Java file, Show Usages answered for the wrong symbol and Go to Declaration could do nothing. Usages, Find Usages and hierarchy now wait for the editor's edits to sync before sending the caret position, and the java-usages commands (usages, implementations, hierarchy, configuration) wait for jdtls to reconcile queued document changes before resolving a position (bundle 1.13.0, rebuilt reproducibly from source). File-system events (e.g. the build writing target/classes) now only drop cached navigation answers instead of discarding an in-flight Go to Declaration.
2026-11-12 01:33:25 +08:00
arookieofcтаClaude Code fba1f02375 feat(hotkeys): warn when the OS grabbed an IDE shortcut (#1876) (#2683)
* feat(hotkeys): warn when the OS grabbed an IDE shortcut (#1876)

Probe single-stroke Alt bindings at startup with RegisterHotKey and
notify once per session when another app owns one, with an action that
opens Keymap for reassignment.

Co-Authored-By: Claude Code <[email protected]>

* fix(hotkeys): satisfy clippy::collapsible_if on the F-key parse

Co-Authored-By: Claude Code <[email protected]>

---------

Co-authored-by: Claude Code <[email protected]>
2026-11-12 01:33:04 +08:00
l0ng-ai 053ca43368 fix(usages): say at once that Show Usages is searching (#2760)
Show Usages showed nothing until its popup opened (300 ms after the first usages, or when the whole answer came), so a slower search looked like the shortcut had done nothing. It now shows "Searching for usages…" at the caret until the popup or the answer replaces it, and pending hints (such as Go to Implementation's) appear after 50 ms instead of 250 ms.
2026-11-12 01:30:18 +08:00
Vadim LazukтаInf1mum 8fedc2efba fix(vcs): stop filtered logs from fanning one dashed lane per merge path (#2788)
A filtered log connects each visible commit to the nearest matching ancestor along every merge path, and in merge-heavy histories one row could collect dozens of dashed connections, each getting its own lane, so the graph column stretched the log horizontally. pruneFilteredEdges now drops a dashed connection whose target the commit's other connections already reach (shallowest first), so reachability is unchanged with one lane instead of one per hidden merge path. Real parents, collapsed fragments and unfiltered histories are untouched; parent/child navigation follows the same pruned graph.

Fixes #2670

Co-authored-by: Inf1mum <[email protected]>
2026-11-12 01:28:32 +08:00
zhangdagouтаPLZY 2f2fa79cbe fix(build-tools): select a clicked completion in Execute Maven Goal / Gradle Task instead of appending it (#2785)
Clicking a completion appended it to the command line and re-filtered the list, so repeated clicks kept accumulating goals. A click now selects the row and shows its command line in the input; another click switches to that row; the list filters only on typing; Enter or a double-click runs it. Goals and tasks already on the command line are no longer offered again.

Co-authored-by: PLZY <+[email protected]>
2026-11-12 01:26:36 +08:00
Deng Liuтаdengliu 65d49619dd fix(terminal): invalidate selection highlight cache on scrollback trim (#2784)
The selection-occurrence cache keyed matches by absolute buffer row and relied on onRender to invalidate edited lines, so a full scrollback trimming from row 0, CSI 3 J or clear() shifted cached margin and scrollback rows without invalidation, and a selection trimmed off row 0 left its boxes behind. The cache now anchors a marker at row 0 while populated (any top trim disposes it and clears the cache), invalidates active-screen rows across baseY advances on write, and clears the overlay when a trim removes the selection.

Co-authored-by: dengliu <[email protected]>
2026-11-12 01:24:20 +08:00
Andrea Basileтаandreabasile 7f28161869 feat(languages): Django templates (#2781)
Adds a Django Template language: .html/.txt files in a templates folder of a project with manage.py (new [[framework-files]] detection), a Lezer grammar for tags, variables, filters and comments with HTML mounted between them, block folding/indent/matching, {% %} / {{ }} / {# #} pairing, end-tag insertion and completion, per-line {# #} comments, HTML language server and Emmet in the HTML parts, a banner offering the language for HTML files with Django tags elsewhere, a fixture and docs. The built-in HTML syntax check skips Django templates, whose tags the HTML grammar reports as errors.

Refs #2581

Co-authored-by: andreabasile <[email protected]>
2026-11-12 01:22:23 +08:00
l0ng-ai fc1493e1a4 feat(go): report upgrades, vulnerabilities and deprecated modules when go.mod opens (#2787)
gopls reports available upgrades only after its "Check for upgrades" code lens runs, never reports deprecated modules, and checks vulnerabilities only on demand. Opening a go.mod now runs the upgrade check once per file and session, gopls' vulncheck setting is on for imports (Go, go.mod, go.work), and `go list -m -u` marks deprecated requirements on open and save. go.mod highlighting also covers godebug, tool and ignore.
2026-11-12 01:14:56 +08:00
l0ng-ai efda6834ad feat(search): list directories in Go to File and Search Everywhere (#2783)
Go to File and the Files section of Search Everywhere listed only files, so a
folder could not be found by name. Directories from the index's folder search
now rank with the files (a file first on equal scores), with a folder icon, and
choosing one selects it in the Project view. A file type filter or a
File.kt:42 query still lists files only.
2026-11-12 00:54:21 +08:00
zhangdagouтаPLZY e92e867019 feat(vcs): show an added or deleted file's diff as one full-width pane (#2779)
A diff of an added or deleted file showed an empty half next to the existing side. It now shows the side that exists alone, full width, with its lines tinted and the viewer-mode buttons disabled, as IDEA's one-side viewer does. The saved viewer mode is unchanged.

Co-authored-by: PLZY <[email protected]>
2026-11-12 00:47:35 +08:00
zhangdagouтаPLZY 8d8c7a481b fix(vcs): align the diff titles and find bar with the panes, and title revisions by commit and path (#2778)
After the side-by-side divider was dragged, the right half of the title row and of the find bar stayed at 50% and no longer lined up with the right pane. Both now follow the right pane's actual start. A revision side's title shows its short commit id (HEAD resolved) and the file's path in the repository.

Co-authored-by: PLZY <[email protected]>
2026-11-12 00:47:25 +08:00
zhangdagouтаPLZY 0b3ca2fc9a feat(vcs): put the left pane's line numbers next to the divider in side-by-side diffs (#2776)
The left editor of a side-by-side diff is mirrored: its line numbers sit on its right, next to the divider, as in IDEA. CodeMirror's lineNumbers() has no side option, so the viewer adds an equivalent line-number gutter on the after side for that pane.

Co-authored-by: PLZY <[email protected]>
2026-11-12 00:47:15 +08:00
Ernstjan FreriksтаEjaYF 2414773d61 fix(agents): attach files dropped from outside the project (#2767)
Dropping a file from the OS onto the agent message box did nothing when it was outside the project (a screen recording on the Desktop, say): the drop handler only turned project paths into @ references and skipped the rest silently. Such files now show as attachment chips next to the image thumbnails and reach the agent as resource links. A dropped image over the 5 MB inline limit, or one that cannot be read, is attached by path instead of inlined. The box highlights while an OS drag is over it. Remote windows still skip paths outside the project.

Co-authored-by: EjaYF <[email protected]>
2026-11-12 00:45:19 +08:00
rohitkumardandaтаrohitdanda e4181c36ea fix(agents): roll a local task back to where it started when HEAD moved (#2774)
A task working in the project's own checkout compared and rolled back its changes against the current HEAD. Once HEAD moved back or to another branch during the task (git reset --soft, an undone commit, a checkout), the user's own work showed up as the task's changes and Rollback overwrote it; in a repository without commits, Changes and Rollback failed.

Local tasks now use the commit they started from: files that are as they were at the start are not listed, and Rollback and the diff base use the start commit, or HEAD when the user committed on top of it so those commits are kept. In a repository without commits new files are listed and rollback deletes them. Worktree tasks are unchanged.

Co-authored-by: rohitdanda <[email protected]>
2026-11-12 00:42:35 +08:00
usong 5063f6756e fix(editor): correct library source encoding and Reload with Encoding (#2775)
Library sources in a non-UTF-8 charset (e.g. GBK) opened garbled, and Reload with Encoding failed on them with a read-only error: library and virtual documents bypassed the editor's encoding pipeline (archive entries read as strict UTF-8, jdt: documents labelled utf-8, set_encoding rejecting every read-only virtual document).

jar: and jdt: sources are now decoded through the same VFS pipeline as files on disk (detection, BOM, UTF-16, explicit reload) and report the charset used. Reload re-decodes virtual documents while keeping them read-only: jar: entries are re-read; for jdt: sources the jdtls source attachment encoding is updated (waiting for Maven's background refresh, retrying once) and the attached entry re-read. Reload is disabled for decompiled and server-generated content and for read-only containers such as the JDK.

Co-authored-by: usongon <[email protected]>
2026-11-12 00:41:22 +08:00
rohitkumardandaтаrohitdanda 1a2f5308a0 fix(editor): keep syncing edits after a document snapshot fails (#2777)
When an edit was rejected as out of sync and the rebase snapshot then failed, the in-flight edit was never cleared, so flush() returned early forever: save, format and refactorings waited and the backend kept the old text. A failed rebase snapshot now resends the edit (bounded by MAX_REBASES, then the existing hard resync), and idle waiters are no longer released while an edit is pending.

When hardResync's own snapshot failed, the in-flight and buffered changes were dropped although the editor still showed them, so later edits were expressed against text the backend never had. The difference between the confirmed and local text is now kept as the buffer and sent with the next flush.

Co-authored-by: rohitdanda <[email protected]>
2026-11-12 00:34:03 +08:00
l0ng-ai cd04593d04 refactor(settings): open Project Structure only as its own dialog (#2744)
Settings no longer lists a Project Structure page; File › Project Structure…
(⌘; / Ctrl+Alt+Shift+S) opens the standalone dialog, which already had the
same sections. Arrow keys in the dialog's sidebar follow its navigation order.
2026-11-12 00:31:51 +08:00
l0ng-ai 38241e7fae feat(deployment): download Remote Host items to a chosen folder or by dragging (#2780)
Remote Host › Download to… asks for a local folder (the Project view's selected
folder preselected) and copies only the selected files and folders, each under
its own name, instead of recreating the server path below the project root.
Dragging items from Remote Host onto a folder in the Project view does the same.
Download to Project keeps using the mappings.
2026-11-12 00:29:34 +08:00
l0ng-ai 2e6d6a05b9 fix(plugins): accept empty navigation anchor ranges as positions (#2773)
A document indexer that anchors a whole-file definition at the file's start
(an empty range at 0:0) had the whole document rejected with "invalid
navigation anchors". Empty ranges inside the document are now accepted:
Go to Declaration lands on them, while Find Usages and Rename skip them so
a rename never inserts the new name at that position. The error for a
document that is still rejected names the plugin and the file.
2026-11-12 00:11:15 +08:00
183 змінених файлів з 5769 додано та 496 видалено

No files matched your search

+1
Переглянути файл
@@ -12,6 +12,7 @@
"src/editor/core/cm/angular/parser*.ts",
"src/editor/core/cm/jsp/parser*.ts",
"src/editor/core/cm/astro/parser*.ts",
"src/editor/core/cm/django/parser*.ts",
"plugins/themes.md",
"pnpm-lock.yaml",
"**/*.toml",
Створено
+1
Переглянути файл
@@ -4618,6 +4618,7 @@ name = "ide-lsp"
version = "0.1.42"
dependencies = [
"dunce",
"encoding_rs",
"futures",
"globset",
"ide-core",
+20 -15
Переглянути файл
@@ -1338,15 +1338,19 @@ impl AgentManager {
/// Files the task changed since it started.
pub async fn changes(&self, id: &str) -> IdeResult<Vec<AgentChange>> {
let task = self.task(id)?;
let (cwd, base) = task.cwd_and_base();
let at = task.base();
let before = task.state.lock().before.clone();
blocking(move || Ok(worktree::without_untouched(worktree::changes(&cwd, &base)?, &before))).await
blocking(move || at.changes(&before)).await
}
/// Content of `path` (in the task's directory) when the task started.
pub async fn base_text(&self, id: &str, path: PathBuf) -> IdeResult<Option<String>> {
let (cwd, base) = self.task(id)?.cwd_and_base();
blocking(move || worktree::file_at(&cwd, &path, &base)).await
let at = self.task(id)?.base();
blocking(move || match at.rev()? {
Some(rev) => worktree::file_at(at.cwd(), &path, &rev),
None => Ok(None),
})
.await
}
/// Rollback: puts the task's changed files among `rel_paths` (all with `None`) back as they
@@ -1356,16 +1360,17 @@ impl AgentManager {
if task.state.lock().busy {
return Err(IdeError::invalid("the agent is working: stop it before rolling back its changes"));
}
let (cwd, base) = task.cwd_and_base();
let at = task.base();
let (before, saved) = {
let s = task.state.lock();
(s.before.clone(), s.saved.as_ref().map(|name| self.shared.paths.store_dir.join(name)))
};
let restamped = blocking(move || {
let changes = worktree::without_untouched(worktree::changes(&cwd, &base)?, &before);
let changes = at.changes(&before)?;
let rev = at.rev()?;
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(&cwd, &base, change, &before, saved.as_deref())?;
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)));
@@ -1469,15 +1474,15 @@ impl Task {
})
}
fn cwd_and_base(&self) -> (PathBuf, String) {
fn base(&self) -> worktree::TaskBase {
let s = self.state.lock();
// In the project's own checkout the user commits too: compare against the current HEAD,
// as the Commit tool window does, so commits made since the task started drop out.
let base = match s.task.isolation {
Isolation::Local => None,
Isolation::Worktree => s.task.base_commit.clone(),
};
(s.task.cwd.0.clone(), base.unwrap_or_else(|| "HEAD".into()))
let cwd = s.task.cwd.0.clone();
match s.task.isolation {
Isolation::Local => worktree::TaskBase::Local { cwd, start: s.task.base_commit.clone() },
Isolation::Worktree => {
worktree::TaskBase::Worktree { cwd, base: s.task.base_commit.clone().unwrap_or_else(|| "HEAD".into()) }
}
}
}
fn emit_task(&self) {
+152
Переглянути файл
@@ -2869,6 +2869,158 @@ async fn rolling_back_restores_the_start_of_the_task_and_keeps_the_users_own_edi
assert!(!dir.join("store").join(format!("{}.before", task.id)).exists());
}
/// A git repository in a temporary folder, for local tasks whose checkout moves under them.
struct Checkout {
_tmp: tempfile::TempDir,
dir: PathBuf,
}
impl Checkout {
fn new() -> Self {
let tmp = tempfile::tempdir().unwrap();
let dir = dunce::canonicalize(tmp.path()).unwrap();
let checkout = Self { _tmp: tmp, dir };
checkout.git(&["init", "-q", "-b", "main"]);
checkout.git(&["config", "core.autocrlf", "false"]);
checkout.write(".gitignore", "store/\nworktrees/\nagents/\nmodels.json\n");
checkout
}
fn git(&self, args: &[&str]) {
let ok = std::process::Command::new("git")
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
.args(args)
.current_dir(&self.dir)
.status()
.unwrap()
.success();
assert!(ok, "{args:?}");
}
fn commit(&self, message: &str) {
self.git(&["add", "."]);
self.git(&["commit", "-q", "-m", message]);
}
fn write(&self, rel: &str, text: &str) {
std::fs::write(self.dir.join(rel), text).unwrap();
}
fn read(&self, rel: &str) -> String {
std::fs::read_to_string(self.dir.join(rel)).unwrap()
}
/// A local task, stopped so it can be rolled back.
async fn task(&self) -> (AgentManager, AgentTask) {
let mgr = manager(&self.dir, Arc::new(FakeAgent::default()));
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Hello"),
provider: "claude".into(),
config: Vec::new(),
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
mgr.cancel(&task.id, None).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
(mgr, task)
}
}
async fn changed(mgr: &AgentManager, id: &str) -> Vec<String> {
mgr.changes(id).await.unwrap().into_iter().map(|c| c.rel_path).collect()
}
#[tokio::test(flavor = "multi_thread")]
async fn work_uncommitted_by_a_reset_during_a_local_task_is_not_rolled_back() {
let repo = Checkout::new();
repo.write("a.txt", "base\n");
repo.write("mine.txt", "base\n");
repo.commit("init");
repo.write("mine.txt", "my work\n");
repo.commit("my work");
let (mgr, task) = repo.task().await;
repo.write("a.txt", "agent\n");
// The user takes the last commit back into the working tree (`git reset --soft`, an
// undone commit): its changes are the user's, not the agent's.
repo.git(&["reset", "-q", "--soft", "HEAD~1"]);
assert_eq!(changed(&mgr, &task.id).await, ["a.txt"]);
let base = mgr.base_text(&task.id, repo.dir.join("a.txt")).await.unwrap();
assert_eq!(base.as_deref(), Some("base\n"));
mgr.rollback_changes(&task.id, None).await.unwrap();
assert_eq!(repo.read("a.txt"), "base\n");
assert_eq!(repo.read("mine.txt"), "my work\n", "the user's work survives the rollback");
assert!(changed(&mgr, &task.id).await.is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn a_branch_switched_to_during_a_local_task_is_left_alone() {
let repo = Checkout::new();
repo.write("a.txt", "base\n");
repo.write("other.txt", "base\n");
repo.commit("init");
repo.git(&["checkout", "-q", "-b", "feature"]);
repo.write("other.txt", "feature\n");
repo.write("only-feature.txt", "feature\n");
repo.commit("feature");
repo.git(&["checkout", "-q", "main"]);
repo.write("only-main.txt", "main\n");
repo.commit("main");
let (mgr, task) = repo.task().await;
repo.write("a.txt", "agent\n");
// The agent's uncommitted edit comes along to a branch that doesn't contain the task's start.
repo.git(&["checkout", "-q", "feature"]);
assert_eq!(changed(&mgr, &task.id).await, ["a.txt"]);
mgr.rollback_changes(&task.id, None).await.unwrap();
assert_eq!(repo.read("a.txt"), "base\n");
assert_eq!(repo.read("other.txt"), "feature\n");
assert_eq!(repo.read("only-feature.txt"), "feature\n");
assert!(!repo.dir.join("only-main.txt").exists());
assert!(changed(&mgr, &task.id).await.is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn commits_made_during_a_local_task_are_kept_by_its_rollback() {
let repo = Checkout::new();
repo.write("a.txt", "base\n");
repo.commit("init");
let (mgr, task) = repo.task().await;
repo.write("a.txt", "agent\n");
repo.write("b.txt", "agent\n");
// The user commits what the agent did to `a.txt` so far: it drops out of the task's changes.
repo.git(&["add", "a.txt"]);
repo.git(&["commit", "-q", "-m", "keep"]);
assert_eq!(changed(&mgr, &task.id).await, ["b.txt"]);
repo.write("a.txt", "agent\nagain\n");
assert_eq!(changed(&mgr, &task.id).await, ["a.txt", "b.txt"]);
mgr.rollback_changes(&task.id, None).await.unwrap();
assert_eq!(repo.read("a.txt"), "agent\n", "back to the commit, not to before it");
assert!(!repo.dir.join("b.txt").exists());
assert!(changed(&mgr, &task.id).await.is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn a_local_task_in_a_repository_without_commits_lists_and_rolls_back_its_changes() {
let repo = Checkout::new();
repo.write("mine.txt", "mine\n");
let (mgr, task) = repo.task().await;
repo.write("new.txt", "agent\n");
assert_eq!(changed(&mgr, &task.id).await, ["new.txt"]);
assert_eq!(mgr.base_text(&task.id, repo.dir.join("new.txt")).await.unwrap(), None);
mgr.rollback_changes(&task.id, None).await.unwrap();
assert!(!repo.dir.join("new.txt").exists());
assert_eq!(repo.read("mine.txt"), "mine\n");
assert!(changed(&mgr, &task.id).await.is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn security_policy_auto_approves_whitelist_and_rejects_blacklist() {
use ide_core::settings::AgentSecuritySettings;
+123 -9
Переглянути файл
@@ -117,14 +117,92 @@ pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult<Vec<AgentChange>> {
let tracked: std::collections::HashSet<String> = out.iter().map(|c| c.rel_path.clone()).collect();
for s in repo.status()? {
if s.unstaged == Some(ChangeKind::Untracked) && !tracked.contains(&s.rel_path) {
let added = std::fs::read_to_string(&*s.path).map_or(0, |t| t.lines().count() as u32);
out.push(AgentChange { path: s.path, rel_path: s.rel_path, kind: ChangeKind::Added, added, removed: 0 });
out.push(added(s.path, s.rel_path));
}
}
out.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
Ok(out)
}
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 }
}
/// Where a task works and what its changes are measured against.
pub(crate) enum TaskBase {
/// A worktree of its own: everything since the commit it was created from, the agent's
/// own commits included.
Worktree { cwd: PathBuf, base: String },
/// The project's checkout, where the user works and commits too. `start` is the commit it
/// started from (`None` in a repository without commits, or an imported session).
Local { cwd: PathBuf, start: Option<String> },
}
impl TaskBase {
pub(crate) fn cwd(&self) -> &Path {
match self {
Self::Worktree { cwd, .. } | Self::Local { cwd, .. } => cwd,
}
}
/// The files the task changed, less those changed before it started and untouched since.
pub(crate) fn changes(&self, before: &BTreeMap<String, Stamp>) -> IdeResult<Vec<AgentChange>> {
let (cwd, start) = match self {
Self::Worktree { cwd, base } => return Ok(without_untouched(changes(cwd, base)?, before)),
Self::Local { cwd, start } => (cwd, start.as_deref()),
};
let Some(repo) = discover(cwd)? else { return Ok(Vec::new()) };
// What is uncommitted, as the Commit tool window lists it: commits made since the task
// started drop out.
let head = repo.branch_info()?.head_commit;
let listed = match &head {
Some(head) => changes(cwd, head)?,
None => {
let mut all: Vec<AgentChange> = repo
.status()?
.into_iter()
.filter(|s| s.path.is_file())
.map(|s| added(s.path, s.rel_path))
.collect();
all.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
all
}
};
let listed = without_untouched(listed, before);
let Some(start) = start.filter(|s| head.as_deref() != Some(*s)) else { return Ok(listed) };
// HEAD moved (a reset, a checkout, a pull): a file that is as it was when the task
// started is the user's doing, not the task's.
Ok(listed
.into_iter()
.filter(|c| {
before.contains_key(&c.rel_path)
|| !repo.file_bytes_at(&c.path.0, Some(start)).is_ok_and(|at| at == std::fs::read(&c.path.0).ok())
})
.collect())
}
/// The revision the task's changed files go back to on rollback, and are compared with:
/// the commit the task started from, or the current `HEAD` once the user committed on top
/// of it, so those commits stay. `None` while the repository has no commits.
pub(crate) fn rev(&self) -> IdeResult<Option<String>> {
let (cwd, start) = match self {
Self::Worktree { base, .. } => return Ok(Some(base.clone())),
Self::Local { cwd, start } => (cwd, start.as_deref()),
};
let Some(repo) = discover(cwd)? else { return Ok(None) };
let head = repo.branch_info()?.head_commit;
// A start commit git no longer knows (garbage-collected) can't be gone back to.
let start = start.filter(|s| repo.merge_base(s, s).ok().flatten().is_some());
Ok(match (start, head) {
(Some(start), Some(head)) if start != head && repo.merge_base(start, &head)?.as_deref() != Some(start) => {
Some(start.to_owned())
}
(start, head) => head.or_else(|| start.map(str::to_owned)),
})
}
}
/// Copies the task's `changes` (against `base`) from the worktree `cwd` into the
/// local checkout `root`, uncommitted: the user reviews and commits them with the
/// VCS tools. Files the checkout changed itself are left alone and reported, so
@@ -220,11 +298,11 @@ pub(crate) fn save_before(cwd: &Path, before: &BTreeMap<String, Stamp>, dir: &Pa
}
/// Rolls `change` back: to the copy saved when the task started when the file had changes
/// then (`before`, `saved` holding the copies), otherwise to `base` (removed when `base`
/// lacks it).
/// then (`before`, `saved` holding the copies), otherwise to `rev` (removed when `rev` lacks
/// it, or when there is no `rev`: the repository had no commits).
pub(crate) fn restore(
cwd: &Path,
base: &str,
rev: Option<&str>,
change: &AgentChange,
before: &BTreeMap<String, Stamp>,
saved: Option<&Path>,
@@ -242,10 +320,13 @@ pub(crate) fn restore(
})?;
Some(std::fs::read(copy)?)
}
None => {
let repo = Repo::discover(cwd)?.ok_or_else(|| IdeError::invalid("not in a git repository"))?;
repo.file_bytes_at(path, Some(base))?
}
None => match rev {
Some(rev) => {
let repo = Repo::discover(cwd)?.ok_or_else(|| IdeError::invalid("not in a git repository"))?;
repo.file_bytes_at(path, Some(rev))?
}
None => None,
},
};
match content {
Some(bytes) => {
@@ -490,6 +571,39 @@ mod tests {
assert_eq!(again.accepted, ["a.txt", "gone.txt", "new.txt"]);
}
#[test]
fn a_local_task_goes_back_to_its_start_unless_the_user_committed_on_top_of_it() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
std::fs::create_dir(&repo).unwrap();
git(&repo, &["init", "-q", "-b", "main"]);
let local = |start: Option<&str>| TaskBase::Local { cwd: repo.clone(), start: start.map(str::to_owned) };
let head = || head_of(&repo).unwrap().1;
let commit = |name: &str| {
std::fs::write(repo.join(name), "x\n").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-q", "-m", name]);
head_of(&repo).unwrap().1
};
assert_eq!(local(None).rev().unwrap(), None, "no commits yet");
let first = commit("a.txt");
assert_eq!(local(None).rev().unwrap(), Some(first.clone()), "an imported session: HEAD");
let start = commit("b.txt");
assert_eq!(local(Some(&start)).rev().unwrap(), Some(start.clone()), "HEAD where it started");
let ahead = commit("c.txt");
assert_eq!(local(Some(&start)).rev().unwrap(), Some(ahead), "committed on top: HEAD");
git(&repo, &["reset", "-q", "--soft", &first]);
assert_eq!(local(Some(&start)).rev().unwrap(), Some(start.clone()), "moved back: the start");
git(&repo, &["checkout", "-q", "-b", "side"]);
commit("d.txt");
assert_eq!(local(Some(&start)).rev().unwrap(), Some(start.clone()), "another branch: the start");
let gone = "0123456789abcdef0123456789abcdef01234567";
assert_eq!(local(Some(gone)).rev().unwrap(), Some(head()), "an unknown start: HEAD");
let worktree = TaskBase::Worktree { cwd: repo.clone(), base: start.clone() };
assert_eq!(worktree.rev().unwrap(), Some(start));
}
#[test]
fn accept_leaves_files_the_checkout_changed_itself() {
let (_tmp, repo, created) = accept_repo();
+10 -2
Переглянути файл
@@ -12,11 +12,16 @@ use std::path::{Path, PathBuf};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// `p` as the UI sees it: `C:\a\b` and `\\?\C:\a\b` become `C:/a/b`.
/// `p` as the UI sees it: `C:\a\b`, `c:\a\b` and `\\?\C:\a\b` become `C:/a/b`.
pub fn to_ipc(p: &Path) -> String {
#[cfg(windows)]
{
dunce::simplified(p).to_string_lossy().replace('\\', "/")
let mut s = dunce::simplified(p).to_string_lossy().replace('\\', "/");
// The UI compares path strings; a drive letter is case-insensitive.
if s.as_bytes().get(1) == Some(&b':') {
s[..1].make_ascii_uppercase();
}
s
}
#[cfg(not(windows))]
{
@@ -144,5 +149,8 @@ mod tests {
assert_eq!(to_ipc(Path::new(r"\\?\C:\a\b")), "C:/a/b");
assert_eq!(to_ipc(&Path::new("C:/a").join("b")), "C:/a/b");
assert_eq!(serde_json::to_string(&IpcPath::from(r"C:\a\b")).unwrap(), r#""C:/a/b""#);
assert_eq!(to_ipc(Path::new(r"c:\a\b")), "C:/a/b");
assert_eq!(to_ipc(Path::new(r"\\?\d:\a")), "D:/a");
assert_eq!(to_ipc(Path::new(r"\\server\share\a")), "//server/share/a");
}
}
+35 -2
Переглянути файл
@@ -138,8 +138,11 @@ impl WorkspaceEdit {
continue;
}
*expected = expected.or(version);
// Repeats within one entry are intended (LSP inserts at one position
// apply in order); only an alias entry repeats what is already merged.
let merged = own.len();
for edit in edits {
if !own.contains(&edit) {
if !own[..merged].contains(&edit) {
own.push(edit);
}
}
@@ -277,10 +280,40 @@ pub struct OutlineItem {
/// Fuzzy-match highlight: indices (in chars of the displayed string) that matched.
pub type MatchIndices = Vec<u32>;
#[cfg(all(test, unix))]
#[cfg(test)]
mod workspace_edit_tests {
use super::*;
#[test]
fn repeated_edits_within_one_entry_survive_but_repeats_across_entries_do_not() {
let path = PathBuf::from("missing-dir/main.ts");
let at = |line, text: &str| TextEdit {
range: Range::new(Pos::new(line, 0), Pos::new(line, 0)),
new_text: text.into(),
};
let entry = |edits: Vec<TextEdit>| FileEdit::Edit { path: path.clone().into(), edits, version: None };
let (nl, brace, other) = (at(0, "\n"), at(1, "}"), at(2, "x"));
for (entries, expected) in [
(vec![vec![nl.clone(), nl.clone()]], vec![nl.clone(), nl.clone()]),
(vec![vec![brace.clone(), nl.clone(), brace.clone()]], vec![brace.clone(), nl.clone(), brace.clone()]),
(vec![vec![nl.clone(), nl.clone()], vec![nl.clone(), nl.clone()]], vec![nl.clone(), nl.clone()]),
(vec![vec![nl.clone()], vec![nl.clone(), other.clone()]], vec![nl.clone(), other.clone()]),
(
vec![vec![nl.clone()], vec![other.clone(), other.clone()]],
vec![nl.clone(), other.clone(), other.clone()],
),
] {
let mut workspace =
WorkspaceEdit { files: entries.clone().into_iter().map(entry).collect(), ..Default::default() };
workspace.deduplicate();
assert_eq!(workspace.files, [entry(expected.clone())], "{entries:?}");
let once = workspace.clone();
workspace.deduplicate();
assert_eq!(workspace, once, "{entries:?}");
}
}
#[cfg(unix)]
#[test]
fn aliases_are_one_edit_but_resource_operations_preserve_order() {
let dir = tempfile::tempdir().unwrap();
+7 -11
Переглянути файл
@@ -621,9 +621,9 @@ fn step_settled(session: &Session, sent: Result<Value, DapError>) -> Result<Valu
sent
}
/// Delve compiles the debuggee to `__debug_bin` in the session's cwd (on Windows
/// `__debug_bin.exe` plus `__debug_bin.exe<digits>` in DAP mode) and removes it on a
/// graceful exit — a shutdown it doesn't always reach, so the files pile up. Sweep
/// Delve compiles the debuggee to `__debug_bin` in the session's cwd (in DAP mode a
/// temp-file name, `__debug_bin<digits>`; on Windows with `.exe` before the digits) and
/// removes it on a graceful exit — a shutdown it doesn't always reach, so the files pile up. Sweep
/// the leftovers once the session is down. Runs after `session_closed`, so Delve
/// has already exited or been killed and holds no file locks; a leftover of a
/// still-building run is removed on that run's own shutdown.
@@ -632,14 +632,10 @@ fn remove_delve_binaries(cwd: &Path) {
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
// Windows writes `__debug_bin.exe` (and `.exe<digits>` in DAP mode); other
// platforms keep the bare name. Suffixes like `.go` are user files.
let suffix = name.strip_prefix("__debug_bin");
let leftover = suffix.is_some_and(|s| {
s.is_empty()
|| s == ".exe"
|| (s.starts_with(".exe") && s[".exe".len()..].chars().all(|c| c.is_ascii_digit()))
});
// `__debug_bin`, then `.exe` on Windows, then DAP mode's temp-file digits.
// Suffixes like `.go` are user files.
let suffix = name.strip_prefix("__debug_bin").map(|s| s.strip_prefix(".exe").unwrap_or(s));
let leftover = suffix.is_some_and(|s| s.chars().all(|c| c.is_ascii_digit()));
if !leftover || entry.file_type().is_ok_and(|t| !t.is_file()) {
continue;
}
+5 -4
Переглянути файл
@@ -1472,6 +1472,7 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
indent: None,
ui_mode: None,
framework_ui_modes: Vec::new(),
framework_files: Vec::new(),
formatter: None,
scratch_template: None,
tree_sitter: None,
@@ -2095,20 +2096,20 @@ async fn stop_kills_the_program_started_by_the_reported_launcher() {
fn delve_leftovers_are_removed_without_touching_other_files() {
let dir = std::env::temp_dir().join(format!("openidea-delve-bin-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
for name in ["__debug_bin", "__debug_bin.exe", "__debug_bin.exe3323301925"] {
for name in ["__debug_bin", "__debug_bin833767175", "__debug_bin.exe", "__debug_bin.exe3323301925"] {
std::fs::write(dir.join(name), b"bin").unwrap();
}
// Lookalikes that are not Delve's own binaries stay: sources, a future
// in-progress build, and a directory that happens to share the name.
for name in ["__debug_bin.go", "__debug_bin.go.exe", "main.go"] {
for name in ["__debug_bin.go", "__debug_bin.go.exe", "__debug_bin12.go", "main.go"] {
std::fs::write(dir.join(name), b"keep").unwrap();
}
std::fs::create_dir(dir.join("__debug_bin.dir")).unwrap();
remove_delve_binaries(&dir);
for name in ["__debug_bin", "__debug_bin.exe", "__debug_bin.exe3323301925"] {
for name in ["__debug_bin", "__debug_bin833767175", "__debug_bin.exe", "__debug_bin.exe3323301925"] {
assert!(!dir.join(name).exists(), "{name} should be gone");
}
for name in ["__debug_bin.go", "__debug_bin.go.exe", "main.go", "__debug_bin.dir"] {
for name in ["__debug_bin.go", "__debug_bin.go.exe", "__debug_bin12.go", "main.go", "__debug_bin.dir"] {
assert!(dir.join(name).exists(), "{name} should stay");
}
std::fs::remove_dir_all(&dir).unwrap();
+30
Переглянути файл
@@ -169,6 +169,25 @@ pub async fn remote_rename(state: &Host, connection: FsConnection, from: String,
state.remote.remote_rename(&connection, &from, &to).await
}
/// Remote Host › Download to…: remote files and folders into a local folder, each under its
/// own name.
pub async fn remote_download_into(
window: &str,
state: &Host,
connection: FsConnection,
paths: Vec<String>,
target: IpcPath,
request: RequestId,
) -> IdeResult<TransferReport> {
let project = state.project(window)?;
let token = state.begin_request(request);
let (mut report, done) = progress(project.sink.clone(), "Downloading", token.clone());
let r = state.remote.download_into(&connection, &paths, &target, &token, &mut report).await;
done();
state.end_request(request);
r
}
pub async fn deploy_test_connection(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> {
state.remote.test_connection(&connection, &path).await
}
@@ -197,6 +216,17 @@ pub(crate) fn register(d: &mut crate::rpc::Dispatcher) {
d.add("remote_of", |c| async move { Ok(remote_of(&c.host, c.arg("path")?)) });
d.add("remote_delete", |c| async move { remote_delete(&c.host, c.arg("connection")?, c.arg("path")?).await });
d.add("remote_mkdir", |c| async move { remote_mkdir(&c.host, c.arg("connection")?, c.arg("path")?).await });
d.add("remote_download_into", |c| async move {
remote_download_into(
&c.window,
&c.host,
c.arg("connection")?,
c.arg("paths")?,
c.arg("target")?,
c.arg("request")?,
)
.await
});
d.add("remote_rename", |c| async move {
remote_rename(&c.host, c.arg("connection")?, c.arg("from")?, c.arg("to")?).await
});
+21 -1
Переглянути файл
@@ -19,7 +19,17 @@ pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send
pub async fn doc_open_virtual(host: &Host, window: &str, uri: String) -> IdeResult<DocumentInfo> {
let project = host.project(window)?;
let vd = crate::ops::lsp::lsp_virtual_document(host, window, uri).await?;
blocking(move || project.documents.open_virtual(&vd.uri, vd.language_id, &vd.text)).await
blocking(move || {
project.documents.open_virtual(
&vd.uri,
vd.language_id,
&vd.text,
&vd.encoding,
vd.has_bom,
vd.can_reload_encoding,
)
})
.await
}
/// The CodeMirror mode `path` gets when opened (`DocumentInfo::ui_mode`),
@@ -30,6 +40,8 @@ pub async fn doc_ui_mode(host: &Host, window: &str, path: PathBuf) -> IdeResult<
}
/// "Reload with Encoding" (`reload`) or "Convert to Encoding"; returns the new encoding and BOM state.
/// Virtual documents re-read their origin (archive, language server, Git blob)
/// instead of a disk file; converting them would write to it, so it stays refused.
pub async fn doc_set_encoding(
host: &Host,
window: &str,
@@ -38,6 +50,14 @@ pub async fn doc_set_encoding(
reload: bool,
) -> IdeResult<DocumentEncoding> {
let project = host.project(window)?;
if reload && let Some(uri) = project.documents.virtual_uri(doc)? {
let vd = if uri.starts_with("git-revision:") {
crate::ops::vcs::revision_virtual_document(host, window, &uri, Some(&encoding)).await?
} else {
project.lsp.reload_virtual(&uri, &encoding).await?
};
return host.blocking(move || project.documents.replace_virtual(doc, &vd.text, &vd.encoding, vd.has_bom)).await;
}
host.blocking(move || project.documents.set_encoding(doc, &encoding, reload)).await
}
+5 -2
Переглянути файл
@@ -176,8 +176,11 @@ async fn binary_and_virtual_documents_are_skipped() {
std::fs::write(&path, b"\0\0binary payload").unwrap();
let binary = f.open(&path).await;
assert!(binary.read_only);
let virtual_doc =
f.project.documents.open_virtual("test://virtual", ide_core::LanguageId::plain_text(), "virtual").unwrap();
let virtual_doc = f
.project
.documents
.open_virtual("test://virtual", ide_core::LanguageId::plain_text(), "virtual", "utf-8", false, false)
.unwrap();
std::fs::write(&path, "now ordinary text").unwrap();
f.events.lock().clear();
f.sync().await;
+1 -1
Переглянути файл
@@ -544,7 +544,7 @@ pub async fn lsp_goto_implementation(
/// classes and disk class files. Cached per project.
pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> IdeResult<VirtualDocument> {
if uri.starts_with("git-revision:") {
return crate::ops::vcs::revision_virtual_document(host, window, &uri).await;
return crate::ops::vcs::revision_virtual_document(host, window, &uri, None).await;
}
host.project(window)?.lsp.virtual_document(&uri).await
}
+59 -1
Переглянути файл
@@ -20,10 +20,13 @@ struct LogAccess {
}
/// Immutable Git objects exposed through the editor's ordinary read-only VFS.
/// `encoding` re-decodes the blob with it ("Reload with Encoding") instead of
/// the project's detection.
pub async fn revision_virtual_document(
host: &Host,
window: &str,
uri: &str,
encoding: Option<&str>,
) -> IdeResult<ide_lsp::types::VirtualDocument> {
#[derive(Deserialize)]
struct Target {
@@ -47,7 +50,7 @@ pub async fn revision_virtual_document(
.collect::<IdeResult<Vec<_>>>()?;
let target: Target = serde_json::from_slice(&bytes).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?;
let path = target.repo.join(&target.path);
let text = vcs_file_at(host, window, path, Some(target.revision.clone()))
let (text, charset, has_bom) = revision_text(host, window, &path, &target.revision, encoding)
.await?
.ok_or_else(|| ide_core::IdeError::not_found("file is absent at this revision"))?;
Ok(ide_lsp::types::VirtualDocument {
@@ -57,8 +60,63 @@ pub async fn revision_virtual_document(
title: target.path.file_name().unwrap_or_default().to_string_lossy().into_owned(),
description: Some(format!("{} · {}", target.repo.display(), target.revision)),
decompiled: false,
encoding: charset.name().to_ascii_lowercase(),
has_bom,
can_reload_encoding: true,
})
}
/// Revision text with the charset it was decoded with and whether a matching
/// BOM was stripped; `None` when the file is absent at the revision.
async fn revision_text(
host: &Host,
window: &str,
path: &Path,
rev: &str,
encoding: Option<&str>,
) -> IdeResult<Option<(String, &'static encoding_rs::Encoding, bool)>> {
let project = host.project(window).ok();
let external = host.external_logs.read().get(host.owner_window(window)).and_then(|rs| {
rs.iter().filter(|r| path.starts_with(r.root())).max_by_key(|r| r.root().components().count()).cloned()
});
let (path, rev, encoding) = (path.to_path_buf(), rev.to_owned(), encoding.map(str::to_owned));
blocking(move || {
fn with_repo<T>(
project: &Option<std::sync::Arc<Project>>,
external: &Option<std::sync::Arc<ide_vcs::Repo>>,
path: &Path,
f: impl FnOnce(&ide_vcs::Repo) -> IdeResult<T>,
) -> IdeResult<T> {
match project {
Some(project) => project.with_log_repo_of(path, f),
None => f(external
.as_ref()
.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?),
}
}
let (text_encoding, charset) = match &encoding {
Some(label) => {
let charset = encoding_rs::Encoding::for_label(label.trim().as_bytes())
.ok_or_else(|| ide_core::IdeError::invalid(format!("unknown encoding {label:?}")))?;
(ide_vcs::TextEncoding::from(charset), charset)
}
None => match &project {
Some(project) => {
let text_encoding = project.vcs_encoding(&path, Some(&rev))?;
(text_encoding, text_encoding.charset)
}
None => (ide_vcs::TextEncoding::from(encoding_rs::UTF_8), encoding_rs::UTF_8),
},
};
let bytes = with_repo(&project, &external, &path, |r| r.file_bytes_at(&path, Some(&rev)))?;
let Some(bytes) = bytes else { return Ok(None) };
let has_bom = encoding_rs::Encoding::for_bom(&bytes).is_some_and(|(enc, _)| enc == charset);
let text =
with_repo(&project, &external, &path, |r| r.file_at(&path, Some(&rev), text_encoding))?.unwrap_or_default();
Ok(Some((text, charset, has_bom)))
})
.await
}
impl LogAccess {
fn new(host: &Host, window: &str, at: Option<&Path>) -> Self {
let project = host.project(window).ok();
+1 -1
Переглянути файл
@@ -1231,7 +1231,7 @@ fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cac
/// The roots a file may navigate into: its own root (the workspace member containing it, or
/// the project root) plus that root's configured dependencies. A workspace member depends
/// only on the roots picked per member in Settings › Project Structure › Dependencies —
/// only on the roots picked per member in Project Structure › Dependencies —
/// being attached to the workspace never makes its code navigable by itself.
/// Cached navigation roots per (project, file), with the instant they were computed.
type NavigationRootsCache = HashMap<(PathBuf, PathBuf), (Instant, Vec<PathBuf>)>;
+8 -2
Переглянути файл
@@ -93,9 +93,15 @@ async fn external_history_is_readable_and_never_becomes_a_write_target() {
let payload =
serde_json::to_vec(&serde_json::json!({ "repo": external, "revision": first, "path": "deleted.txt" })).unwrap();
let hex = payload.iter().map(|b| format!("{b:02x}")).collect::<String>();
let document =
vcs::revision_virtual_document(&host, "standalone", &format!("git-revision:{hex}/deleted.txt")).await.unwrap();
let uri = format!("git-revision:{hex}/deleted.txt");
let document = vcs::revision_virtual_document(&host, "standalone", &uri, None).await.unwrap();
assert_eq!(document.text, "historical content\n");
assert_eq!(document.encoding, "utf-8");
assert!(document.can_reload_encoding);
// Reload with Encoding re-decodes the blob with the chosen charset.
let reloaded = vcs::revision_virtual_document(&host, "standalone", &uri, Some("utf-16le")).await.unwrap();
assert_eq!(reloaded.encoding, "utf-16le");
assert_ne!(reloaded.text, document.text);
host.trust.decide(&root, true, false).unwrap();
let project = host.open_project("main", &root).unwrap();
assert!(vcs::vcs_log(&host, "main", Some(external.clone()), Default::default()).await.is_err());
+1
Переглянути файл
@@ -330,6 +330,7 @@ async fn local_deployment_runs_on_the_project_host() {
"remote_delete",
"remote_mkdir",
"remote_rename",
"remote_download_into",
"db_sources",
"db_save_sources",
"db_set_password",
+1
Переглянути файл
@@ -94,6 +94,7 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
"remote_delete",
"remote_mkdir",
"remote_rename",
"remote_download_into",
"remote_import_local",
"remote_bootstrap_exec",
"deploy_upload",
+16
Переглянути файл
@@ -55,6 +55,10 @@ pub struct LanguageConfig {
/// the first one whose root marker is found applies.
#[serde(default)]
pub framework_ui_modes: Vec<FrameworkUiMode>,
/// Files of this language in a framework's projects that their extension alone would
/// give to another language (Django templates are `.html` files in `templates`).
#[serde(default)]
pub framework_files: Vec<FrameworkFiles>,
#[serde(default)]
pub tree_sitter: Option<String>,
#[serde(default)]
@@ -90,6 +94,18 @@ pub struct FrameworkUiMode {
pub root_markers: Vec<String>,
}
/// Files in a framework's folder: a file with one of `extensions` below a folder named
/// `folder`, which has a root marker in a directory above it.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct FrameworkFiles {
pub folder: String,
/// Without the dot, compared ignoring case.
pub extensions: Vec<String>,
/// Matched like language servers' `root-markers`, e.g. `manage.py`.
pub root_markers: Vec<String>,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all(serialize = "camelCase", deserialize = "kebab-case"))]
pub struct IndentConfig {
+54 -7
Переглянути файл
@@ -22,6 +22,7 @@ const BUILTIN: &[(&str, &str)] = &[
("d", include_str!("../../../languages/d.toml")),
("dart", include_str!("../../../languages/dart.toml")),
("diff", include_str!("../../../languages/diff.toml")),
("django-html", include_str!("../../../languages/django-html.toml")),
("dockerfile", include_str!("../../../languages/dockerfile.toml")),
("dotenv", include_str!("../../../languages/dotenv.toml")),
("elixir", include_str!("../../../languages/elixir.toml")),
@@ -371,14 +372,13 @@ impl LanguageRegistry {
.map(|f| f.ui_mode.clone())
}
/// Creating or removing `path` can change a file's `framework_ui_mode`:
/// it is a framework's root marker.
/// Creating or removing `path` can change a file's `framework_ui_mode` or its
/// language (`framework-files`): it is a framework's root marker.
pub fn is_framework_marker(&self, path: &Path) -> bool {
self.state()
.languages
.iter()
.flat_map(|l| &l.framework_ui_modes)
.any(|f| f.root_markers.iter().any(|m| crate::markers::is_marker_path(path, m)))
let state = self.state();
let modes = state.languages.iter().flat_map(|l| &l.framework_ui_modes).map(|f| &f.root_markers);
let files = state.languages.iter().flat_map(|l| &l.framework_files).map(|f| &f.root_markers);
modes.chain(files).flatten().any(|m| crate::markers::is_marker_path(path, m))
}
/// Detects by Override File Type, exact file name, then file-name pattern
@@ -400,6 +400,9 @@ impl LanguageRegistry {
if let Some((_, i)) = t.name_globs.iter().find(|(p, _)| wildcard_match(p, file_name)) {
return languages[*i].id.clone();
}
if let Some(l) = languages.iter().find(|l| l.framework_files.iter().any(|f| is_framework_file(f, path))) {
return l.id.clone();
}
let lower = file_name.to_ascii_lowercase();
let mut rest = lower.as_str();
while let Some(dot) = rest.find('.') {
@@ -420,6 +423,19 @@ impl LanguageRegistry {
}
}
/// `path` is one of `files`: its extension is listed, and the nearest folder above it named
/// `files.folder` has a root marker in a directory above that folder.
fn is_framework_file(files: &crate::FrameworkFiles, path: &Path) -> bool {
let Some(ext) = path.extension().and_then(|e| e.to_str()) else { return false };
if !files.extensions.iter().any(|e| e.eq_ignore_ascii_case(ext)) {
return false;
}
path.ancestors()
.skip(1)
.find(|dir| dir.file_name().is_some_and(|name| name == files.folder.as_str()))
.is_some_and(|folder| crate::marked_root(None, folder, &files.root_markers).is_some())
}
/// `*` matches any (possibly empty) run of characters, `?` exactly one; everything else is literal.
fn wildcard_match(pattern: &str, name: &str) -> bool {
let p: Vec<char> = pattern.chars().collect();
@@ -497,6 +513,7 @@ mod tests {
"Brainfuck",
"Cobol",
"C#",
"Django Template",
"Clojure",
"ClojureScript",
"Closure Stylesheets (GSS)",
@@ -801,6 +818,36 @@ mod tests {
assert!(!reg.is_framework_marker(&root.join("apps/admin/package.json")));
}
#[test]
fn django_projects_make_html_in_templates_folders_templates() {
let reg = reg();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let detect = |file: &str| reg.detect(&root.join(file), None).as_str().to_owned();
std::fs::create_dir_all(root.join("src/orders/templates/orders/email")).unwrap();
std::fs::create_dir_all(root.join("site/templates")).unwrap();
std::fs::write(root.join("src/manage.py"), "").unwrap();
assert_eq!(detect("src/orders/templates/orders/detail.html"), "django-html");
assert_eq!(detect("src/orders/templates/orders/email/body.TXT"), "django-html");
assert_eq!(detect("src/templates/base.html"), "django-html");
assert_eq!(detect("src/orders/templates/orders/data.json"), "json");
assert_eq!(detect("src/orders/static/orders/page.html"), "html");
// No `manage.py` above this `templates` folder.
assert_eq!(detect("site/templates/index.html"), "html");
assert_eq!(detect("page.djhtml"), "django-html");
assert!(reg.is_framework_marker(&root.join("src/manage.py")));
}
#[test]
fn the_django_fixture_has_templates_and_a_page() {
let reg = reg();
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/django-app");
let detect = |file: &str| reg.detect(&fixture.join(file), None).as_str().to_owned();
assert_eq!(detect("shop/templates/shop/product_list.html"), "django-html");
assert_eq!(detect("shop/templates/shop/email/order.txt"), "django-html");
assert_eq!(detect("pages/promo.html"), "html");
}
#[test]
fn markers_outside_the_project_do_not_make_angular_templates() {
let reg = reg();
+1
Переглянути файл
@@ -29,4 +29,5 @@ zip.workspace = true
[dev-dependencies]
tempfile.workspace = true
encoding_rs.workspace = true
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "test-util"] }
+223
Переглянути файл
@@ -0,0 +1,223 @@
//! go.mod as GoLand shows it: available upgrades, vulnerabilities and deprecated modules are
//! reported as soon as the file is opened. gopls reports upgrades only from its "Check for
//! upgrades" code lens, so that runs once per go.mod and session; its results (and the
//! vulnerabilities from the `vulncheck` setting) come back as gopls diagnostics with quick fixes.
//! gopls never reports a deprecated module, so `go list -m -u` does, on open and on save.
use crate::LspManager;
use crate::server::Server;
use ide_core::{DocId, Pos, Range};
use serde::Deserialize;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
pub(crate) const CHECK_UPGRADES: &str = "gopls.check_upgrades";
/// gopls answers code lenses once it has loaded the module.
const ATTEMPTS: u32 = 10;
const RETRY: Duration = Duration::from_secs(2);
/// Producer of the deprecation diagnostics (one full set per go.mod).
const DEPRECATIONS: &str = "go-mod-deprecations";
fn is_go_mod(path: &Path) -> bool {
path.file_name().is_some_and(|n| n == "go.mod")
}
impl LspManager {
pub(crate) async fn check_go_mod(&self, doc: DocId, path: &Path, server: Arc<Server>) {
if server.name != "gopls" || !is_go_mod(path) {
return;
}
self.report_deprecated_modules(doc, &server).await;
if !self.go_mod_checked.lock().insert(path.to_owned()) {
return;
}
for attempt in 0..ATTEMPTS {
if attempt > 0 {
tokio::time::sleep(RETRY).await;
}
if !self.docs.lock().contains_key(&doc) {
break;
}
let Ok(lenses) = self.code_lens(doc).await else { continue };
let Some(command) = lenses.into_iter().filter_map(|l| l.command).find(|c| c.command == CHECK_UPGRADES)
else {
continue;
};
if let Err(e) = self.execute_command(doc, command).await {
tracing::debug!("gopls: {CHECK_UPGRADES} for {}: {}", path.display(), e.message);
}
return;
}
// Not checked after all (closed, or gopls never offered the lens): a later open retries.
self.go_mod_checked.lock().remove(path);
}
/// After a save of a go.mod: its requirements may have changed.
pub(crate) fn go_mod_saved(&self, doc: DocId) {
let server = {
let docs = self.docs.lock();
let Some(d) = docs.get(&doc).filter(|d| is_go_mod(&d.path)) else { return };
let Some(a) = d.servers.iter().find(|a| a.server.name == "gopls") else { return };
a.server.clone()
};
let Some(me) = self.me.upgrade() else { return };
self.runtime.spawn(async move { me.report_deprecated_modules(doc, &server).await });
}
async fn report_deprecated_modules(&self, doc: DocId, server: &Server) {
let Some((path, text)) = self.docs.lock().get(&doc).map(|d| (d.path.clone(), d.text.as_str().to_owned()))
else {
return;
};
let required = required_modules(&text);
if required.is_empty() {
return;
}
let config = server.effective_config();
let search = crate::launch::server_search_path(&config);
let Some(dir) = path.parent().map(Path::to_path_buf) else { return };
let modules: Vec<String> = required.iter().map(|r| r.path.clone()).collect();
let listed = tokio::task::spawn_blocking(move || list_deprecations(&dir, &search, &modules)).await;
let Ok(Some(deprecated)) = listed else { return };
// The document changed meanwhile: its own save runs again.
if self.docs.lock().get(&doc).is_none_or(|d| d.text.as_str() != text) {
return;
}
let items = required
.into_iter()
.filter_map(|r| {
let message = deprecated.iter().find(|(p, _)| *p == r.path)?.1.trim();
Some(ide_core::Diagnostic {
range: r.range,
severity: ide_core::Severity::Warning,
message: format!("{} is deprecated: {message}", r.path),
source: Some("go".into()),
code: None,
unnecessary: false,
deprecated: true,
server: Some("gopls".into()),
})
})
.collect();
self.sink().emit(ide_core::IdeEvent::Diagnostics { path: path.into(), source: DEPRECATIONS.into(), items });
}
}
#[derive(Debug, PartialEq)]
struct Required {
path: String,
/// The module path in go.mod (UTF-16 columns).
range: Range,
}
/// Modules of go.mod's `require` directives, single-line or in a block.
fn required_modules(text: &str) -> Vec<Required> {
let mut out = Vec::new();
let mut in_block = false;
for (line, raw) in text.lines().enumerate() {
let code = raw.split("//").next().unwrap_or("");
let trimmed = code.trim_start();
let entry = if in_block {
if trimmed.starts_with(')') {
in_block = false;
continue;
}
trimmed
} else if let Some(rest) = trimmed.strip_prefix("require").filter(|r| r.starts_with(char::is_whitespace)) {
let rest = rest.trim_start();
if rest.starts_with('(') {
in_block = true;
continue;
}
rest
} else {
continue;
};
let mut words = entry.split_whitespace();
let (Some(module), Some(_version)) = (words.next(), words.next()) else { continue };
let module = module.trim_matches('"');
let Some(start) = code.find(module) else { continue };
let col = |byte: usize| code[..byte].encode_utf16().count() as u32;
let line = line as u32;
out.push(Required {
path: module.to_owned(),
range: Range { start: Pos { line, col: col(start) }, end: Pos { line, col: col(start + module.len()) } },
});
}
out
}
#[derive(Deserialize)]
struct ListedModule {
#[serde(rename = "Path")]
path: String,
#[serde(rename = "Deprecated", default)]
deprecated: Option<String>,
}
/// `(module, message)` of each deprecated module in `modules`; `None` when `go` failed.
fn list_deprecations(dir: &Path, search: &std::ffi::OsStr, modules: &[String]) -> Option<Vec<(String, String)>> {
let go: PathBuf = ide_core::tool_path::find_command("go", search, dir)?;
let output = ide_core::process::prepare(&mut std::process::Command::new(go))
.args(["list", "-m", "-u", "-e", "-json"])
.args(modules)
.current_dir(dir)
.env("PATH", search)
.stdin(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if output.stdout.is_empty() {
tracing::debug!("go list -m -u in {}: {}", dir.display(), output.status);
return None;
}
Some(parse_deprecations(&output.stdout))
}
fn parse_deprecations(json: &[u8]) -> Vec<(String, String)> {
serde_json::Deserializer::from_slice(json)
.into_iter::<ListedModule>()
.map_while(Result::ok)
.filter_map(|m| Some((m.path, m.deprecated.filter(|d| !d.trim().is_empty())?)))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn at(line: u32, from: u32, to: u32) -> Range {
Range { start: Pos { line, col: from }, end: Pos { line, col: to } }
}
#[test]
fn finds_required_modules_in_blocks_and_single_lines() {
let text = "module example.com/m\n\ngo 1.24\n\nrequire github.com/a/b v1.0.0 // indirect\n\
require (\n\tgithub.com/c/d v1.2.0\n\t\"github.com/e/é\" v0.1.0 // indirect\n)\n\
// require github.com/x/y v1.0.0\nreplace github.com/c/d => ../d\ntool golang.org/x/tools/cmd/stringer\n";
assert_eq!(
required_modules(text),
vec![
Required { path: "github.com/a/b".into(), range: at(4, 8, 22) },
Required { path: "github.com/c/d".into(), range: at(6, 1, 15) },
Required { path: "github.com/e/é".into(), range: at(7, 2, 16) },
]
);
}
#[test]
fn reads_deprecations_from_the_go_list_stream() {
let json = br#"{"Path": "github.com/golang/protobuf", "Version": "v1.5.4",
"Deprecated": "Use the \"google.golang.org/protobuf\" module instead."}
{"Path": "github.com/sergi/go-diff", "Version": "v1.3.1"}
{"Path": "example.com/blank", "Deprecated": " "}"#;
assert_eq!(
parse_deprecations(json),
vec![(
"github.com/golang/protobuf".to_owned(),
"Use the \"google.golang.org/protobuf\" module instead.".to_owned()
)]
);
}
}
+1 -1
Переглянути файл
@@ -16,7 +16,7 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
const SERVER: &str = "jdtls";
pub(crate) const SERVER: &str = "jdtls";
pub(crate) mod configuration;
mod formatter;
+1 -1
Переглянути файл
@@ -146,7 +146,7 @@ fn required_env_set(config: &LanguageServerConfig) -> bool {
}
/// A configured `PATH` (e.g. a selected toolchain prepended) also resolves the binary.
fn server_search_path(config: &LanguageServerConfig) -> OsString {
pub(crate) fn server_search_path(config: &LanguageServerConfig) -> OsString {
config.env.get("PATH").map(OsString::from).unwrap_or_else(search_path)
}
+234 -26
Переглянути файл
@@ -15,6 +15,7 @@ mod convert;
mod external_project;
mod file_ops;
mod file_watch;
mod go_mod;
mod go_scope;
mod handler;
mod injections;
@@ -221,6 +222,8 @@ pub struct LspManager {
configuration_cache: Arc<Mutex<jdtls::configuration::ConfigurationCache>>,
/// `window/showMessage` texts already surfaced; servers repeat them on every restart/reconfigure.
shown_messages: Mutex<HashSet<(String, String)>>,
/// go.mod files whose upgrades gopls has been asked to check this session.
go_mod_checked: Mutex<HashSet<PathBuf>>,
/// Virtual (non-`file:`) URIs a server returned, by the server that owns them.
virtual_origins: Mutex<HashMap<String, ide_core::ServerId>>,
/// Contents fetched for virtual URIs.
@@ -326,6 +329,41 @@ const RENAME_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis
/// Implementation requests of one line-marker pass in flight at once.
const IMPLEMENTATION_CONCURRENCY: usize = 4;
/// How a virtual document's content was decoded, and whether it can be
/// re-decoded ("Reload with Encoding").
struct Decoding {
encoding: String,
has_bom: bool,
can_reload: bool,
}
impl Decoding {
/// Text the server decoded itself: no raw bytes here to re-read.
fn server_text() -> Self {
Decoding { encoding: "utf-8".into(), has_bom: false, can_reload: false }
}
}
/// jdtls' source attachment of a `jdt:` class (`java.project.resolveSourceAttachment`).
struct SourceAttachment {
path: Option<String>,
encoding: Option<String>,
can_edit_encoding: bool,
}
/// Reads a `jar:` document's source: an entry as is, a class from the sources
/// jar next to its binary. `encoding` decodes with it instead of detecting.
fn read_jar_source(uri: &str, encoding: Option<&str>) -> Option<(String, ide_vfs::LoadedText)> {
match library_source::parse(uri) {
Some(e) if !e.entry.ends_with(".class") => {
library_source::read_entry(&e.jar, &e.entry, encoding).map(|loaded| (e.entry, loaded))
}
Some(_) => library_source::class_source(uri, encoding)
.and_then(|(source, loaded)| Some((library_source::parse(&source)?.entry, loaded))),
None => None,
}
}
/// Library classes in `jar:` targets (the official Kotlin LSP) go to their source
/// in the artifact's sources jar when it is cached, at the declaration of the
/// name navigated from; [`LspManager::virtual_document`] serves it.
@@ -340,8 +378,9 @@ async fn library_sources(snap: &DocSnap, pos: Pos, locs: Vec<Location>) -> Vec<L
let Some(uri) = loc.uri.as_deref().filter(|u| u.starts_with("jar:") && u.ends_with(".class")) else {
return loc;
};
let Some((source, text)) = library_source::class_source(uri) else { return loc };
let range = name.as_deref().map(|n| library_source::declaration_range(&text, n)).unwrap_or_default();
let Some((source, loaded)) = library_source::class_source(uri, None) else { return loc };
let range =
name.as_deref().map(|n| library_source::declaration_range(&loaded.text, n)).unwrap_or_default();
Location { path: source.clone().into(), range, uri: Some(source) }
})
.collect()
@@ -433,6 +472,7 @@ impl LspManager {
debug_servers: Mutex::new(HashMap::new()),
configuration_cache: Arc::new(Mutex::new(Default::default())),
shown_messages: Mutex::new(HashSet::new()),
go_mod_checked: Mutex::new(HashSet::new()),
virtual_origins: Mutex::new(HashMap::new()),
virtual_docs: Mutex::new(HashMap::new()),
injections: injections::State::default(),
@@ -1032,6 +1072,7 @@ impl LspManager {
servers: Vec<Arc<Server>>,
) {
let canonical = dunce::canonicalize(&path).unwrap_or_else(|_| path.clone());
let canonical_path = canonical.clone();
let compose_detected = language.as_str() == "yaml" && compose::is_content_detected_compose_file(&path, text);
let mut state = DocState {
path,
@@ -1062,6 +1103,13 @@ impl LspManager {
self.schedule_pull(doc, None, Duration::ZERO);
for s in &servers {
self.ensure_started(s);
// Waits for a server still starting, so it is not tied to `running()` below.
if s.name == "gopls"
&& let Some(me) = self.me.upgrade()
{
let (path, server) = (canonical_path.clone(), s.clone());
self.runtime.spawn(async move { me.check_go_mod(doc, &path, server).await });
}
if s.running().is_some()
&& let Some(me) = self.me.upgrade()
{
@@ -1203,6 +1251,7 @@ impl LspManager {
drop(docs);
self.schedule_pull(doc, None, Duration::ZERO);
self.pull_dependents(doc);
self.go_mod_saved(doc);
}
pub fn did_close(&self, doc: DocId) {
@@ -3020,8 +3069,9 @@ impl LspManager {
let original = uri.to_owned();
let archive = sources.clone();
let found = tokio::task::spawn_blocking(move || {
library_source::source_in_archive(&original, &archive).map(|(uri, text)| {
let range = name.as_deref().map(|n| library_source::declaration_range(&text, n)).unwrap_or_default();
library_source::source_in_archive(&original, &archive, None).map(|(uri, loaded)| {
let range =
name.as_deref().map(|n| library_source::declaration_range(&loaded.text, n)).unwrap_or_default();
Location { path: uri.clone().into(), uri: Some(uri), range }
})
})
@@ -3063,28 +3113,10 @@ impl LspManager {
if scheme == "jar" {
let found = {
let uri = uri.to_owned();
tokio::task::spawn_blocking(move || match library_source::parse(&uri) {
Some(e) if !e.entry.ends_with(".class") => {
library_source::read_entry(&e.jar, &e.entry).map(|text| (e.entry, text))
}
Some(_) => library_source::class_source(&uri)
.and_then(|(source, text)| Some((library_source::parse(&source)?.entry, text))),
None => None,
})
.await
.ok()
.flatten()
tokio::task::spawn_blocking(move || read_jar_source(&uri, None)).await.ok().flatten()
};
if let Some((entry, text)) = found {
let title = entry.rsplit('/').next().unwrap_or(&entry).to_owned();
return Ok(self.store_virtual(
uri,
text,
Some(title),
None,
&[],
self.origin_server(uri).map(|s| s.id),
));
if let Some((entry, loaded)) = found {
return Ok(self.store_jar_entry(uri, entry, loaded));
}
}
let class_file = scheme == "jdt" || (scheme == "file" && uri.ends_with(".class"));
@@ -3155,7 +3187,21 @@ impl LspManager {
continue;
}
};
return Ok(self.store_virtual(uri, text, None, description, &languages, Some(t.server.id)));
// jdtls decodes attached sources itself: its attachment encoding is
// the document's real charset, and editable ones can be re-decoded.
let decoding = if scheme == "jdt" && t.server.name == jdtls::SERVER && !virtual_doc::is_decompiled(&text) {
match self.jdt_source_attachment(uri).await {
Some(att) if att.path.is_some() => Decoding {
encoding: att.encoding.unwrap_or_else(|| "utf-8".into()),
has_bom: false,
can_reload: att.can_edit_encoding,
},
_ => Decoding::server_text(),
}
} else {
Decoding::server_text()
};
return Ok(self.store_virtual(uri, text, None, description, &languages, Some(t.server.id), decoding));
}
if jar_class && error.is_none() {
return Err(IdeError::not_found(format!(
@@ -3167,6 +3213,7 @@ impl LspManager {
}
/// Caches `text` as the virtual document `uri`, titled `title` (else after the URI).
#[allow(clippy::too_many_arguments)]
fn store_virtual(
&self,
uri: &str,
@@ -3175,6 +3222,7 @@ impl LspManager {
description: Option<String>,
languages: &[LanguageId],
server: Option<ide_core::ServerId>,
decoding: Decoding,
) -> VirtualDocument {
let decompiled = if virtual_doc::scheme(uri) == "csharp" {
virtual_doc::is_csharp_decompiled(uri)
@@ -3195,6 +3243,9 @@ impl LspManager {
title,
description: description.or_else(|| virtual_doc::description(uri)),
decompiled,
encoding: decoding.encoding,
has_bom: decoding.has_bom,
can_reload_encoding: decoding.can_reload,
};
if let Some(server) = server {
self.virtual_origins.lock().entry(uri.to_owned()).or_insert(server);
@@ -3207,6 +3258,163 @@ impl LspManager {
doc
}
/// Caches a decoded `jar:` entry under the document's original URI, titled
/// after the entry; the backend owns these bytes, so they can be re-decoded.
fn store_jar_entry(&self, uri: &str, entry: String, loaded: ide_vfs::LoadedText) -> VirtualDocument {
let title = entry.rsplit('/').next().unwrap_or(&entry).to_owned();
self.store_virtual(
uri,
loaded.text,
Some(title),
None,
&[],
self.origin_server(uri).map(|s| s.id),
Decoding { encoding: loaded.encoding, has_bom: loaded.has_bom, can_reload: true },
)
}
/// The source attachment jdtls resolves for a `jdt:` class; `None` when the
/// command is unavailable or the class's container cannot be queried.
async fn jdt_source_attachment(&self, uri: &str) -> Option<SourceAttachment> {
let result = self
.jdtls_command(
&self.project_root,
"java.project.resolveSourceAttachment",
vec![json!({ "classFileUri": uri })],
)
.await
.ok()?;
if result.get("errorMessage").and_then(Value::as_str).is_some_and(|s| !s.is_empty()) {
return None;
}
let attrs = result.get("attributes")?;
let non_empty = |key: &str| attrs.get(key).and_then(Value::as_str).filter(|s| !s.is_empty()).map(str::to_owned);
Some(SourceAttachment {
path: non_empty("sourceAttachmentPath"),
encoding: non_empty("sourceAttachmentEncoding"),
can_edit_encoding: attrs.get("canEditEncoding").and_then(Value::as_bool).unwrap_or(true),
})
}
/// Updates a `jdt:` class's source attachment encoding. The command drops
/// the attachment when the path is omitted, so the resolved path is always
/// sent along with the encoding.
async fn update_jdt_source_encoding(&self, uri: &str, path: &str, encoding: &str) -> IdeResult<()> {
let result = self
.jdtls_command(
&self.project_root,
"java.project.updateSourceAttachment",
vec![json!({
"classFileUri": uri,
"attributes": { "sourceAttachmentPath": path, "sourceAttachmentEncoding": encoding },
})],
)
.await?;
if let Some(error) = result.get("errorMessage").and_then(Value::as_str).filter(|s| !s.is_empty()) {
return Err(IdeError::unavailable(error));
}
Ok(())
}
/// Waits until the resolved attachment reports `encoding`; `false` once
/// `until` passes. Maven applies attachment updates in a background job,
/// so the new encoding is not visible immediately after the update.
async fn wait_for_jdt_source_encoding(&self, uri: &str, encoding: &str, until: tokio::time::Instant) -> bool {
loop {
match self.jdt_source_attachment(uri).await {
Some(att) if att.encoding.as_deref() == Some(encoding) => return true,
_ if tokio::time::Instant::now() < until => {
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
}
_ => return false,
}
}
}
/// "Reload with Encoding" for a virtual document: re-reads its origin with
/// `encoding` and replaces the cached content. `jar:` entries are re-read
/// here; `jdt:` sources are decoded by jdtls, so its source attachment
/// encoding is updated and the contents fetched again (keeping the server's
/// text and positions in sync). Server-generated content cannot be re-decoded.
pub async fn reload_virtual(&self, uri: &str, encoding: &str) -> IdeResult<VirtualDocument> {
ide_vfs::encode("", encoding, false)?;
match virtual_doc::scheme(uri) {
"jar" => {
let (owned_uri, owned_encoding) = (uri.to_owned(), encoding.to_owned());
let found = tokio::task::spawn_blocking(move || read_jar_source(&owned_uri, Some(&owned_encoding)))
.await
.map_err(|e| IdeError::internal(e.to_string()))?;
let Some((entry, loaded)) = found else {
return Err(IdeError::not_found(format!(
"no source is available for {}",
virtual_doc::title(uri, false)
)));
};
Ok(self.store_jar_entry(uri, entry, loaded))
}
"jdt" if self.origin_server(uri).is_none_or(|s| s.name == jdtls::SERVER) => {
let Some(att) = self.jdt_source_attachment(uri).await else {
return Err(IdeError::unavailable("jdtls cannot resolve the source attachment of this class"));
};
let Some(path) = att.path else {
return Err(IdeError::invalid("this class has no attached sources to re-decode"));
};
if !att.can_edit_encoding {
return Err(IdeError::invalid("the source attachment encoding of this library cannot be changed"));
}
self.update_jdt_source_encoding(uri, &path, encoding).await?;
// Maven containers apply the update in a background job (persist
// attached sources, then refresh the classpath): wait until the
// resolved attachment reports the new encoding, else the refetch
// below would still decode with the old one.
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
let first_try = tokio::time::Instant::now() + std::time::Duration::from_secs(3);
if !self.wait_for_jdt_source_encoding(uri, encoding, first_try).await {
// jdtls matches the existing encoding attribute by reference
// (`==`), so an entry restored from the saved container state
// gains a duplicate attribute and keeps the old value. That
// update still refreshes the container, after which a second
// update replaces the value correctly.
self.update_jdt_source_encoding(uri, &path, encoding).await?;
if !self.wait_for_jdt_source_encoding(uri, encoding, deadline).await {
return Err(IdeError::unavailable(
"jdtls did not apply the source attachment encoding; try reloading again",
));
}
}
// JDT keeps the class file's decoded buffer cached, so
// classFileContents would still return the old decoding: re-read
// the attached source entry here, like `jar:` documents. The
// server's open document is kept in sync by the regular
// document change flow.
let (owned_uri, owned_path, owned_encoding) = (uri.to_owned(), path.clone(), encoding.to_owned());
let found = tokio::task::spawn_blocking(move || {
library_source::source_in_archive(&owned_uri, Path::new(&owned_path), Some(&owned_encoding))
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?;
if let Some((_source_uri, loaded)) = found {
return Ok(self.store_virtual(
uri,
loaded.text,
None,
None,
&[],
self.origin_server(uri).map(|s| s.id),
Decoding { encoding: loaded.encoding, has_bom: loaded.has_bom, can_reload: true },
));
}
// Attachments that are not readable archives (e.g. folders):
// fall back to the server's contents.
self.virtual_docs.lock().remove(uri);
self.virtual_document(uri).await
}
_ => Err(IdeError::invalid(
"this document's content is generated by a language server and cannot be re-decoded with another encoding",
)),
}
}
/// Called when a server sends `workspace/applyEdit`; installed by `src-tauri`
/// so edits flow through `ide-workspace::Documents`.
pub fn set_config_hook(&self, hook: Box<ConfigFn>) {
+119 -33
Переглянути файл
@@ -7,8 +7,8 @@
//! it in the Maven or Gradle cache.
use ide_core::{Pos, Range};
use ide_vfs::LoadedText;
use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode};
use std::io::Read;
use std::path::{Path, PathBuf};
/// An entry of an archive named by a `jar:` URI.
@@ -57,29 +57,32 @@ impl JarEntry {
}
}
/// The text of `entry` in the archive `jar`, when it is a UTF-8 file.
pub(crate) fn read_entry(jar: &Path, entry: &str) -> Option<String> {
let mut zip = zip::ZipArchive::new(std::fs::File::open(jar).ok()?).ok()?;
let mut file = zip.by_name(entry).ok()?;
let mut text = String::new();
file.read_to_string(&mut text).ok()?;
Some(text)
/// The text of `entry` in the archive `jar`, decoded like a file opened in the
/// editor; `encoding` decodes with it instead of detecting ("Reload with
/// Encoding"). `None` for binary or unreadable entries.
pub(crate) fn read_entry(jar: &Path, entry: &str, encoding: Option<&str>) -> Option<LoadedText> {
let path = ide_vfs::archive_entry_path(jar, entry);
let loaded = match encoding {
Some(enc) => ide_vfs::load_text_as(&path, enc).ok()?,
None => ide_vfs::load_text(&path).ok()?,
};
(!loaded.binary).then_some(loaded)
}
/// The library source for a class entry: its `.kt` / `.java` file in the
/// artifact's sources jar, as `(uri, text)`.
pub(crate) fn class_source(uri: &str) -> Option<(String, String)> {
pub(crate) fn class_source(uri: &str, encoding: Option<&str>) -> Option<(String, LoadedText)> {
let jar = parse(uri)?;
let class = jar.entry.strip_suffix(".class")?;
for sources in sources_jars(&jar.jar) {
if let Some((entry, text)) = source_entry(&sources, class) {
if let Some((entry, text)) = source_entry(&sources, class, encoding) {
return Some((jar.uri_of(&sources, &entry), text));
}
}
None
}
pub(crate) fn source_in_archive(uri: &str, sources: &Path) -> Option<(String, String)> {
pub(crate) fn source_in_archive(uri: &str, sources: &Path, encoding: Option<&str>) -> Option<(String, LoadedText)> {
let jar = if uri.starts_with("jdt://contents/") {
let path = uri.split('?').next()?.strip_prefix("jdt://contents/")?;
let parts: Vec<_> = path.split('/').collect();
@@ -96,7 +99,7 @@ pub(crate) fn source_in_archive(uri: &str, sources: &Path) -> Option<(String, St
parse(uri)?
};
let class = jar.entry.strip_suffix(".class")?;
let (entry, text) = source_entry(sources, class)?;
let (entry, text) = source_entry(sources, class, encoding)?;
Some((jar.uri_of(sources, &entry), text))
}
@@ -117,8 +120,8 @@ fn sources_jars(jar: &Path) -> Vec<PathBuf> {
}
/// The source file of `class` (`pkg/Name`, `pkg/Outer$Inner`, `pkg/FileKt`) in `sources`.
fn source_entry(sources: &Path, class: &str) -> Option<(String, String)> {
let mut zip = zip::ZipArchive::new(std::fs::File::open(sources).ok()?).ok()?;
fn source_entry(sources: &Path, class: &str, encoding: Option<&str>) -> Option<(String, LoadedText)> {
let zip = zip::ZipArchive::new(std::fs::File::open(sources).ok()?).ok()?;
let (package, name) = class.rsplit_once('/').unwrap_or(("", class));
let outer = name.split('$').next().unwrap_or(name);
let dir = if package.is_empty() { String::new() } else { format!("{package}/") };
@@ -127,13 +130,9 @@ fn source_entry(sources: &Path, class: &str) -> Option<(String, String)> {
if let Some(file) = outer.strip_suffix("Kt").filter(|f| !f.is_empty()) {
candidates.push(format!("{dir}{file}.kt"));
}
let read = |zip: &mut zip::ZipArchive<std::fs::File>, entry: &str| {
let mut text = String::new();
zip.by_name(entry).ok()?.read_to_string(&mut text).ok()?;
Some(text)
};
let read = |entry: &str| read_entry(sources, entry, encoding);
for entry in &candidates {
if let Some(text) = read(&mut zip, entry) {
if let Some(text) = read(entry) {
return Some((entry.clone(), text));
}
}
@@ -145,8 +144,8 @@ fn source_entry(sources: &Path, class: &str) -> Option<(String, String)> {
.collect();
let jvm_name = format!("@file:JvmName(\"{outer}\")");
for entry in kotlin_files {
if let Some(text) = read(&mut zip, &entry)
&& text.contains(&jvm_name)
if let Some(text) = read(&entry)
&& text.text.contains(&jvm_name)
{
return Some((entry, text));
}
@@ -233,11 +232,15 @@ mod tests {
use std::io::Write;
fn jar(path: &Path, entries: &[(&str, &str)]) {
jar_bytes(path, &entries.iter().map(|(n, t)| (*n, t.as_bytes())).collect::<Vec<_>>());
}
fn jar_bytes(path: &Path, entries: &[(&str, &[u8])]) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let mut zip = zip::ZipWriter::new(std::fs::File::create(path).unwrap());
for (name, text) in entries {
for (name, bytes) in entries {
zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(text.as_bytes()).unwrap();
zip.write_all(bytes).unwrap();
}
zip.finish().unwrap();
}
@@ -274,12 +277,12 @@ mod tests {
"jdt://contents/lib-1.jar/p/Lib.java?=project/escaped-jar-path",
"jar:file:///m2/g/lib/1/lib-1.jar!/p/Lib$Inner.class",
] {
let (uri, text) = source_in_archive(uri, &sources).unwrap();
let (uri, text) = source_in_archive(uri, &sources, None).unwrap();
assert!(uri.ends_with("library%20sources.zip!/p/Lib.java"), "{uri}");
assert!(text.contains("class Lib"));
assert!(text.text.contains("class Lib"));
}
assert!(source_in_archive("jdt://contents/lib.jar/p/Missing.class", &sources).is_none());
assert!(source_in_archive("csharp:/p/App.csproj/decompiled/Lib.cs", &sources).is_none());
assert!(source_in_archive("jdt://contents/lib.jar/p/Missing.class", &sources, None).is_none());
assert!(source_in_archive("csharp:/p/App.csproj/decompiled/Lib.cs", &sources, None).is_none());
}
#[test]
@@ -312,20 +315,103 @@ mod tests {
("org/springframework/boot/Builder.class", "Builder.kt"),
("Root.class", "Root.java"),
] {
let (source_uri, text) = class_source(&uri(class)).unwrap_or_else(|| panic!("{class} in {binary:?}"));
let (source_uri, text) =
class_source(&uri(class), None).unwrap_or_else(|| panic!("{class} in {binary:?}"));
let dir = class.rsplit_once('/').map_or(String::new(), |(p, _)| format!("{p}/"));
assert!(source_uri.ends_with(&format!("-sources.jar!/{dir}{file}")), "{source_uri}");
assert_eq!(
parse(&source_uri).and_then(|e| read_entry(&e.jar, &e.entry)).as_deref(),
Some(text.as_str())
parse(&source_uri).and_then(|e| read_entry(&e.jar, &e.entry, None)).map(|l| l.text),
Some(text.text.clone())
);
}
assert_eq!(class_source(&uri("org/springframework/boot/Missing.class")), None);
assert!(class_source(&uri("org/springframework/boot/Missing.class"), None).is_none());
}
// No sources jar: nothing to show from here.
let lone = tmp.path().join("lone/x-1.jar");
jar(&lone, &[]);
assert_eq!(class_source(&format!("jar://{}!/X.class", lone.to_string_lossy().replace('\\', "/"))), None);
assert!(class_source(&format!("jar://{}!/X.class", lone.to_string_lossy().replace('\\', "/")), None).is_none());
}
/// The reported case: a library source file saved as GBK bytes, not UTF-8.
const GBK_SAMPLE: &str = "package example.encoding;\n\n/** 中文编码测试。 */\npublic final class EncodingSample {\n public static String message() {\n return \"中文编码测试:是否启用\";\n }\n}\n";
#[test]
fn archive_entries_decode_like_files_opened_in_the_editor() {
let tmp = tempfile::tempdir().unwrap();
let gbk = encoding_rs::GBK.encode(GBK_SAMPLE).0.into_owned();
// encoding_rs' encode() falls back to UTF-8 for UTF-16 (WHATWG has no encoder for it).
let mut utf16 = vec![0xFF, 0xFE];
utf16.extend(GBK_SAMPLE.encode_utf16().flat_map(u16::to_le_bytes));
let cases: Vec<(&str, Vec<u8>, &str, &str, bool)> = vec![
("utf8.java", GBK_SAMPLE.as_bytes().to_vec(), GBK_SAMPLE, "utf-8", false),
("bom.java", [b"\xEF\xBB\xBF", GBK_SAMPLE.as_bytes()].concat(), GBK_SAMPLE, "utf-8", true),
("gbk.java", gbk, GBK_SAMPLE, "gbk", false),
("utf16.java", utf16, GBK_SAMPLE, "utf-16le", true),
];
for (name, bytes, text, encoding, has_bom) in cases {
let jar_path = tmp.path().join(format!("lib-{name}.jar"));
jar_bytes(&jar_path, &[(&format!("p/{name}"), &bytes)]);
let loaded = read_entry(&jar_path, &format!("p/{name}"), None).unwrap_or_else(|| panic!("{name}"));
assert_eq!(loaded.text, text, "{name}");
assert_eq!(loaded.encoding, encoding, "{name}");
assert_eq!(loaded.has_bom, has_bom, "{name}");
assert!(!loaded.binary, "{name}");
}
// Binary entries are not shown as text.
let jar_path = tmp.path().join("bin.jar");
jar_bytes(&jar_path, &[("p/img.png", &[0x89, b'P', b'N', b'G', 0, 0, 0, 0])]);
assert!(read_entry(&jar_path, "p/img.png", None).is_none());
assert!(read_entry(&jar_path, "p/missing.java", None).is_none());
}
#[test]
fn reload_decodes_archive_entries_with_the_chosen_encoding() {
let tmp = tempfile::tempdir().unwrap();
let jar_path = tmp.path().join("lib.jar");
let gbk = encoding_rs::GBK.encode(GBK_SAMPLE).0.into_owned();
jar_bytes(&jar_path, &[("p/EncodingSample.java", &gbk)]);
let loaded = read_entry(&jar_path, "p/EncodingSample.java", Some("gbk")).unwrap();
assert_eq!((loaded.text.as_str(), loaded.encoding.as_str()), (GBK_SAMPLE, "gbk"));
// An explicit encoding wins over detection, even when it decodes to replacement characters.
let forced = read_entry(&jar_path, "p/EncodingSample.java", Some("utf-8")).unwrap();
assert!(forced.text.contains('\u{FFFD}'));
assert_eq!(forced.encoding, "utf-8");
assert!(read_entry(&jar_path, "p/EncodingSample.java", Some("not-an-encoding")).is_none());
}
#[test]
fn class_sources_in_maven_caches_decode_non_utf8() {
let tmp = tempfile::tempdir().unwrap();
let m2 = tmp.path().join("m2/com/example/encoding-sample/1.0.0");
let gbk = encoding_rs::GBK.encode(GBK_SAMPLE).0.into_owned();
let facade = "@file:JvmName(\"SampleUtils\")\npackage example.encoding\n\n/** 中文注释。 */\nfun message() = \"中文编码测试:是否启用\"\n";
let facade_gbk = encoding_rs::GBK.encode(facade).0.into_owned();
jar(&m2.join("encoding-sample-1.0.0.jar"), &[]);
jar_bytes(
&m2.join("encoding-sample-1.0.0-sources.jar"),
&[("example/encoding/EncodingSample.java", &gbk), ("example/encoding/Renamed.kt", &facade_gbk)],
);
let uri = |class: &str| {
format!("jar://{}!/{class}", m2.join("encoding-sample-1.0.0.jar").to_string_lossy().replace('\\', "/"))
};
for (class, text) in
[("example/encoding/EncodingSample.class", GBK_SAMPLE), ("example/encoding/SampleUtils.class", facade)]
{
let (source_uri, loaded) = class_source(&uri(class), None).unwrap_or_else(|| panic!("{class}"));
assert!(
source_uri.ends_with("-sources.jar!/example/encoding/EncodingSample.java")
|| source_uri.ends_with("-sources.jar!/example/encoding/Renamed.kt")
);
assert_eq!(loaded.text, text, "{class}");
assert_eq!(loaded.encoding, "gbk", "{class}");
// The manually attached archive path decodes the same way.
let jdt = "jdt://contents/encoding-sample-1.0.0.jar/example.encoding/EncodingSample.class?=project/x";
let (_, attached) = source_in_archive(jdt, &m2.join("encoding-sample-1.0.0-sources.jar"), None).unwrap();
assert_eq!((attached.text.as_str(), attached.encoding.as_str()), (GBK_SAMPLE, "gbk"));
}
// Reload with an explicit encoding re-reads the same source.
let (_, reloaded) = class_source(&uri("example/encoding/EncodingSample.class"), Some("gbk")).unwrap();
assert_eq!((reloaded.text.as_str(), reloaded.encoding.as_str()), (GBK_SAMPLE, "gbk"));
}
#[test]
+8
Переглянути файл
@@ -357,6 +357,14 @@ pub struct VirtualDocument {
pub description: Option<String>,
/// Generated Java from a decompiler, rather than real source or a disassembler stub.
pub decompiled: bool,
/// Charset the content was decoded with (a WHATWG label, e.g. `gbk`);
/// `utf-8` for text the server decoded itself.
pub encoding: String,
pub has_bom: bool,
/// "Reload with Encoding" can re-decode the origin's bytes (archive
/// entries, JDT sources with an editable attachment encoding); false for
/// server-generated content such as decompiled code.
pub can_reload_encoding: bool,
}
/// Reference updates for a file / folder move (`workspace/willRenameFiles`).
+64 -10
Переглянути файл
@@ -23,9 +23,20 @@ const PATH: &AsciiSet = &CONTROLS
.add(b'}');
pub fn path_to_uri_string(path: &Path) -> String {
let s = path.to_string_lossy();
let s = if cfg!(windows) { s.replace('\\', "/") } else { s.into_owned() };
let s = if cfg!(windows) {
dunce::simplified(path).to_string_lossy().replace('\\', "/")
} else {
path.to_string_lossy().into_owned()
};
let mut out = String::from("file://");
// `\\server\share\p` → `file://server/share/p` (RFC 8089 Appendix E.3).
let unc = s.strip_prefix("//?/UNC/").or_else(|| s.strip_prefix("//")).filter(|_| cfg!(windows));
if let Some(unc) = unc {
let (host, rest) = unc.split_at(unc.find('/').unwrap_or(unc.len()));
out.extend(utf8_percent_encode(host, PATH));
out.extend(utf8_percent_encode(rest, PATH));
return out;
}
if !s.starts_with('/') {
out.push('/');
}
@@ -41,21 +52,30 @@ pub fn path_to_uri(path: &Path) -> lsp_types::Uri {
})
}
/// Converts a `file:` URI to a path; `None` for other schemes.
/// Converts a `file:` URI to a path; `None` for other schemes. On Windows a
/// host names a share (`file://server/share/p` → `\\server\share\p`) and the
/// drive letter is upper-cased, as the rest of the IDE spells it.
pub fn uri_to_path(uri: &str) -> Option<PathBuf> {
let rest = uri.strip_prefix("file://").or_else(|| uri.strip_prefix("FILE://"))?;
// Skip an authority (`file://localhost/...`).
let path = match rest.find('/') {
Some(0) => rest,
Some(i) if rest[..i].eq_ignore_ascii_case("localhost") => &rest[i..],
_ => return None,
};
let (host, path) = rest.split_at(rest.find('/')?);
let local = host.is_empty() || host.eq_ignore_ascii_case("localhost");
let path = path.split(['?', '#']).next().unwrap_or(path);
let decoded = percent_decode_str(path).decode_utf8().ok()?;
if cfg!(windows) {
if !local {
let host = percent_decode_str(host).decode_utf8().ok()?;
return Some(PathBuf::from(format!(r"\\{host}{}", decoded.replace('/', "\\"))));
}
// `/C:/x` → `C:\x`
let trimmed = decoded.strip_prefix('/').filter(|s| s.as_bytes().get(1) == Some(&b':')).unwrap_or(&decoded);
return Some(PathBuf::from(trimmed.replace('/', "\\")));
let mut path = trimmed.replace('/', "\\");
if path.as_bytes().get(1) == Some(&b':') {
path[..1].make_ascii_uppercase();
}
return Some(PathBuf::from(path));
}
if !local {
return None;
}
Some(PathBuf::from(decoded.into_owned()))
}
@@ -89,6 +109,40 @@ mod tests {
assert_eq!(uri_to_path("jdt://contents/x"), None);
}
#[cfg(unix)]
#[test]
fn remote_hosts_are_not_local_paths() {
assert_eq!(uri_to_path("file://server/share/a.ts"), None);
}
#[cfg(windows)]
#[test]
fn windows_uris() {
for (uri, path) in [
("file:///C:/a%20b/c.rs", r"C:\a b\c.rs"),
("file:///c:/x/y.ts", r"C:\x\y.ts"),
("file:///c%3A/x/y.ts", r"C:\x\y.ts"),
("file:///C%3a/x", r"C:\x"),
("file://localhost/d:/x", r"D:\x"),
("file://server/share/a.ts", r"\\server\share\a.ts"),
("file://server/share/a%20b/c.ts", r"\\server\share\a b\c.ts"),
("file:////server/share/a.ts", r"\\server\share\a.ts"),
] {
let got = uri_to_path(uri).unwrap();
assert_eq!(got.as_os_str(), path, "{uri}");
}
assert_eq!(uri_to_path("file://server"), None);
for (path, uri, back) in [
(r"C:\a b\c.rs", "file:///C:/a%20b/c.rs", r"C:\a b\c.rs"),
(r"\\?\C:\x\y.ts", "file:///C:/x/y.ts", r"C:\x\y.ts"),
(r"\\server\share\a b.ts", "file://server/share/a%20b.ts", r"\\server\share\a b.ts"),
(r"\\?\UNC\server\share\a.ts", "file://server/share/a.ts", r"\\server\share\a.ts"),
] {
assert_eq!(path_to_uri(Path::new(path)).as_str(), uri, "{path}");
assert_eq!(uri_to_path(uri).unwrap().as_os_str(), back, "{path}");
}
}
#[test]
fn virtual_targets() {
let jdt = "jdt://contents/rt.jar/java.lang/String.class?=p/%5C/x.jar%3Cjava.lang(String.class";
+335
Переглянути файл
@@ -306,6 +306,7 @@ fn harness_configured(
indent: None,
ui_mode: None,
framework_ui_modes: Vec::new(),
framework_files: Vec::new(),
formatter: None,
scratch_template: None,
tree_sitter: None,
@@ -3668,6 +3669,340 @@ async fn jdt_class_file_contents() {
assert_eq!(source.title, "WithSources.java");
}
#[tokio::test(flavor = "multi_thread")]
async fn jdt_library_sources_reload_with_the_attachment_encoding() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
h.open("App.java", "import example.encoding.EncodingSample;\n");
h.wait_status("jdtls", ServerStatus::Ready).await;
let s = h.launcher.state("jdtls");
let jdt = "jdt://contents/encoding-sample-1.0.jar/example.encoding/EncodingSample.class?=demo/%5C/m2%5C/encoding-sample-1.0.jar%3Cexample.encoding%28EncodingSample.class";
// The mock jdtls decodes the GBK attachment with the configured charset:
// until the encoding is updated, the text arrives garbled (U+FFFD).
let source = "package example.encoding;\n\n/** 中文编码测试。 */\npublic final class EncodingSample {}";
let garbled =
"package example.encoding;\n\n/** \u{FFFD}\u{FFFD}\u{FFFD}\u{FFFD} */\npublic final class EncodingSample {}";
let attachment_encoding: Arc<Mutex<Option<String>>> = Arc::default();
*s.responder.lock() = Some(Arc::new({
let encoding = attachment_encoding.clone();
move |_, method, params| match method {
"java/classFileContents" => {
let text = if encoding.lock().as_deref() == Some("gbk") { source } else { garbled };
Some(json!(text))
}
"workspace/executeCommand" => match params["command"].as_str()? {
"java.project.resolveSourceAttachment" => Some(json!({
"errorMessage": null,
"attributes": {
"jarPath": "/m2/encoding-sample-1.0.jar",
"sourceAttachmentPath": "/m2/encoding-sample-1.0-sources.jar",
"sourceAttachmentEncoding": encoding.lock().clone(),
"canEditEncoding": true,
}
})),
"java.project.updateSourceAttachment" => {
let attrs = &params["arguments"][0]["attributes"];
*encoding.lock() = attrs["sourceAttachmentEncoding"].as_str().map(str::to_owned);
Some(json!({ "errorMessage": null, "attributes": null }))
}
_ => None,
},
_ => None,
}
}));
let vd = h.mgr.virtual_document(jdt).await.unwrap();
assert_eq!(vd.text, garbled);
assert_eq!(vd.encoding, "utf-8", "no attachment encoding configured yet");
assert!(vd.can_reload_encoding);
let reloaded = h.mgr.reload_virtual(jdt, "gbk").await.unwrap();
assert_eq!(reloaded.text, source);
assert_eq!(reloaded.encoding, "gbk");
assert!(reloaded.can_reload_encoding);
// The update kept the attachment path (omitting it would drop the sources)
// and set the encoding; the contents were fetched again.
let updates: Vec<Value> = s
.requested("workspace/executeCommand")
.into_iter()
.filter(|p| p["command"] == "java.project.updateSourceAttachment")
.collect();
assert_eq!(updates.len(), 1);
assert_eq!(updates[0]["arguments"][0]["classFileUri"], jdt);
assert_eq!(updates[0]["arguments"][0]["attributes"]["sourceAttachmentPath"], "/m2/encoding-sample-1.0-sources.jar");
assert_eq!(updates[0]["arguments"][0]["attributes"]["sourceAttachmentEncoding"], "gbk");
assert!(s.requested("java/classFileContents").len() >= 2);
// The cache serves the re-decoded text without another round trip.
assert_eq!(h.mgr.virtual_document(jdt).await.unwrap().text, source);
// Round trip back to UTF-8.
let back = h.mgr.reload_virtual(jdt, "utf-8").await.unwrap();
assert_eq!(back.text, garbled);
assert_eq!(back.encoding, "utf-8");
}
#[tokio::test(flavor = "multi_thread")]
async fn jdt_reload_waits_for_the_maven_container_refresh() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
h.open("App.java", "class App {}\n");
h.wait_status("jdtls", ServerStatus::Ready).await;
let s = h.launcher.state("jdtls");
let jdt = "jdt://contents/lib.jar/p/Lib.class?=demo/%5C/m2%5C/lib.jar%3Cp%28Lib.class";
// m2e persists and refreshes the container in a background job: resolve
// keeps reporting the old encoding for a few calls after the update.
let state: Arc<Mutex<(Option<String>, usize)>> = Arc::default();
*s.responder.lock() = Some(Arc::new({
let state = state.clone();
move |_, method, params| match method {
"java/classFileContents" => {
let enc = state.lock().0.clone();
Some(json!(if enc.as_deref() == Some("gbk") {
"package p; /** 中文 */ class Lib {}"
} else {
"package p; /** \u{FFFD} */ class Lib {}"
}))
}
"workspace/executeCommand" => match params["command"].as_str()? {
"java.project.resolveSourceAttachment" => {
let mut st = state.lock();
st.1 += 1;
let reported = if st.1 > 2 { st.0.clone() } else { None };
Some(json!({
"attributes": {
"sourceAttachmentPath": "/m2/lib-sources.jar",
"sourceAttachmentEncoding": reported,
"canEditEncoding": true,
}
}))
}
"java.project.updateSourceAttachment" => {
let mut st = state.lock();
st.0 = params["arguments"][0]["attributes"]["sourceAttachmentEncoding"].as_str().map(str::to_owned);
st.1 = 0;
Some(json!({ "errorMessage": null }))
}
_ => None,
},
_ => None,
}
}));
let vd = h.mgr.virtual_document(jdt).await.unwrap();
assert!(vd.text.contains('\u{FFFD}'));
let reloaded = h.mgr.reload_virtual(jdt, "gbk").await.unwrap();
assert_eq!(reloaded.text, "package p; /** 中文 */ class Lib {}");
assert_eq!(reloaded.encoding, "gbk");
assert!(state.lock().1 >= 3, "polled until the container reported the new encoding");
}
#[tokio::test(flavor = "multi_thread")]
async fn jdt_reload_retries_when_a_restored_container_ignores_the_update() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
h.open("App.java", "class App {}\n");
h.wait_status("jdtls", ServerStatus::Ready).await;
let s = h.launcher.state("jdtls");
let jdt = "jdt://contents/lib.jar/p/Lib.class?=demo/%5C/m2%5C/lib.jar%3Cp%28Lib.class";
// jdtls replaces the encoding attribute by reference equality: an entry
// restored from the saved container state keeps the old value on the first
// update (a duplicate attribute is appended); only a second update, after
// the container was refreshed, applies the new one.
let state: Arc<Mutex<(Option<String>, usize)>> = Arc::default();
*s.responder.lock() = Some(Arc::new({
let state = state.clone();
move |_, method, params| match method {
"java/classFileContents" => {
let enc = state.lock().0.clone();
Some(json!(if enc.as_deref() == Some("gbk") {
"package p; /** 中文 */ class Lib {}"
} else {
"package p; /** \u{FFFD} */ class Lib {}"
}))
}
"workspace/executeCommand" => match params["command"].as_str()? {
"java.project.resolveSourceAttachment" => Some(json!({
"attributes": {
"sourceAttachmentPath": "/m2/lib-sources.jar",
"sourceAttachmentEncoding": state.lock().0.clone(),
"canEditEncoding": true,
}
})),
"java.project.updateSourceAttachment" => {
let mut st = state.lock();
st.1 += 1;
if st.1 > 1 {
st.0 = params["arguments"][0]["attributes"]["sourceAttachmentEncoding"]
.as_str()
.map(str::to_owned);
}
Some(json!({ "errorMessage": null }))
}
_ => None,
},
_ => None,
}
}));
let vd = h.mgr.virtual_document(jdt).await.unwrap();
assert!(vd.text.contains('\u{FFFD}'));
let reloaded = h.mgr.reload_virtual(jdt, "gbk").await.unwrap();
assert_eq!(reloaded.text, "package p; /** 中文 */ class Lib {}");
assert_eq!(reloaded.encoding, "gbk");
let updates: Vec<Value> = s
.requested("workspace/executeCommand")
.into_iter()
.filter(|p| p["command"] == "java.project.updateSourceAttachment")
.collect();
assert_eq!(updates.len(), 2, "the ignored first update was retried once");
assert!(updates.iter().all(|u| u["arguments"][0]["attributes"]["sourceAttachmentPath"] == "/m2/lib-sources.jar"));
}
#[tokio::test(flavor = "multi_thread")]
async fn jdt_reload_reads_the_attachment_when_the_server_buffer_is_stale() {
use std::io::Write;
let tmp = tempfile::tempdir().unwrap();
let sources = tmp.path().join("lib-sources.jar");
let source = "package p; /** 中文 */ class Lib {}";
let gbk = encoding_rs::GBK.encode(source).0.into_owned();
let mut zip = zip::ZipWriter::new(std::fs::File::create(&sources).unwrap());
zip.start_file("p/Lib.java", zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(&gbk).unwrap();
zip.finish().unwrap();
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
h.open("App.java", "class App {}\n");
h.wait_status("jdtls", ServerStatus::Ready).await;
let s = h.launcher.state("jdtls");
let jdt = "jdt://contents/lib.jar/p/Lib.class?=demo/%5C/m2%5C/lib.jar%3Cp%28Lib.class";
// JDT caches the decoded buffer of a class file: even after the attachment
// encoding is updated, classFileContents keeps returning the old decoding.
let encoding: Arc<Mutex<Option<String>>> = Arc::default();
*s.responder.lock() = Some(Arc::new({
let encoding = encoding.clone();
let sources = sources.clone();
move |_, method, params| match method {
"java/classFileContents" => Some(json!("package p; /** \u{FFFD} */ class Lib {}")),
"workspace/executeCommand" => match params["command"].as_str()? {
"java.project.resolveSourceAttachment" => Some(json!({
"attributes": {
"sourceAttachmentPath": sources.to_string_lossy(),
"sourceAttachmentEncoding": encoding.lock().clone(),
"canEditEncoding": true,
}
})),
"java.project.updateSourceAttachment" => {
*encoding.lock() =
params["arguments"][0]["attributes"]["sourceAttachmentEncoding"].as_str().map(str::to_owned);
Some(json!({ "errorMessage": null }))
}
_ => None,
},
_ => None,
}
}));
let vd = h.mgr.virtual_document(jdt).await.unwrap();
assert!(vd.text.contains('\u{FFFD}'));
let reloaded = h.mgr.reload_virtual(jdt, "gbk").await.unwrap();
assert_eq!(reloaded.text, source, "read from the attachment, not the stale server buffer");
assert_eq!(reloaded.encoding, "gbk");
assert!(reloaded.can_reload_encoding);
}
#[tokio::test(flavor = "multi_thread")]
async fn jdt_reload_is_refused_when_the_sources_cannot_be_re_decoded() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
h.open("App.java", "class App {}\n");
h.wait_status("jdtls", ServerStatus::Ready).await;
let s = h.launcher.state("jdtls");
let class =
|name: &str| format!("jdt://contents/lib.jar/p/{name}.class?=demo/%5C/m2%5C/lib.jar%3Cp%28{name}.class");
s.respond("java/classFileContents", json!("package p;\npublic class Lib {}"));
// A container that does not allow editing the attachment encoding.
*s.responder.lock() = Some(Arc::new(|_, method, params| match method {
"workspace/executeCommand" if params["command"] == "java.project.resolveSourceAttachment" => Some(json!({
"attributes": { "sourceAttachmentPath": "/m2/lib-sources.jar", "canEditEncoding": false }
})),
_ => None,
}));
let vd = h.mgr.virtual_document(&class("ReadOnly")).await.unwrap();
assert!(!vd.can_reload_encoding);
let err = h.mgr.reload_virtual(&class("ReadOnly"), "gbk").await.unwrap_err();
assert!(err.message.contains("cannot be changed"), "{err}");
// Decompiled content has no attached sources to re-decode.
*s.responder.lock() = Some(Arc::new(|_, method, params| match method {
"workspace/executeCommand" if params["command"] == "java.project.resolveSourceAttachment" => Some(json!({
"attributes": { "sourceAttachmentPath": null, "canEditEncoding": true }
})),
_ => None,
}));
s.respond(
"java/classFileContents",
json!("// Source code is decompiled from a .class file using FernFlower decompiler (from Intellij IDEA).\npackage p;\npublic class Lib {}"),
);
let decompiled = h.mgr.virtual_document(&class("Decompiled")).await.unwrap();
assert!(decompiled.decompiled && !decompiled.can_reload_encoding);
let err = h.mgr.reload_virtual(&class("Decompiled"), "gbk").await.unwrap_err();
assert!(err.message.contains("no attached sources"), "{err}");
// The resolve command fails (unqueryable container): no reload offered.
s.errors.lock().insert("workspace/executeCommand".into(), (-32603, "unsupported".into()));
let unresolved = h.mgr.virtual_document(&class("Unresolved")).await.unwrap();
assert_eq!(unresolved.encoding, "utf-8");
assert!(!unresolved.can_reload_encoding);
let err = h.mgr.reload_virtual(&class("Unresolved"), "gbk").await.unwrap_err();
assert!(err.message.contains("cannot resolve"), "{err}");
// Server-generated content of other schemes cannot be re-decoded at all.
let err = h.mgr.reload_virtual("csharp:/p/App.csproj/decompiled/Lib.cs", "gbk").await.unwrap_err();
assert!(err.message.contains("generated by a language server"), "{err}");
// Unknown encodings are refused before any server round trip.
assert!(h.mgr.reload_virtual(&class("ReadOnly"), "not-an-encoding").await.is_err());
}
#[tokio::test(flavor = "multi_thread")]
async fn jar_library_sources_reload_with_another_encoding() {
use std::io::Write;
let repo = tempfile::tempdir().unwrap();
let dir = repo.path().join("com/example/encoding-sample/1.0.0");
std::fs::create_dir_all(&dir).unwrap();
let source = "package example.encoding\n\n/** 中文编码测试。 */\nfun message() = \"中文编码测试:是否启用\"\n";
let gbk = encoding_rs::GBK.encode(source).0.into_owned();
let jar_bytes = |path: &Path, entries: &[(&str, &[u8])]| {
let mut zip = zip::ZipWriter::new(std::fs::File::create(path).unwrap());
for (name, bytes) in entries {
zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(bytes).unwrap();
}
zip.finish().unwrap();
};
jar_bytes(&dir.join("encoding-sample-1.0.jar"), &[("example/encoding/EncodingSampleKt.class", b"\0")]);
jar_bytes(&dir.join("encoding-sample-1.0-sources.jar"), &[("example/encoding/EncodingSample.kt", &gbk)]);
let jar_uri = |jar: &Path, entry: &str| format!("jar://{}!/{entry}", uri_of(jar).trim_start_matches("file://"));
let h = harness_for("kotlin", "kt", &[("kotlin-lsp", incremental_caps())], &[]);
h.open("Main.kt", "fun main() {}\n");
h.wait_status("kotlin-lsp", ServerStatus::Ready).await;
let class = jar_uri(&dir.join("encoding-sample-1.0.jar"), "example/encoding/EncodingSampleKt.class");
let entry = jar_uri(&dir.join("encoding-sample-1.0-sources.jar"), "example/encoding/EncodingSample.kt");
let vd = h.mgr.virtual_document(&class).await.unwrap();
assert_eq!(vd.text, source);
assert_eq!(vd.encoding, "gbk");
assert!(vd.can_reload_encoding);
// An explicit encoding wins over detection, even when it garbles the text.
let forced = h.mgr.reload_virtual(&class, "utf-8").await.unwrap();
assert!(forced.text.contains('\u{FFFD}'));
assert_eq!(forced.encoding, "utf-8");
let back = h.mgr.reload_virtual(&class, "gbk").await.unwrap();
assert_eq!((back.text.as_str(), back.encoding.as_str()), (source, "gbk"));
// Non-class entries reload the same way.
let entry_vd = h.mgr.virtual_document(&entry).await.unwrap();
assert_eq!((entry_vd.text.as_str(), entry_vd.encoding.as_str()), (source, "gbk"));
let entry_reload = h.mgr.reload_virtual(&entry, "gbk").await.unwrap();
assert_eq!(entry_reload.text, source);
// Missing sources fail cleanly.
let missing = jar_uri(&dir.join("encoding-sample-1.0.jar"), "example/encoding/Missing.class");
assert!(h.mgr.reload_virtual(&missing, "gbk").await.is_err());
}
#[tokio::test(flavor = "multi_thread")]
async fn class_file_opening_routes_sources_and_binary_documents() {
let mut caps = incremental_caps();
+1
Переглянути файл
@@ -55,6 +55,7 @@ fn lang(id: &str, ext: &str, server: LanguageServerConfig) -> LanguageConfig {
indent: None,
ui_mode: None,
framework_ui_modes: Vec::new(),
framework_files: Vec::new(),
formatter: None,
scratch_template: None,
tree_sitter: None,
+1 -1
Переглянути файл
@@ -69,7 +69,7 @@ pub struct TransferError {
}
impl TransferReport {
fn fail(&mut self, path: impl Into<String>, e: IdeError) {
pub(crate) fn fail(&mut self, path: impl Into<String>, e: IdeError) {
self.errors.push(TransferError { path: path.into(), message: e.message });
}
}
+131
Переглянути файл
@@ -286,6 +286,70 @@ impl RemoteService {
r
}
/// Remote Host › Download to…: `remotes` (folders recursively) into the local folder `dir`,
/// each under its own name, without the server path above it. Existing files are replaced.
pub async fn download_into(
&self,
connection: &FsConnection,
remotes: &[String],
dir: &Path,
cancel: &CancellationToken,
progress: &mut (dyn FnMut(&str, usize, usize) + Send),
) -> IdeResult<TransferReport> {
let fs = self.fs(connection).await?;
let mut report = TransferReport::default();
let mut folders = Vec::new();
let mut jobs = Vec::new();
for remote in remotes {
let Some(top) = download_target(dir, remote, remote) else {
report.skipped += 1;
continue;
};
match fs.stat(remote).await {
Ok(Some(e)) if e.is_dir => {
folders.push(top);
let mut entries = Vec::new();
if let Err(e) = remote_tree(&fs, remote, cancel, &mut entries).await {
report.fail(remote.clone(), e);
}
for entry in entries {
match download_target(dir, remote, &entry.path) {
Some(local) if entry.is_dir => folders.push(local),
Some(local) => jobs.push((entry.path, local)),
None => report.skipped += 1,
}
}
}
Ok(Some(_)) => jobs.push((remote.clone(), top)),
Ok(None) => report.fail(remote.clone(), IdeError::not_found(format!("{remote} does not exist"))),
Err(e) => report.fail(remote.clone(), e),
}
}
for folder in &folders {
if let Err(e) = tokio::fs::create_dir_all(folder).await {
report.fail(folder.display().to_string(), e.into());
}
}
for (i, (remote, local)) in jobs.iter().enumerate() {
if cancel.is_cancelled() {
break;
}
progress(remote, i, jobs.len());
if let Some(parent) = local.parent()
&& let Err(e) = tokio::fs::create_dir_all(parent).await
{
report.fail(remote.clone(), e.into());
continue;
}
match fs.download(remote, local).await {
Ok(()) => report.files += 1,
Err(e) => report.fail(remote.clone(), e),
}
}
fs.close().await;
Ok(report)
}
// --- save hook -----------------------------------------------------------------
/// After a document save: uploads a remote file's copy, or (automatic upload)
@@ -497,3 +561,70 @@ fn cache_key(connection: &FsConnection) -> String {
FsConnection::Sftp { ssh } => format!("sftp-{ssh}"),
}
}
/// Files and folders under remote folder `dir` (symlinked folders are not followed).
async fn remote_tree(
fs: &RemoteFs,
dir: &str,
cancel: &CancellationToken,
out: &mut Vec<RemoteEntry>,
) -> IdeResult<()> {
for e in fs.list(dir).await? {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let descend = e.is_dir && !e.is_symlink;
let path = e.path.clone();
out.push(e);
if descend {
Box::pin(remote_tree(fs, &path, cancel, out)).await?;
}
}
Ok(())
}
/// Where remote `path`, inside the downloaded `selected` file or folder, lands under local `dir`:
/// `dir/<name of selected>/<rest>`. `None` for a name that would leave `dir`.
fn download_target(dir: &Path, selected: &str, path: &str) -> Option<PathBuf> {
let selected = selected.trim_end_matches('/');
let name = selected.rsplit('/').next()?;
let rest = path.trim_end_matches('/').strip_prefix(selected)?;
if !rest.is_empty() && !rest.starts_with('/') {
return None;
}
let mut local = dir.to_path_buf();
for part in std::iter::once(name).chain(rest.split('/').filter(|p| !p.is_empty())) {
if matches!(part, "" | "." | "..") || part.contains('\\') || (cfg!(windows) && part.contains(':')) {
return None;
}
local.push(part);
}
Some(local)
}
#[cfg(test)]
mod download_into_tests {
use super::*;
#[test]
fn downloads_keep_only_the_selected_name() {
let dir = Path::new("/p/project");
let selected = "/home/u/www/site/current/drush";
assert_eq!(download_target(dir, selected, selected), Some(dir.join("drush")));
assert_eq!(download_target(dir, "/home/u/www/site/current/drush/", selected), Some(dir.join("drush")));
assert_eq!(
download_target(dir, selected, "/home/u/www/site/current/drush/src/a.php"),
Some(dir.join("drush").join("src").join("a.php"))
);
assert_eq!(download_target(dir, "/srv/app.log", "/srv/app.log"), Some(dir.join("app.log")));
}
#[test]
fn names_that_would_leave_the_folder_are_refused() {
let dir = Path::new("/p/project");
assert_eq!(download_target(dir, "/srv/a", "/srv/a/../../etc/passwd"), None);
assert_eq!(download_target(dir, "/srv/a", "/srv/ab/x"), None, "a sibling with the same prefix");
assert_eq!(download_target(dir, "/", "/etc"), None, "the root has no name");
assert_eq!(download_target(dir, "/srv/..", "/srv/.."), None);
}
}
+34
Переглянути файл
@@ -319,3 +319,37 @@ async fn browser_mappings_resolve_symlinks_and_download_to_the_project() {
assert!(report.errors.is_empty());
assert_eq!(std::fs::read_to_string(local).unwrap(), "from the server");
}
#[tokio::test]
async fn download_into_copies_only_the_selected_items() {
let tmp = tempfile::tempdir().unwrap();
let server = tmp.path().join("srv/www/site/current");
let project = tmp.path().join("project");
std::fs::create_dir_all(server.join("drush/sub")).unwrap();
std::fs::create_dir_all(server.join("drush/empty")).unwrap();
std::fs::create_dir_all(&project).unwrap();
std::fs::write(server.join("drush/a.txt"), "a").unwrap();
std::fs::write(server.join("drush/sub/b.txt"), "b").unwrap();
std::fs::write(server.join("index.php"), "php").unwrap();
std::fs::write(project.join("index.php"), "old").unwrap();
let service = service(tmp.path());
let remotes = [server.join("drush"), server.join("index.php")].map(|p| p.to_str().unwrap().to_owned());
let report = service
.download_into(&FsConnection::Local, &remotes, &project, &CancellationToken::new(), &mut |_, _, _| {})
.await
.unwrap();
assert_eq!(report.files, 3, "{report:?}");
assert!(report.errors.is_empty(), "{report:?}");
assert_eq!(std::fs::read_to_string(project.join("drush/a.txt")).unwrap(), "a");
assert_eq!(std::fs::read_to_string(project.join("drush/sub/b.txt")).unwrap(), "b");
assert!(project.join("drush/empty").is_dir());
assert_eq!(std::fs::read_to_string(project.join("index.php")).unwrap(), "php");
assert!(!project.join("srv").exists() && !project.join("www").exists());
let missing = [server.join("gone").to_str().unwrap().to_owned()];
let report = service
.download_into(&FsConnection::Local, &missing, &project, &CancellationToken::new(), &mut |_, _, _| {})
.await
.unwrap();
assert_eq!((report.files, report.errors.len()), (0, 1), "{report:?}");
}
+1 -1
Переглянути файл
@@ -1541,7 +1541,7 @@ pub fn usages_bundle(data_dir: &Path) -> Option<PathBuf> {
install_bundle(
data_dir,
"java-usages",
"1.12.0",
"1.13.0",
include_bytes!("../../../java/java-usages/openidea-java-usages.jar"),
)
}
+142 -21
Переглянути файл
@@ -5,6 +5,7 @@ use crate::{ChangeKind, FileStatus, Repo};
use gix::status::index_worktree::Item as WtItem;
use gix::status::plumbing::index_as_worktree::{Change as WtChange, EntryStatus};
use ide_core::IdeResult;
use ide_core::ignored::IgnoredNames;
use std::collections::{BTreeMap, HashSet};
#[derive(Default)]
@@ -15,10 +16,43 @@ struct Entry {
}
pub(crate) fn status(r: &Repo) -> IdeResult<Vec<FileStatus>> {
let ignored = r.ignored.read().unwrap_or_else(|p| p.into_inner()).clone();
// gitoxide stops at the first IO error (a directory it may not read), where git warns
// and lists the rest: git answers then.
let (map, conflicts) = gix_status(r, &ignored).or_else(|e| {
tracing::debug!("status via gitoxide failed, asking git: {}", e.message);
cli_status(r, &ignored)
})?;
Ok(map
.into_iter()
.filter(|(_, e)| e.staged.is_some() || e.unstaged.is_some())
// Ignored names mean "the IDE treats these as nonexistent": hide their tracked
// changes too, not just untracked hits above. Conflicts stay visible so merges
// cannot stall on a file the user cannot see.
.filter(|(rel, _)| conflicts.contains(rel) || !ignored.is_ignored_path(rel))
.map(|(rel, mut e)| {
if conflicts.contains(&rel) {
e.staged = None;
e.renamed_from = None;
e.unstaged = Some(ChangeKind::Conflicted);
}
FileStatus {
path: r.abs_path(&rel).into(),
rel_path: rel,
staged: e.staged,
unstaged: e.unstaged,
renamed_from: e.renamed_from,
}
})
.collect())
}
type Entries = (BTreeMap<String, Entry>, HashSet<String>);
fn gix_status(r: &Repo, ignored: &IgnoredNames) -> IdeResult<Entries> {
let repo = r.gix()?;
let mut map: BTreeMap<String, Entry> = BTreeMap::new();
let mut conflicts: HashSet<String> = HashSet::new();
let ignored = r.ignored.read().unwrap_or_else(|p| p.into_inner()).clone();
// Stamp before status can read the index: stamping after the load could capture
// an external `git switch`, `add` or commit's new index and allow the stale
@@ -118,28 +152,60 @@ pub(crate) fn status(r: &Repo) -> IdeResult<Vec<FileStatus>> {
}
}
Ok(map
.into_iter()
.filter(|(_, e)| e.staged.is_some() || e.unstaged.is_some())
// Ignored names mean "the IDE treats these as nonexistent": hide their tracked
// changes too, not just untracked hits above. Conflicts stay visible so merges
// cannot stall on a file the user cannot see.
.filter(|(rel, _)| conflicts.contains(rel) || !ignored.is_ignored_path(rel))
.map(|(rel, mut e)| {
if conflicts.contains(&rel) {
e.staged = None;
e.renamed_from = None;
e.unstaged = Some(ChangeKind::Conflicted);
Ok((map, conflicts))
}
/// `git status --porcelain=v2`: what git lists, unreadable directories skipped.
fn cli_status(r: &Repo, ignored: &IgnoredNames) -> IdeResult<Entries> {
let out = crate::cli::run(r.root(), &["status", "--porcelain=v2", "-z", "--untracked-files=all"], None)?;
Ok(parse_porcelain_v2(&String::from_utf8_lossy(&out.stdout), ignored))
}
fn parse_porcelain_v2(out: &str, ignored: &IgnoredNames) -> Entries {
let kind = |c: u8| match c {
b'M' | b'T' => Some(ChangeKind::Modified),
b'A' | b'C' => Some(ChangeKind::Added),
b'D' => Some(ChangeKind::Deleted),
b'R' => Some(ChangeKind::Renamed),
_ => None,
};
let mut map: BTreeMap<String, Entry> = BTreeMap::new();
let mut conflicts: HashSet<String> = HashSet::new();
let mut records = out.split('\0');
while let Some(record) = records.next() {
let (tag, rest) = record.split_once(' ').unwrap_or((record, ""));
match tag {
// `1 XY sub mH mI mW hH hI path`, `2 … Xscore path` + NUL + the original path.
"1" | "2" => {
let fields = if tag == "1" { 8 } else { 9 };
let mut parts = rest.splitn(fields, ' ');
let xy = parts.next().unwrap_or("..").as_bytes();
let Some(path) = parts.nth(fields - 2) else { continue };
let e = map.entry(path.to_owned()).or_default();
e.staged = xy.first().copied().and_then(kind);
// Intent to add shows as `.A`; worktree renames are off, as with gitoxide.
e.unstaged = xy.get(1).copied().and_then(kind).filter(|k| *k != ChangeKind::Renamed);
if tag == "2" {
let from = records.next().unwrap_or_default();
if e.staged == Some(ChangeKind::Renamed) {
e.renamed_from = Some(from.to_owned());
}
}
}
FileStatus {
path: r.abs_path(&rel).into(),
rel_path: rel,
staged: e.staged,
unstaged: e.unstaged,
renamed_from: e.renamed_from,
// `u XY sub m1 m2 m3 mW h1 h2 h3 path`
"u" => {
if let Some(path) = rest.splitn(10, ' ').nth(9) {
conflicts.insert(path.to_owned());
map.entry(path.to_owned()).or_default().unstaged = Some(ChangeKind::Conflicted);
}
}
})
.collect())
"?" if !rest.is_empty() && !ignored.is_ignored_path(rest) => {
map.entry(rest.to_owned()).or_default().unstaged = Some(ChangeKind::Untracked);
}
_ => {}
}
}
(map, conflicts)
}
/// Lets a test land an external index write in the window between the status run
@@ -227,4 +293,59 @@ mod tests {
let staged = Command::new("git").current_dir(dir).args(["diff", "--cached", "--stat"]).output().unwrap();
assert!(staged.stdout.is_empty(), "{}", String::from_utf8_lossy(&staged.stdout));
}
/// git's porcelain answer (used when gitoxide can't read the tree) matches gitoxide's.
#[test]
fn git_and_gitoxide_report_the_same_status() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"]);
git(dir, &["config", "user.name", "Test User"]);
git(dir, &["config", "user.email", "[email protected]"]);
git(dir, &["config", "commit.gpgsign", "false"]);
for f in ["modified.txt", "staged.txt", "deleted.txt", "staged_del.txt", "both.txt", "conflict.txt"] {
std::fs::write(dir.join(f), "a\n").unwrap();
}
std::fs::write(dir.join("rename_me.txt"), "some longer content\nthat is stable\nfor rename detection\n")
.unwrap();
commit_all(dir, "base");
git(dir, &["checkout", "-q", "-b", "side"]);
std::fs::write(dir.join("conflict.txt"), "side\n").unwrap();
commit_all(dir, "side");
git(dir, &["checkout", "-q", "main"]);
std::fs::write(dir.join("conflict.txt"), "main\n").unwrap();
commit_all(dir, "main");
let merge = Command::new("git").current_dir(dir).args(["merge", "-q", "side"]).output().unwrap();
assert!(!merge.status.success());
std::fs::write(dir.join("modified.txt"), "b\n").unwrap();
std::fs::write(dir.join("staged.txt"), "b\n").unwrap();
git(dir, &["add", "staged.txt"]);
std::fs::remove_file(dir.join("deleted.txt")).unwrap();
git(dir, &["rm", "-q", "staged_del.txt"]);
std::fs::write(dir.join("both.txt"), "b\n").unwrap();
git(dir, &["add", "both.txt"]);
std::fs::write(dir.join("both.txt"), "c\n").unwrap();
git(dir, &["mv", "rename_me.txt", "renamed with space.txt"]);
std::fs::create_dir_all(dir.join("new dir")).unwrap();
std::fs::write(dir.join("new dir/untracked.txt"), "u\n").unwrap();
std::fs::write(dir.join("intent.txt"), "i\n").unwrap();
git(dir, &["add", "-N", "intent.txt"]);
let repo = Repo::discover(dir).unwrap().expect("repository found");
let ignored = IgnoredNames::default();
let show = |(map, conflicts): Entries| {
let mut c: Vec<_> = conflicts.into_iter().collect();
c.sort();
let m: Vec<_> = map
.into_iter()
.map(|(k, e)| format!("{k}: {:?} {:?} {:?}", e.staged, e.unstaged, e.renamed_from))
.collect();
(m, c)
};
let from_git = show(cli_status(&repo, &ignored).unwrap());
assert_eq!(from_git, show(gix_status(&repo, &ignored).unwrap()));
assert!(from_git.0.iter().any(|l| l.starts_with("renamed with space.txt: Some(Renamed)")), "{from_git:?}");
assert_eq!(from_git.1, ["conflict.txt"]);
}
}
+21
Переглянути файл
@@ -2015,3 +2015,24 @@ fn reword_refuses_commits_outside_the_current_branch() {
fx.git(&["checkout", "-q", "main"]);
assert!(fx.repo().reword(&side, "other").is_err());
}
#[cfg(unix)]
#[test]
fn status_skips_a_directory_it_cannot_read() {
// `git status` warns "could not open directory: Permission denied" and lists the rest.
use std::os::unix::fs::PermissionsExt;
let fx = Fixture::new();
fx.write("a.txt", "a\n");
fx.commit_all("init");
fx.write("a.txt", "b\n");
fx.write("new.txt", "n\n");
let locked = fx.path("locked");
std::fs::create_dir(&locked).unwrap();
std::fs::write(locked.join("inside.txt"), "x").unwrap();
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
let st = fx.repo().status();
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
let st = st.unwrap();
assert_eq!(status_of(&st, "a.txt").unstaged, Some(ChangeKind::Modified));
assert_eq!(status_of(&st, "new.txt").unstaged, Some(ChangeKind::Untracked));
}
+79 -1
Переглянути файл
@@ -359,6 +359,32 @@ fn sync_error_is_tolerable(e: &std::io::Error) -> bool {
false
}
/// On Windows a file can't be replaced while another process has it open without
/// delete sharing: an antivirus or indexer scan, a sync client, a program reading
/// its config. Those let go within moments, so the replacing rename is retried briefly.
const RENAME_ATTEMPTS: u32 = 10;
const RENAME_PAUSE: std::time::Duration = std::time::Duration::from_millis(100);
/// Access denied (os error 5), sharing and lock violations (32, 33) on Windows.
fn held_by_another_process(e: &std::io::Error) -> bool {
cfg!(windows) && matches!(e.raw_os_error(), Some(5 | 32 | 33))
}
fn retrying(
attempts: u32,
pause: std::time::Duration,
transient: impl Fn(&std::io::Error) -> bool,
mut op: impl FnMut() -> std::io::Result<()>,
) -> std::io::Result<()> {
for _ in 1..attempts {
match op() {
Err(e) if transient(&e) => std::thread::sleep(pause),
r => return r,
}
}
op()
}
/// Writes `bytes` to `path` atomically: temp file in the same directory, fsync,
/// rename over the target. Preserves the permissions of an existing file and
/// writes through symlinks. Falls back to an in-place write when the directory
@@ -441,7 +467,7 @@ fn write_atomic_impl(path: &Path, bytes: &[u8], allow_in_place: bool, private: b
fs::set_permissions(&tmp, permissions)?;
}
drop(file);
fs::rename(&tmp, &target)
retrying(RENAME_ATTEMPTS, RENAME_PAUSE, held_by_another_process, || fs::rename(&tmp, &target))
})();
if let Err(e) = result {
let _ = fs::remove_file(&tmp);
@@ -539,6 +565,58 @@ mod tests {
assert_eq!(fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o640);
}
#[test]
fn replacing_is_retried_only_while_a_file_is_held() {
use std::io::{Error, ErrorKind};
let in_use = || Error::from_raw_os_error(32);
let transient = |e: &Error| e.raw_os_error() == Some(32);
let zero = std::time::Duration::ZERO;
for (fails, outcome) in [(0, true), (3, true), (RENAME_ATTEMPTS - 1, true), (RENAME_ATTEMPTS, false)] {
let mut calls = 0;
let r = retrying(RENAME_ATTEMPTS, zero, transient, || {
calls += 1;
if calls <= fails { Err(in_use()) } else { Ok(()) }
});
assert_eq!(r.is_ok(), outcome, "{fails} failures");
assert_eq!(calls, (fails + 1).min(RENAME_ATTEMPTS));
}
let mut calls = 0;
let r = retrying(RENAME_ATTEMPTS, zero, transient, || {
calls += 1;
Err(Error::from(ErrorKind::NotFound))
});
assert_eq!(r.unwrap_err().kind(), ErrorKind::NotFound);
assert_eq!(calls, 1, "other errors fail at once");
for code in [5, 32, 33] {
assert_eq!(held_by_another_process(&Error::from_raw_os_error(code)), cfg!(windows), "os error {code}");
}
assert!(!held_by_another_process(&Error::from(ErrorKind::NotFound)));
}
#[cfg(windows)]
#[test]
fn saving_waits_for_a_windows_file_lock_to_be_released() {
use std::os::windows::fs::OpenOptionsExt;
use windows_sys::Win32::Storage::FileSystem::{FILE_SHARE_READ, FILE_SHARE_WRITE};
// No sharing at all (os error 32), and readers that don't allow deletion (os error 5).
for share in [0, FILE_SHARE_READ | FILE_SHARE_WRITE] {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.json");
fs::write(&path, b"old").unwrap();
let held = fs::OpenOptions::new().read(true).share_mode(share).open(&path).unwrap();
let release = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(300));
drop(held);
});
let result = write_atomic(&path, b"new");
release.join().unwrap();
result.unwrap();
assert_eq!(fs::read(&path).unwrap(), b"new", "share mode {share}");
assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1, "no temp file left behind");
}
}
#[cfg(unix)]
#[test]
fn strict_write_does_not_truncate_when_sibling_creation_is_denied() {
+69 -6
Переглянути файл
@@ -25,6 +25,11 @@ pub struct DocumentInfo {
pub line_ending: LineEnding,
pub read_only: bool,
pub dirty: bool,
/// Whether "Reload with Encoding" can re-decode this document's source
/// bytes: true for files and virtual documents the backend can re-read
/// (archive entries, JDT sources with an editable attachment encoding,
/// Git revisions); false for server-generated content (decompiled code).
pub can_reload_encoding: bool,
/// Larger than `ide_vfs::LARGE_FILE_THRESHOLD`: language servers, syntax
/// structure and folding are disabled; the editor should drop heavy features.
#[specta(optional)]
@@ -143,6 +148,9 @@ struct DocState {
observed: bool,
binary: bool,
dirty: bool,
can_reload_encoding: bool,
/// The user picked `encoding`; reloads keep it instead of detecting again.
encoding_chosen: bool,
/// Disk state we last loaded or wrote; changes matching it are our own.
disk_mtime: f64,
disk_len: u64,
@@ -339,6 +347,7 @@ impl Documents {
line_ending: st.doc.line_ending(),
read_only: st.read_only,
dirty: st.dirty,
can_reload_encoding: st.can_reload_encoding,
large: st.large,
editorconfig: st.editorconfig.clone(),
uri: st.uri.clone(),
@@ -412,6 +421,8 @@ impl Documents {
observed,
binary: loaded.binary,
dirty: false,
can_reload_encoding: true,
encoding_chosen: false,
disk_mtime: loaded.mtime_ms,
disk_len: disk_len(path),
closed: false,
@@ -449,8 +460,19 @@ impl Documents {
/// Opens (or re-references) a read-only virtual document: contents a
/// language server provides for a non-`file:` URI. It is keyed by the URI,
/// never touches the disk and cannot be edited or saved.
pub fn open_virtual(&self, uri: &str, language: LanguageId, text: &str) -> IdeResult<DocumentInfo> {
/// never touches the disk and cannot be edited or saved. `encoding` /
/// `has_bom` describe how the content was decoded (not a guess);
/// `can_reload_encoding` tells whether [`Documents::replace_virtual`] can
/// re-decode it ("Reload with Encoding").
pub fn open_virtual(
&self,
uri: &str,
language: LanguageId,
text: &str,
encoding: &str,
has_bom: bool,
can_reload_encoding: bool,
) -> IdeResult<DocumentInfo> {
let _operation = self.operations.lock();
let key = PathBuf::from(uri);
if let Some(info) = self.reopen(&key) {
@@ -462,8 +484,8 @@ impl Documents {
doc: Document::new(text),
language,
ui_mode: None,
encoding: "utf-8".into(),
has_bom: false,
encoding: encoding.into(),
has_bom,
native_to_ascii: false,
read_only: true,
large,
@@ -471,6 +493,8 @@ impl Documents {
observed: true,
binary: false,
dirty: false,
can_reload_encoding,
encoding_chosen: false,
disk_mtime: 0.0,
disk_len: 0,
closed: false,
@@ -846,6 +870,7 @@ impl Documents {
let loaded = Self::load_as(&st.path, encoding, enabled)?;
st.native_to_ascii = enabled;
st.encoding = loaded.encoding.clone();
st.encoding_chosen = true;
st.has_bom = loaded.has_bom;
st.disk_mtime = loaded.mtime_ms;
st.disk_len = disk_len(&st.path);
@@ -864,12 +889,45 @@ impl Documents {
ide_vfs::encode(&st.doc.text_for_save(), encoding, has_bom)?;
}
st.encoding = encoding.to_owned();
st.encoding_chosen = true;
st.has_bom = has_bom;
self.set_dirty(doc, st, true);
Ok(DocumentEncoding { encoding: st.encoding.clone(), has_bom: st.has_bom })
})
}
/// The URI of a virtual document, or `None` for a file-backed one.
pub fn virtual_uri(&self, doc: DocId) -> IdeResult<Option<String>> {
self.with_doc(doc, |st| Ok(st.uri.clone()))
}
/// Re-decoded content of a virtual document ("Reload with Encoding"):
/// replaces the text and the encoding label, keeping the document
/// read-only and clean. The caller re-reads the origin (archive entry,
/// language server, Git blob) with the chosen encoding first.
pub fn replace_virtual(
&self,
doc: DocId,
text: &str,
encoding: &str,
has_bom: bool,
) -> IdeResult<DocumentEncoding> {
self.with_doc(doc, |st| {
if st.uri.is_none() {
return Err(IdeError::invalid("not a virtual document"));
}
ide_vfs::encode("", encoding, false)?;
st.doc.set_line_ending(LineEnding::detect(text));
self.replace_locked(doc, st, text)?;
st.encoding = encoding.to_owned();
st.has_bom = has_bom;
st.large = text.len() as u64 > ide_vfs::LARGE_FILE_THRESHOLD;
// Reloading is not an edit: the buffer matches its source again.
self.set_dirty(doc, st, false);
Ok(DocumentEncoding { encoding: st.encoding.clone(), has_bom: st.has_bom })
})
}
/// Adds or removes the BOM on a UTF-8 document; the change is written on save.
pub fn set_bom(&self, doc: DocId, has_bom: bool) -> IdeResult<DocumentEncoding> {
self.with_doc(doc, |st| {
@@ -968,8 +1026,13 @@ impl Documents {
Self::load_as(&st.path, &st.encoding, st.native_to_ascii)?
} else {
let defaults = self.file_defaults();
let loaded = Self::load_with_defaults(&st.path, &defaults)?;
st.native_to_ascii = defaults.native_to_ascii_for(&st.path);
let native_to_ascii = defaults.native_to_ascii_for(&st.path);
let loaded = if st.encoding_chosen {
Self::load_as(&st.path, &st.encoding, native_to_ascii)?
} else {
Self::load_with_defaults(&st.path, &defaults)?
};
st.native_to_ascii = native_to_ascii;
loaded
};
st.disk_mtime = loaded.mtime_ms;
+6 -1
Переглянути файл
@@ -748,6 +748,7 @@ impl Documents {
let result = self.with_doc(*id, |state| {
self.replace_locked(*id, state, &before.doc.text())?;
state.encoding = before.encoding.clone();
state.encoding_chosen = before.encoding_chosen;
state.has_bom = before.has_bom;
state.doc.set_line_ending(before.doc.line_ending());
state.disk_mtime = fs::metadata(&state.path).map(|m| ide_vfs::mtime_ms(&m)).unwrap_or(0.0);
@@ -789,7 +790,11 @@ impl Documents {
return Err(stale(path));
}
let defaults = self.file_defaults();
let loaded = ide_vfs::load_text_with_fallback(path, defaults.fallback_for(path))?;
let loaded = if state.encoding_chosen {
Self::load_as(path, &state.encoding, state.native_to_ascii)?
} else {
ide_vfs::load_text_with_fallback(path, defaults.fallback_for(path))?
};
self.replace_locked(id, state, &loaded.text)?;
state.encoding = loaded.encoding;
state.has_bom = loaded.has_bom;
+106 -2
Переглянути файл
@@ -505,6 +505,48 @@ fn reload_and_convert_encoding() {
assert!(f.docs.set_encoding(info.id, "gbk", true).is_err());
}
#[test]
fn external_changes_keep_a_chosen_encoding() {
// Valid UTF-8 bytes, so detection alone would read them as UTF-8.
let (first, changed) = (b"caf\xC3\xA9".as_slice(), b"caf\xC3\xA9 \xC3\xA8".as_slice());
for (encoding, reload) in [("windows-1252", true), ("gbk", true), ("windows-1252", false)] {
let f = fixture();
let path = f.root.join("legacy.txt");
fs::write(&path, first).unwrap();
let info = f.docs.open(&path).unwrap();
assert_eq!(info.encoding, "utf-8");
if reload {
f.docs.set_encoding(info.id, encoding, true).unwrap();
} else {
// Convert, then save: the file is now in the chosen encoding.
f.docs.replace_all(info.id, "café").unwrap();
f.docs.set_encoding(info.id, encoding, false).unwrap();
f.docs.save(info.id).unwrap();
}
let expected =
|bytes: &[u8]| encoding_rs::Encoding::for_label(encoding.as_bytes()).unwrap().decode(bytes).0.into_owned();
let changed = if reload { changed.to_vec() } else { ide_vfs::encode("café è", encoding, false).unwrap() };
fs::write(&path, &changed).unwrap();
f.docs.on_disk_changed(std::slice::from_ref(&path));
let snapshot = f.docs.snapshot(info.id).unwrap();
assert_eq!(snapshot.text, expected(&changed), "{encoding} reload={reload}");
assert_eq!(snapshot.encoding, encoding, "{encoding} reload={reload}");
assert!(!snapshot.dirty);
}
}
#[test]
fn external_changes_redetect_an_encoding_the_user_never_chose() {
let f = fixture();
let path = f.root.join("plain.txt");
fs::write(&path, "plain").unwrap();
let info = f.docs.open(&path).unwrap();
fs::write(&path, ide_vfs::encode("wide", "utf-16le", true).unwrap()).unwrap();
f.docs.on_disk_changed(std::slice::from_ref(&path));
let snapshot = f.docs.snapshot(info.id).unwrap();
assert_eq!((snapshot.text.as_str(), snapshot.encoding.as_str()), ("wide", "utf-16le"));
}
#[test]
fn utf8_bom_is_added_and_removed_on_save() {
let f = fixture();
@@ -656,14 +698,14 @@ fn editorconfig_applies_on_open_and_save() {
fn virtual_documents_are_read_only_and_keyed_by_uri() {
let f = fixture();
let uri = "jdt://contents/a.jar/p/C.class?=x";
let info = f.docs.open_virtual(uri, LanguageId::new("java"), "class C {}\n").unwrap();
let info = f.docs.open_virtual(uri, LanguageId::new("java"), "class C {}\n", "utf-8", false, true).unwrap();
assert_eq!(info.uri.as_deref(), Some(uri));
assert_eq!(info.path, PathBuf::from(uri));
assert!(info.read_only && !info.dirty);
assert_eq!(info.language.as_str(), "java");
// Re-opening shares the document; edits and saves never reach a file.
let again = f.docs.open_virtual(uri, LanguageId::new("java"), "ignored").unwrap();
let again = f.docs.open_virtual(uri, LanguageId::new("java"), "ignored", "utf-8", false, true).unwrap();
assert_eq!(again.id, info.id);
assert_eq!(again.text, "class C {}\n");
let err = f.docs.edit(info.id, info.version, change(0, 0, "x")).unwrap_err();
@@ -677,6 +719,68 @@ fn virtual_documents_are_read_only_and_keyed_by_uri() {
assert_eq!(*f.rec.log.lock(), vec!["open v0 java".to_owned(), "close".to_owned()]);
}
#[test]
fn virtual_documents_carry_their_encoding_and_reload_capability() {
let f = fixture();
// Archive sources decoded by the backend report the charset actually used.
let uri = "jar:///m2/lib-1.0-sources.jar!/p/C.java";
let info = f.docs.open_virtual(uri, LanguageId::new("java"), "中文", "gbk", false, true).unwrap();
assert_eq!((info.encoding.as_str(), info.has_bom, info.can_reload_encoding), ("gbk", false, true));
let bom =
f.docs.open_virtual("jar:///m2/b.jar!/p/B.java", LanguageId::new("java"), "x", "utf-8", true, true).unwrap();
assert!(bom.has_bom);
// Server-generated content (decompiled) cannot be re-decoded.
let decompiled = f
.docs
.open_virtual(
"csharp:/p/App.csproj/decompiled/Lib.cs",
LanguageId::new("csharp"),
"class Lib {}",
"utf-8",
false,
false,
)
.unwrap();
assert!(!decompiled.can_reload_encoding);
// File-backed documents can always be re-read from disk.
let path = f.root.join("f.txt");
fs::write(&path, "x").unwrap();
assert!(f.docs.open(&path).unwrap().can_reload_encoding);
}
#[test]
fn virtual_documents_reload_with_another_encoding_without_becoming_editable() {
let f = fixture();
let uri = "jdt://contents/a.jar/p/C.class?=x";
let info = f.docs.open_virtual(uri, LanguageId::new("java"), "涓枃", "utf-8", false, true).unwrap();
let encoding = f.docs.replace_virtual(info.id, "中文", "gbk", false).unwrap();
assert_eq!((encoding.encoding.as_str(), encoding.has_bom), ("gbk", false));
let snap = f.docs.snapshot(info.id).unwrap();
assert_eq!(snap.text, "中文");
assert_eq!(snap.encoding, "gbk");
assert!(snap.read_only && !snap.dirty, "reloading is not an edit");
assert!(snap.version > info.version);
assert_eq!(f.docs.virtual_uri(info.id).unwrap().as_deref(), Some(uri));
// The replacement reached observers and the event stream (editors resync).
assert!(f.rec.log.lock().iter().any(|l| l.starts_with("change v") && l.contains("中文")));
assert!(f.events.lock().iter().any(|e| matches!(e, IdeEvent::DocPatched { doc, .. } if *doc == info.id)));
// Read-only protection is unchanged.
assert_eq!(f.docs.edit(info.id, snap.version, change(0, 0, "x")).unwrap_err().kind, ErrorKind::PermissionDenied);
assert!(f.docs.set_encoding(info.id, "utf-8", false).is_err());
assert!(f.docs.set_encoding(info.id, "gbk", true).is_err(), "the op layer routes virtual reloads");
assert!(f.docs.set_bom(info.id, true).is_err());
f.docs.save(info.id).unwrap();
// Unknown encodings are refused; file-backed documents are not virtual.
assert!(f.docs.replace_virtual(info.id, "x", "not-an-encoding", false).is_err());
let path = f.root.join("f.txt");
fs::write(&path, "x").unwrap();
let file = f.docs.open(&path).unwrap();
assert!(f.docs.replace_virtual(file.id, "y", "utf-8", false).is_err());
assert_eq!(f.docs.virtual_uri(file.id).unwrap(), None);
}
#[test]
fn toggling_the_writable_bit_updates_the_open_document() {
let f = fixture();
+27
Переглянути файл
@@ -162,6 +162,21 @@ fn undo_id(events: &Mutex<Vec<IdeEvent>>) -> u32 {
.expect("successful transaction has one undo record")
}
#[test]
fn undo_keeps_the_encoding_chosen_for_a_file_opened_after_the_edit() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("legacy.txt");
std::fs::write(&path, b"abc\xC3\xA9").unwrap();
let (docs, events) = recorded_documents();
docs.apply_workspace_edit(&WorkspaceEdit { files: vec![edit(&path, "new")], ..Default::default() }).unwrap();
let info = docs.open(&path).unwrap();
docs.set_encoding(info.id, "windows-1252", true).unwrap();
assert_eq!(docs.snapshot(info.id).unwrap().text, "newé");
docs.undo_workspace_edit(undo_id(&events)).unwrap();
let snapshot = docs.snapshot(info.id).unwrap();
assert_eq!((snapshot.text.as_str(), snapshot.encoding.as_str()), ("abcé", "windows-1252"));
}
#[test]
fn stale_document_version_rejects_the_whole_edit() {
let tmp = tempfile::tempdir().unwrap();
@@ -515,6 +530,18 @@ fn duplicate_edits_keep_versions_and_apply_one_replacement() {
assert_eq!(std::fs::read_to_string(path).unwrap(), "new_long");
}
#[test]
fn repeated_inserts_at_one_position_all_apply_once_per_entry() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("main.ts");
std::fs::write(&path, "f(").unwrap();
let docs = documents();
let close = TextEdit { range: Range::new(Pos::new(0, 2), Pos::new(0, 2)), new_text: ")".into() };
let entry = FileEdit::Edit { path: path.clone().into(), version: None, edits: vec![close.clone(), close] };
docs.apply_workspace_edit(&WorkspaceEdit { files: vec![entry.clone(), entry], ..Default::default() }).unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "f())");
}
#[test]
fn undo_accepts_typing_that_was_reverted_to_the_saved_refactoring() {
let tmp = tempfile::tempdir().unwrap();
+5 -3
Переглянути файл
@@ -11,9 +11,10 @@ Each open file gets its language from the definitions, checked in this order:
1. A per-file **Override File Type** (in the context menu of a file in the project tree or of an editor tab).
2. An exact file name (`Dockerfile`, `go.mod`) or a file name pattern.
3. The extension, longest match first (`.html.erb` before `.erb`).
4. The first line: a shebang such as `#!/usr/bin/env python3`, or a pattern such as `<?php`.
5. Otherwise the file is plain text.
3. A framework's folder: an `.html` or `.txt` file in a `templates` folder with a `manage.py` above it is a Django template.
4. The extension, longest match first (`.html.erb` before `.erb`).
5. The first line: a shebang such as `#!/usr/bin/env python3`, or a pattern such as `<?php`.
6. Otherwise the file is plain text.
You can add or remove file name patterns per language in **Settings › Editor › File Types** (**Recognized File Types**).
@@ -106,6 +107,7 @@ The table lists every built-in language definition. Server and debugger names in
| D | `d` | `.d`, `.di` | Yes | — | serve-d | — |
| Dart | `dart` | `.dart` | Yes | Yes | dart | dart |
| Diff | `diff` | `.diff`, `.patch`, `.rej` | Yes | — | — | — |
| Django Template | `django-html` | `.djhtml`, `templates/*.html`, `*.txt` | Yes | Yes | **vscode-html-language-server** | — |
| Dockerfile | `dockerfile` | `.dockerfile`, `.containerfile` | Yes | — | **dockerfile-language-server** | — |
| Elixir | `elixir` | `.ex`, `.exs` | Yes | Yes | **elixir-ls** | **elixir-ls-debugger** |
| ERB | `erb` | `.erb`, `.html.erb`, `.rhtml`, `.js.erb`, `.text.erb`, … | Yes | — | — | — |
+6
Переглянути файл
@@ -125,6 +125,12 @@ See [Debugging](/en/guide/debugging) for breakpoints, stepping and the debugger
Django is the only framework with dedicated support: the `manage.py runserver` and `manage.py test` configurations, the **Django tests** configuration type (target, settings module, options), running single `TestCase` tests from the gutter, and Django mode in the debugger. Other frameworks (Flask, FastAPI and so on) run as ordinary scripts or console scripts.
### Django templates
The HTML and text files in a `templates` folder of a Django project (one with a `manage.py` above it) open as **Django Template** files: the template tags, variables, filters and comments are highlighted on top of the HTML, and block tags (`{% block %}`, `{% if %}`, `{% for %}`, `{% with %}` and the others of Django and its contrib libraries) fold, indent their content and highlight their end tag. Typing `{%`, `{{` or `{#` closes the pair, finishing a block tag adds its end tag, and `{% end` completes the end tags of the open blocks. **Comment with Line Comment** puts each line in its own `{# … #}`, and **Comment with Block Comment** over several lines uses `{% comment %}`. The HTML language server and Emmet work in the markup between the tags.
An HTML file elsewhere with Django tags shows a banner that switches it to Django Template or keeps it as HTML; either choice is the file's **Override File Type**.
## Limitations
- Only one type checker runs at a time. Ruff runs only in projects with a Ruff configuration.
-1
Переглянути файл
@@ -52,7 +52,6 @@ exclude = ["**/.git", "**/node_modules", "**/target", "**/dist"]
| Editor | General (Auto Import, Appearance, Breadcrumbs, Code Completion, Code Folding, Editor Tabs, Gutter Icons, Inline Completion, Postfix Completion, Rich-Text Copy, Smart Keys, Sticky Lines); Code Editing; Font; Color Scheme; Code Style; Inspections; Natural Languages (Spelling); File and Code Templates; File Encodings; Live Templates; File Types; Inlay Hints; TODO; Vim |
| Plugins | Plugins |
| Version Control | Commit; Confirmation; Directory Mappings; Issue Navigation; Log; Shelf; Git; GitHub |
| Project Structure | Project Structure |
| Build, Execution, Deployment | CMake; Gradle; Maven; Console; Debugger (Data Views, Stepping); Python Debugger; Run/Debug; Run Targets; Deployment (Options) |
| Languages & Frameworks | Language Servers; Go (Build Tags, Go Modules); Java; Markdown; Node.js; PHP; Python Interpreter; Prettier; oxfmt; Ruff; Ruby; Rust; TypeScript |
| Tools | Agents (Chat); Terminal; Actions on Save; External Tools; SSH Configurations; MCP Server |
+5 -3
Переглянути файл
@@ -11,9 +11,10 @@ OpenIDEA 用一个小的定义文件 `languages/<id>.toml` 描述每种语言:
1. 单个文件的 **覆盖文件类型**(在项目树中文件的右键菜单或编辑器标签页的右键菜单里)。
2. 精确文件名(`Dockerfile`、`go.mod`)或文件名模式。
3. 扩展名,优先匹配最长的(`.html.erb` 先于 `.erb`)。
4. 文件第一行:例如 `#!/usr/bin/env python3` 这样的 shebang,或 `<?php` 这类模式。
5. 都不匹配时按纯文本处理。
3. 框架目录:上层目录有 `manage.py` 的 `templates` 目录中的 `.html` 或 `.txt` 文件是 Django 模板。
4. 扩展名,优先匹配最长的(`.html.erb` 先于 `.erb`)。
5. 文件第一行:例如 `#!/usr/bin/env python3` 这样的 shebang,或 `<?php` 这类模式。
6. 都不匹配时按纯文本处理。
在 **设置 › 编辑器 › 文件类型**(**已识别的文件类型**)中可以为某种语言添加或移除文件名模式。
@@ -101,6 +102,7 @@ IDE 安装工具从不需要 `sudo`。工具装在本地数据目录下 IDE 自
| D | `d` | `.d`, `.di` | 是 | — | serve-d | — |
| Dart | `dart` | `.dart` | 是 | 是 | dart | dart |
| Diff | `diff` | `.diff`, `.patch`, `.rej` | 是 | — | — | — |
| Django Template | `django-html` | `.djhtml`, `templates/*.html`, `*.txt` | 是 | 是 | **vscode-html-language-server** | — |
| Dockerfile | `dockerfile` | `.dockerfile`, `.containerfile` | 是 | — | **dockerfile-language-server** | — |
| Elixir | `elixir` | `.ex`, `.exs` | 是 | 是 | **elixir-ls** | **elixir-ls-debugger** |
| ERB | `erb` | `.erb`, `.html.erb`, `.rhtml`, `.js.erb`, `.text.erb`, … | 是 | — | — | — |
+6
Переглянути файл
@@ -125,6 +125,12 @@ Python 调试使用 debugpy。开始调试时如果缺少 debugpy,OpenIDEA 会
目前只有 Django 有专门支持:`manage.py runserver` 和 `manage.py test` 运行配置、**Django 测试** 配置类型(目标、settings 模块、选项)、在左侧栏运行单个 `TestCase` 测试,以及调试器的 Django 模式。Flask、FastAPI 等其他框架按普通脚本或命令行入口运行。
### Django 模板
Django 项目(上层目录有 `manage.py`)中 `templates` 目录里的 HTML 和文本文件以 **Django Template** 打开:模板标签、变量、过滤器和注释在 HTML 之上高亮,块标签(`{% block %}`、`{% if %}`、`{% for %}`、`{% with %}` 以及 Django 和其 contrib 库的其他块标签)可以折叠、缩进内容并高亮对应的结束标签。输入 `{%`、`{{` 或 `{#` 会自动补全成对符号,写完块标签会自动添加结束标签,输入 `{% end` 会补全当前打开块的结束标签。**使用行注释** 会把每一行放进各自的 `{# … #}`,多行的 **使用块注释** 则用 `{% comment %}`。HTML 语言服务器和 Emmet 在标签之间的标记中照常工作。
其他位置含有 Django 标签的 HTML 文件会显示一个横幅,可以切换为 Django Template 或保留为 HTML;两种选择都会成为该文件的 **覆盖文件类型**。
## 限制
- 同一时间只运行一个类型检查器。Ruff 只在有 Ruff 配置的项目中运行。
-1
Переглянути файл
@@ -52,7 +52,6 @@ exclude = ["**/.git", "**/node_modules", "**/target", "**/dist"]
| 编辑器 | 常规(自动导入、外观、痕迹导航、代码补全、代码折叠、编辑器标签页、装订区域图标、内联补全、后缀补全、富文本复制、智能键、粘滞行);代码编辑;字体;配色方案;代码样式;检查;自然语言(拼写);文件和代码模板;文件编码;实时模板;文件类型;嵌入提示;TODO;Vim |
| 插件 | 插件 |
| 版本控制 | 提交;确认;目录映射;问题导航;日志;搁置;Git;GitHub |
| 项目结构 | 项目结构 |
| 构建、执行、部署 | CMake;Gradle;Maven;控制台;调试器(数据视图、单步执行);Python 调试器;运行/调试;运行目标;部署(选项) |
| 语言和框架 | 语言服务器;Go(构建标签、Go 模块);Java;Markdown;Node.js;PHP;Python 解释器;Prettier;oxfmt;Ruff;Ruby;Rust;TypeScript |
| 工具 | 智能体(聊天);终端;保存时操作;外部工具;SSH 配置;MCP 服务器 |
+5 -8
Переглянути файл
@@ -85,16 +85,13 @@ for (const id of ["editor.fileTemplates", "editor.liveTemplates"]) {
}
test("Project Structure keeps every details pane usable in a compact window", async ({ workbench: page }) => {
await page.keyboard.press("Meta+,");
await expect(page.getByRole("dialog", { name: "Settings", exact: true })).toBeVisible();
await page.evaluate(async () => {
const path = "/src/features/settings/model/staging.ts";
(await import(/* @vite-ignore */ path)).selectPage("projectStructure");
});
await page.keyboard.press("Meta+;");
const dialog = page.getByRole("dialog", { name: "Project Structure", exact: true });
await expect(dialog).toBeVisible();
await page.setViewportSize({ width: 520, height: 760 });
for (const section of ["Modules", "Libraries", "Facets", "Artifacts", "SDKs"]) {
await page.getByRole("tab", { name: section, exact: true }).click();
const details = page.locator("[class*='splitLayout'] > [class*='detailPane']");
await dialog.getByRole("button", { name: section, exact: true }).click();
const details = dialog.locator("[class*='splitLayout'] > [class*='detailPane']");
await expect(details).toBeAttached();
expect((await details.boundingBox())!.width).toBeGreaterThan(200);
}
+30
Переглянути файл
@@ -93,6 +93,36 @@ test("terminal highlights visible occurrences of selected text in a single overl
activeSession()!.term.clearSelection();
});
await expect(terminal.locator(".openidea-terminal-selection-match:visible")).toHaveCount(0);
// Full-scrollback trim shifts a visible match into the cached margin row above viewportY; scrolling up must highlight it.
await page.evaluate(async () => {
const path = "/src/features/terminal/runtime.ts";
const { activeSession } = await import(path);
const s = activeSession()!;
const rows = s.term.rows;
s.term.options.scrollback = 4;
s.term.reset();
const total = rows + 4;
let payload = "";
for (let i = 0; i < total - 1; i++) {
payload += `${i === 4 ? "needle hit" : `row-${i}`}\r\n`;
}
payload += "needle sel";
await new Promise<void>((resolve) => s.term.write(payload, resolve));
s.term.select(0, total - 1, 6);
});
await expect(terminal.locator(".openidea-terminal-selection-match:visible")).toHaveCount(1);
await page.evaluate(async () => {
await new Promise((r) => setTimeout(r, 100));
const path = "/src/features/terminal/runtime.ts";
const { activeSession } = await import(path);
const s = activeSession()!;
await new Promise<void>((resolve) => s.term.write("\r\nnext", resolve));
s.term.scrollLines(-1);
});
await expect(terminal.locator(".openidea-terminal-selection-match:visible")).toHaveCount(1);
await expect(terminal.locator(".openidea-terminal-selection-match:visible")).toHaveCSS("top", "0px");
});
test("terminal ⌘F highlights active/inactive search matches and seeds with a new selection", async ({
+1
Переглянути файл
@@ -15,6 +15,7 @@ something to show.
| `java-app/` | JDK 21, Maven 3.9 | `mvn compile exec:java` | `mvn test` | `mvn test -DopenideaFail=true` | unused variable in `App.java` |
| `c-app/` | gcc / g++ 12+, make, GDB 14+ | `make all` | — | — | — |
| `php-app/` | PHP 8.5, Composer | `composer quote` | `vendor/bin/pest` | `OPENIDEA_FAIL=1 vendor/bin/pest` | unused variable in `ThresholdCustoms.php` |
| `django-app/` | Python 3.10+, Django 5.2+ | `python manage.py runserver` | `python manage.py test` | `OPENIDEA_FAIL=1 python manage.py test` | unused import in `shop/views.py` |
| `polyglot/` | Go, TS, Python, C, shell, SQL, TOML/YAML/JSON, proto, Terraform | `make` | `make test` | — | unused variable in `native/hash.c` |
The fixtures are standalone: `rust-app` declares its own `[workspace]` and
Переглянути файл
Whitespace-only changes.
+27
Переглянути файл
@@ -0,0 +1,27 @@
"""Settings of the shop fixture: one app, no database tables of its own."""
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = "fixture-only-not-a-secret"
DEBUG = True
ALLOWED_HOSTS = ["localhost", "127.0.0.1"]
INSTALLED_APPS = ["django.contrib.staticfiles", "shop"]
ROOT_URLCONF = "config.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {"context_processors": ["django.template.context_processors.request"]},
}
]
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}}
STATIC_URL = "static/"
USE_TZ = True
+3
Переглянути файл
@@ -0,0 +1,3 @@
from django.urls import include, path
urlpatterns = [path("", include("shop.urls"))]
+16
Переглянути файл
@@ -0,0 +1,16 @@
#!/usr/bin/env python
"""Django's command-line utility for the shop fixture."""
import os
import sys
def main() -> None:
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()
+4
Переглянути файл
@@ -0,0 +1,4 @@
<!-- Not in a templates folder: the editor offers Django Template from its tags. -->
{% load static %}
<p>Two cappuccini for <strong>{{ price|default:"€3" }}</strong></p>
<img src="{% static 'promo.png' %}" alt="">
+1
Переглянути файл
@@ -0,0 +1 @@
Django>=5.2
Переглянути файл
Whitespace-only changes.
+15
Переглянути файл
@@ -0,0 +1,15 @@
{% load static %}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{% block title %}Shop{% endblock %}</title>
<link rel="stylesheet" href="{% static 'shop/site.css' %}">
</head>
<body>
{# The navigation is the same on every page. #}
<nav><a href="{% url 'shop:products' %}">Menu</a></nav>
<main>
{% block content %}{% endblock content %}
</main>
</body>
</html>
+3
Переглянути файл
@@ -0,0 +1,3 @@
{% autoescape off %}Thank you for your order, {{ name|default:"customer" }}!
{% for line in lines %}- {{ line.quantity }} × {{ line.product.name }}
{% endfor %}{% endautoescape %}
+25
Переглянути файл
@@ -0,0 +1,25 @@
{% extends "shop/base.html" %}
{% block title %}{% if single %}{{ products.0.name }}{% else %}Menu{% endif %} · {{ block.super }}{% endblock %}
{% block content %}
<ul class="products">
{% for product in products %}
<li class="{% cycle 'odd' 'even' %}">
<a href="{% url 'shop:product' slug=product.slug %}">{{ product.name|title }}</a>
<span class="price">€{{ product.price|floatformat:2 }}</span>
{% with count=product.tags|length %}
{% if count > 1 %}
<small>{{ product.tags|join:", " }}</small>
{% endif %}
{% endwith %}
</li>
{% empty %}
<li>Nothing on the menu today.</li>
{% endfor %}
</ul>
{% comment "Ideas" %}
<p>{{ seasonal_specials }}</p>
{% endcomment %}
{% verbatim %}<script type="text/x-template">{{ clientSide }}</script>{% endverbatim %}
{% endblock content %}
+22
Переглянути файл
@@ -0,0 +1,22 @@
import os
import unittest
from django.test import SimpleTestCase
from django.urls import reverse
class ProductPagesTests(SimpleTestCase):
def test_lists_every_product(self):
response = self.client.get(reverse("shop:products"))
self.assertContains(response, "Espresso")
self.assertContains(response, "Cornetto")
self.assertContains(response, "€1.60")
def test_unknown_product_is_not_found(self):
response = self.client.get(reverse("shop:product", args=["tea"]))
self.assertEqual(response.status_code, 404)
@unittest.skipUnless(os.environ.get("OPENIDEA_FAIL"), "opt-in failing test")
def test_intentionally_failing(self):
# FIXME: the opt-in failing test, for the test runner's failure views.
self.assertContains(self.client.get(reverse("shop:products")), "Tea")
+10
Переглянути файл
@@ -0,0 +1,10 @@
from django.urls import path
from . import views
app_name = "shop"
urlpatterns = [
path("", views.product_list, name="products"),
path("products/<slug:slug>/", views.product_detail, name="product"),
]
+35
Переглянути файл
@@ -0,0 +1,35 @@
"""Views of the shop: a product list and a product page."""
import json # Intentional warning: unused import.
from dataclasses import dataclass
from decimal import Decimal
from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import render
@dataclass(frozen=True)
class Product:
slug: str
name: str
price: Decimal
tags: tuple[str, ...] = ()
PRODUCTS = [
Product("espresso", "Espresso", Decimal("1.20"), ("coffee", "hot")),
Product("cappuccino", "Cappuccino", Decimal("1.60"), ("coffee", "milk")),
Product("cornetto", "Cornetto", Decimal("1.30")),
]
def product_list(request: HttpRequest) -> HttpResponse:
# TODO: paginate once the menu grows.
return render(request, "shop/product_list.html", {"products": PRODUCTS})
def product_detail(request: HttpRequest, slug: str) -> HttpResponse:
product = next((p for p in PRODUCTS if p.slug == slug), None) # Set a breakpoint here
if product is None:
raise Http404(slug)
return render(request, "shop/product_list.html", {"products": [product], "single": True})
+1 -1
Переглянути файл
@@ -2,7 +2,7 @@ Manifest-Version: 1.0
Bundle-ManifestVersion: 2
Bundle-Name: OpenIDEA Java usages
Bundle-SymbolicName: ai.openidea.usages;singleton:=true
Bundle-Version: 1.12.0
Bundle-Version: 1.13.0
Bundle-RequiredExecutionEnvironment: JavaSE-21
Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.jdt.core,
org.eclipse.jdt.core.manipulation,org.eclipse.core.runtime,
+1 -1
Переглянути файл
@@ -88,7 +88,7 @@ is not a claim of full IntelliJ feature parity.
## Rebuild and delivery
The checked-in jar is embedded by ide-sdk `include_bytes!`, installed as
`java-usages-1.12.0.jar`, and added alongside existing initialization bundles.
`java-usages-1.13.0.jar`, and added alongside existing initialization bundles.
Users need no compiler, Maven or Python. Manifest/install version must change
when replacing the bundle because OSGi caches bundles.
Бінарний файл не відображається.
+21
Переглянути файл
@@ -0,0 +1,21 @@
package ai.openidea;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.OperationCanceledException;
import org.eclipse.jdt.ls.core.internal.JobHelpers;
import org.eclipse.jdt.ls.core.internal.handlers.BaseDocumentLifeCycleHandler;
/** What a command that reads an editor's position needs before it resolves the position. */
public final class Documents {
private Documents() {}
/**
* Waits for the queued document changes to be reconciled, as JDT LS's own position requests
* do: `workspace/executeCommand` does not, so a position sent right after an edit would
* be resolved against the element structure from before it.
*/
public static void awaitChanges(IProgressMonitor monitor) {
JobHelpers.waitForJobs(BaseDocumentLifeCycleHandler.DOCUMENT_LIFE_CYCLE_JOBS, monitor);
if (monitor.isCanceled()) throw new OperationCanceledException();
}
}
+2
Переглянути файл
@@ -1,5 +1,6 @@
package ai.openidea.configuration;
import ai.openidea.Documents;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -96,6 +97,7 @@ public final class ConfigurationCommand implements IDelegateCommandHandler {
@Override
public Object executeCommand(String command, List<Object> args, IProgressMonitor monitor) throws Exception {
if (command.equals("openidea.java.configuration.warmup")) return ConfigurationWarmup.request(args);
Documents.awaitChanges(monitor);
String uri = (String) args.get(0);
String key = args.get(1) instanceof String s ? s : null;
int line = ((Number) args.get(2)).intValue(), column = ((Number) args.get(3)).intValue();
+2
Переглянути файл
@@ -1,5 +1,6 @@
package ai.openidea.hierarchy;
import ai.openidea.Documents;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
@@ -44,6 +45,7 @@ public final class HierarchyCommand implements IDelegateCommandHandler {
@Override
public Object executeCommand(String command, List<Object> arguments, IProgressMonitor monitor) throws Exception {
Documents.awaitChanges(monitor);
String uri = (String) arguments.get(0);
int line = ((Number) arguments.get(1)).intValue();
int column = ((Number) arguments.get(2)).intValue();
+2
Переглянути файл
@@ -1,5 +1,6 @@
package ai.openidea.implementations;
import ai.openidea.Documents;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -27,6 +28,7 @@ public final class ImplementationsCommand implements IDelegateCommandHandler {
@Override
public Object executeCommand(String command, List<Object> arguments, IProgressMonitor monitor) throws Exception {
Documents.awaitChanges(monitor);
List<Location> locations = new ArrayList<>();
boolean complete = find(arguments, locations, monitor);
return Map.of("locations", locations, "complete", complete);
+2
Переглянути файл
@@ -1,5 +1,6 @@
package ai.openidea.usages;
import ai.openidea.Documents;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
@@ -29,6 +30,7 @@ import org.eclipse.lsp4j.*;
public final class UsagesCommand implements IDelegateCommandHandler {
@Override
public Object executeCommand(String command, List<Object> arguments, IProgressMonitor monitor) throws Exception {
Documents.awaitChanges(monitor);
String uri = (String) arguments.get(0);
int line = ((Number) arguments.get(1)).intValue();
int column = ((Number) arguments.get(2)).intValue();
+23
Переглянути файл
@@ -0,0 +1,23 @@
id = "django-html"
name = "Django Template"
extensions = ["djhtml"]
block-comment = ["{#", "#}"]
indent = { tab-size = 2, insert-spaces = true }
ui-mode = "Django Template"
tree-sitter = "html"
# Django projects: the HTML and text files in a `templates` folder.
[[framework-files]]
folder = "templates"
extensions = ["html", "txt"]
root-markers = ["manage.py"]
# The markup between the template tags: completion and hover of tags and
# attributes. The template tags are text to it. No formatter: HTML formatters
# reflow template tags.
[[language-servers]]
name = "vscode-html-language-server"
command = "vscode-html-language-server"
args = ["--stdio"]
initialization-options = { embeddedLanguages = { css = true, javascript = true } }
install-hint = "npm i -g vscode-langservers-extracted"
+1 -1
Переглянути файл
@@ -17,7 +17,7 @@ func main() {
[[language-servers]]
name = "gopls"
command = "gopls"
initialization-options = { semanticTokens = true }
initialization-options = { semanticTokens = true, vulncheck = "Imports" }
root-markers = ["go.work", "go.mod"]
install-hint = "go install golang.org/x/tools/gopls@latest"
+1 -1
Переглянути файл
@@ -8,6 +8,6 @@ ui-mode = "GoMod"
[[language-servers]]
name = "gopls"
command = "gopls"
initialization-options = { semanticTokens = true }
initialization-options = { semanticTokens = true, vulncheck = "Imports" }
root-markers = ["go.work", "go.mod"]
install-hint = "go install golang.org/x/tools/gopls@latest"
+1 -1
Переглянути файл
@@ -8,6 +8,6 @@ ui-mode = "GoMod"
[[language-servers]]
name = "gopls"
command = "gopls"
initialization-options = { semanticTokens = true }
initialization-options = { semanticTokens = true, vulncheck = "Imports" }
root-markers = ["go.work"]
install-hint = "go install golang.org/x/tools/gopls@latest"
+1
Переглянути файл
@@ -22,6 +22,7 @@
"gen:windows-icons": "node scripts/gen-windows-icons.ts",
"gen:angular-grammar": "lezer-generator src/editor/core/cm/angular/angular.grammar --typeScript -o src/editor/core/cm/angular/parser.ts",
"gen:jsp-grammar": "lezer-generator src/editor/core/cm/jsp/jsp.grammar --typeScript -o src/editor/core/cm/jsp/parser.ts",
"gen:django-grammar": "lezer-generator src/editor/core/cm/django/django.grammar --typeScript -o src/editor/core/cm/django/parser.ts",
"gen:astro-grammar": "lezer-generator src/editor/core/cm/astro/astro.grammar --typeScript -o src/editor/core/cm/astro/parser.ts",
"gen:theme-docs": "node scripts/theme/gen-docs.ts",
"theme:import-intellij": "node scripts/theme/import-intellij-theme.ts",
+1
Переглянути файл
@@ -108,6 +108,7 @@ windows = { version = "0.62", features = [
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_Graphics_Gdi",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
] }
+13
Переглянути файл
@@ -128,6 +128,19 @@ pub async fn remote_mkdir(state: State<'_, AppState>, connection: FsConnection,
ide_host::ops::deploy::remote_mkdir(&state.host, connection, path).await
}
#[tauri::command]
#[specta::specta]
pub async fn remote_download_into(
window: Window,
state: State<'_, AppState>,
connection: FsConnection,
paths: Vec<String>,
target: IpcPath,
request: RequestId,
) -> IdeResult<TransferReport> {
ide_host::ops::deploy::remote_download_into(window.label(), &state.host, connection, paths, target, request).await
}
#[tauri::command]
#[specta::specta]
pub async fn remote_rename(
+6 -4
Переглянути файл
@@ -163,12 +163,14 @@ pub async fn doc_selection_ranges(
#[tauri::command]
#[specta::specta]
pub fn history_list(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<Vec<HistoryEntry>> {
ide_host::ops::doc::history_list(&state.host, window.label(), path)
pub async fn history_list(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<Vec<HistoryEntry>> {
let (host, label) = (state.host.clone(), window.label().to_owned());
blocking(move || ide_host::ops::doc::history_list(&host, &label, path)).await
}
#[tauri::command]
#[specta::specta]
pub fn history_content(window: Window, state: State<'_, AppState>, id: String) -> IdeResult<String> {
ide_host::ops::doc::history_content(&state.host, window.label(), id)
pub async fn history_content(window: Window, state: State<'_, AppState>, id: String) -> IdeResult<String> {
let (host, label) = (state.host.clone(), window.label().to_owned());
blocking(move || ide_host::ops::doc::history_content(&host, &label, id)).await
}
+189
Переглянути файл
@@ -0,0 +1,189 @@
//! Detects shortcuts the OS already gave to another app (Windows only).
//!
//! Some vendors register system-wide hotkeys (e.g. NVIDIA's overlay grabs
//! `Alt+R` / `Alt+Shift+R`), so those keystrokes never reach the IDE. Probing
//! asks Windows to register each combo for an instant: success means it is
//! free (released right away), `ERROR_HOTKEY_ALREADY_REGISTERED` means another
//! app grabbed it. Nothing is left registered.
use ide_core::IdeResult;
/// Reports which of `combos` (single-stroke `Alt+Shift+R`-style bindings) are
/// already registered to another app. Empty everywhere but Windows.
#[tauri::command]
#[specta::specta]
pub fn system_probe_hotkeys(combos: Vec<String>) -> IdeResult<Vec<String>> {
Ok(combos.into_iter().filter(|combo| imp::grabbed(combo)).collect())
}
/// A parsed binding: `RegisterHotKey` modifiers plus a virtual-key code.
#[cfg(windows)]
struct Hotkey {
modifiers: u32,
vk: u32,
}
/// Splits a canonical single-stroke binding into modifiers and key; `None`
/// for chords, double-modifier gestures, mouse buttons and unknown keys.
#[cfg(windows)]
fn parse_combo(combo: &str) -> Option<Hotkey> {
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{MOD_ALT, MOD_CONTROL, MOD_SHIFT, MOD_WIN};
if combo.contains(' ') {
return None;
}
let mut parts = combo.split('+');
let key = parts.next_back()?;
let mut modifiers = 0;
for part in parts {
modifiers |= match part.to_ascii_uppercase().as_str() {
"CTRL" => MOD_CONTROL,
"ALT" => MOD_ALT,
"SHIFT" => MOD_SHIFT,
"META" => MOD_WIN,
_ => return None,
};
}
if modifiers == 0 {
return None;
}
Some(Hotkey { modifiers, vk: vk_of(key)? })
}
/// The virtual-key code of a binding key, or `None` when it has no single
/// `RegisterHotKey` code (mouse buttons, gestures, layout-dependent keys).
#[cfg(windows)]
fn vk_of(key: &str) -> Option<u32> {
if key.len() == 1 {
let c = key.as_bytes()[0].to_ascii_uppercase();
if c.is_ascii_alphanumeric() {
return Some(u32::from(c));
}
return match c {
b'-' => Some(0xBD),
b'=' => Some(0xBB),
b'[' => Some(0xDB),
b']' => Some(0xDD),
b'\\' => Some(0xDC),
b';' => Some(0xBA),
b'\'' => Some(0xDE),
b',' => Some(0xBC),
b'.' => Some(0xBE),
b'/' => Some(0xBF),
b'`' => Some(0xC0),
_ => None,
};
}
// "F13" → 0x7C … "F24" → 0x87; anything else falls through to the names below.
let fkey =
key.strip_prefix('F').and_then(|n| n.parse::<u32>().ok()).filter(|n| (1..=24).contains(n)).map(|n| 0x6F + n);
if fkey.is_some() {
return fkey;
}
match key.to_ascii_uppercase().as_str() {
"SPACE" => Some(0x20),
"ENTER" | "RETURN" => Some(0x0D),
"ESCAPE" | "ESC" => Some(0x1B),
"TAB" => Some(0x09),
"BACKSPACE" | "BACK" => Some(0x08),
"DELETE" | "DEL" => Some(0x2E),
"INSERT" | "INS" => Some(0x2D),
"HOME" => Some(0x24),
"END" => Some(0x23),
"PAGEUP" | "PRIOR" => Some(0x21),
"PAGEDOWN" | "NEXT" => Some(0x22),
"UP" => Some(0x26),
"DOWN" => Some(0x28),
"LEFT" => Some(0x25),
"RIGHT" => Some(0x27),
_ => None,
}
}
#[cfg(windows)]
mod imp {
use super::{Hotkey, parse_combo};
use std::sync::atomic::{AtomicI32, Ordering};
use windows_sys::Win32::Foundation::{ERROR_HOTKEY_ALREADY_REGISTERED, GetLastError};
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{RegisterHotKey, UnregisterHotKey};
/// Probe ids (never shown, never left registered).
static NEXT_ID: AtomicI32 = AtomicI32::new(0x1DEA00);
/// Registers `combo` for an instant: grabbed iff Windows refuses with
/// `ERROR_HOTKEY_ALREADY_REGISTERED`. A granted registration is released
/// before returning, so probing never steals a shortcut.
pub(super) fn grabbed(combo: &str) -> bool {
let Some(Hotkey { modifiers, vk }) = parse_combo(combo) else {
return false;
};
// SAFETY: registering a hotkey to a null window only tests availability;
// the id is unique per probe and unregistered on success.
unsafe {
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
if RegisterHotKey(std::ptr::null_mut(), id, modifiers, vk) != 0 {
UnregisterHotKey(std::ptr::null_mut(), id);
false
} else {
GetLastError() == ERROR_HOTKEY_ALREADY_REGISTERED
}
}
}
}
#[cfg(not(windows))]
mod imp {
/// No other platform lets background apps swallow our keystrokes this way.
pub(super) fn grabbed(_combo: &str) -> bool {
false
}
}
#[cfg(all(test, windows))]
mod tests {
use super::imp::grabbed;
use super::parse_combo;
use windows_sys::Win32::Foundation::{ERROR_HOTKEY_ALREADY_REGISTERED, GetLastError};
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{MOD_ALT, MOD_SHIFT, RegisterHotKey, UnregisterHotKey};
#[test]
fn parses_modifier_order_and_key() {
let hotkey = parse_combo("Alt+Shift+R").expect("Alt+Shift+R parses");
assert_eq!(hotkey.modifiers, MOD_ALT | MOD_SHIFT);
assert_eq!(hotkey.vk, u32::from(b'R'));
let f7 = parse_combo("Alt+F7").expect("Alt+F7 parses");
assert_eq!(f7.modifiers, MOD_ALT);
assert_eq!(f7.vk, 0x76);
}
#[test]
fn rejects_what_cannot_be_probed() {
for combo in ["R", "Alt+R Alt+S", "Shift Shift", "Alt+Button4", "Ctrl+Foo", ""] {
assert!(!grabbed(combo), "{combo} must not probe");
}
}
/// Holds a real hotkey, then the probe must report it as grabbed — whether
/// our hold succeeded or another app already held it (both read 1409).
#[test]
fn sees_a_hotkey_held_by_another_registration() {
const COMBO: &str = "Alt+Shift+F24";
const VK_F24: u32 = 0x87;
// SAFETY: a test-only registration, released below.
let held = unsafe { RegisterHotKey(std::ptr::null_mut(), 0x1DEAFE, MOD_ALT | MOD_SHIFT, VK_F24) != 0 };
if !held {
assert_eq!(
unsafe { GetLastError() },
ERROR_HOTKEY_ALREADY_REGISTERED,
"the test hotkey must be holdable or already held"
);
}
assert!(grabbed(COMBO), "a held hotkey reads as grabbed");
if held {
// SAFETY: releases the registration above.
unsafe {
UnregisterHotKey(std::ptr::null_mut(), 0x1DEAFE);
}
}
}
}
+3
Переглянути файл
@@ -28,6 +28,7 @@ pub mod fs;
pub mod github;
pub mod heap;
pub mod history;
pub mod hotkeys;
pub mod http;
pub mod java;
pub mod lsp;
@@ -218,6 +219,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
history::history_list_dir,
history::history_put_label,
history::history_restore,
hotkeys::system_probe_hotkeys,
refactor::refactor_file_move_edits,
refactor::refactor_file_move_text_edits,
refactor::refactor_move_files,
@@ -685,6 +687,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
deploy::remote_of,
deploy::remote_delete,
deploy::remote_mkdir,
deploy::remote_download_into,
deploy::remote_rename,
crate::remote_dev::remote_bootstrap_list,
crate::remote_dev::remote_connect,
+24 -12
Переглянути файл
@@ -1,5 +1,6 @@
//! IPC commands of `ide_host::ops::run` (the shared implementation).
use super::doc::blocking;
use crate::state::AppState;
use ide_core::{IdeResult, RunId};
#[allow(unused_imports)]
@@ -55,13 +56,15 @@ pub async fn run_configs(window: Window, state: State<'_, AppState>) -> IdeResul
#[tauri::command]
#[specta::specta]
pub fn run_save_configs(
pub async fn run_save_configs(
window: Window,
state: State<'_, AppState>,
configs: Vec<RunConfig>,
removed_detected_ids: Option<Vec<String>>,
) -> IdeResult<()> {
ide_host::ops::run::save_configs(&state.host, window.label(), &configs, &removed_detected_ids.unwrap_or_default())
let (host, label) = (state.host.clone(), window.label().to_owned());
let removed = removed_detected_ids.unwrap_or_default();
blocking(move || ide_host::ops::run::save_configs(&host, &label, &configs, &removed)).await
}
/// Temporary configurations as they are kept between sessions: without what settings derived.
@@ -140,36 +143,44 @@ pub fn run_close_stdin(state: State<'_, AppState>, id: RunId) -> IdeResult<()> {
/// Configuration templates: per-type defaults applied to new configurations.
#[tauri::command]
#[specta::specta]
pub fn run_templates(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<RunConfig>> {
ide_host::ops::run::run_templates(&state.host, window.label())
pub async fn run_templates(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<RunConfig>> {
let (host, label) = (state.host.clone(), window.label().to_owned());
blocking(move || ide_host::ops::run::run_templates(&host, &label)).await
}
#[tauri::command]
#[specta::specta]
pub fn run_save_templates(window: Window, state: State<'_, AppState>, templates: Vec<RunConfig>) -> IdeResult<()> {
ide_host::ops::run::run_save_templates(&state.host, window.label(), templates)
pub async fn run_save_templates(
window: Window,
state: State<'_, AppState>,
templates: Vec<RunConfig>,
) -> IdeResult<()> {
let (host, label) = (state.host.clone(), window.label().to_owned());
blocking(move || ide_host::ops::run::run_save_templates(&host, &label, templates)).await
}
/// Project files run configurations can be imported from (`.vscode/launch.json`).
#[tauri::command]
#[specta::specta]
pub fn run_import_sources(
pub async fn run_import_sources(
window: Window,
state: State<'_, AppState>,
) -> IdeResult<Vec<ide_run::import::RunImportSource>> {
ide_host::ops::run::run_import_sources(&state.host, window.label())
let (host, label) = (state.host.clone(), window.label().to_owned());
blocking(move || ide_host::ops::run::run_import_sources(&host, &label)).await
}
/// Configurations read from `source`, not saved; names already in `existing` are skipped.
#[tauri::command]
#[specta::specta]
pub fn run_import_configs(
pub async fn run_import_configs(
window: Window,
state: State<'_, AppState>,
source: ide_run::import::RunImportSource,
existing: Vec<RunConfig>,
) -> IdeResult<ide_run::import::RunImport> {
ide_host::ops::run::run_import_configs(&state.host, window.label(), source, existing)
let (host, label) = (state.host.clone(), window.label().to_owned());
blocking(move || ide_host::ops::run::run_import_configs(&host, &label, source, existing)).await
}
/// The environment runs inherit when "Include system environment variables" is on.
@@ -182,12 +193,13 @@ pub fn run_system_env(state: State<'_, AppState>) -> std::collections::BTreeMap<
/// Variables of the `.env` files a debug launch names, for the debugger to start with.
#[tauri::command]
#[specta::specta]
pub fn run_env_files(
pub async fn run_env_files(
window: Window,
state: State<'_, AppState>,
files: Vec<std::path::PathBuf>,
) -> IdeResult<std::collections::BTreeMap<String, String>> {
ide_host::ops::run::run_env_files(&state.host, window.label(), files)
let (host, label) = (state.host.clone(), window.label().to_owned());
blocking(move || ide_host::ops::run::run_env_files(&host, &label, files)).await
}
/// Writes the Gradle init script adding `jvm_args` to the JVMs the build forks; returns its path.
+30 -13
Переглянути файл
@@ -1,5 +1,6 @@
//! IPC commands of `ide_host::ops::vcs` (the shared implementation).
use super::doc::blocking;
use crate::state::AppState;
use ide_core::{IdeResult, RequestId};
#[allow(unused_imports)]
@@ -7,7 +8,7 @@ pub use ide_host::ops::vcs::*;
use ide_vcs::*;
use std::path::PathBuf;
use tauri::ipc::Channel;
use tauri::{State, Window};
use tauri::{AppHandle, Manager, State, Window};
/// Changes of every repository of the project.
#[tauri::command]
@@ -473,8 +474,12 @@ pub async fn vcs_diff_text(
/// Line hunks between two arbitrary texts (diff viewer, commit dialog).
#[tauri::command]
#[specta::specta]
pub fn vcs_diff_hunks(state: State<'_, AppState>, old_text: String, new_text: String) -> Vec<LineHunk> {
ide_host::ops::vcs::vcs_diff_hunks(&state.host, "", old_text, new_text)
pub async fn vcs_diff_hunks(app: AppHandle, old_text: String, new_text: String) -> Vec<LineHunk> {
// Off the main thread: diffing two large texts would freeze the window.
let host = app.state::<AppState>().host.clone();
tauri::async_runtime::spawn_blocking(move || ide_host::ops::vcs::vcs_diff_hunks(&host, "", old_text, new_text))
.await
.expect("line diff panicked")
}
/// Stages only the selected hunks of a file's working-tree changes.
@@ -783,8 +788,9 @@ pub async fn vcs_shelve(
#[tauri::command]
#[specta::specta]
pub fn vcs_shelves(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<ShelvedChangeList>> {
ide_host::ops::vcs::vcs_shelves(&state.host, window.label())
pub async fn vcs_shelves(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<ShelvedChangeList>> {
let (host, label) = (state.host.clone(), window.label().to_owned());
blocking(move || ide_host::ops::vcs::vcs_shelves(&host, &label)).await
}
/// Unshelve: applies the shelved patch (3-way merging; conflicts are left for the
@@ -803,33 +809,44 @@ pub async fn vcs_unshelve(
#[tauri::command]
#[specta::specta]
pub fn vcs_shelf_delete(window: Window, state: State<'_, AppState>, id: String, path: Option<String>) -> IdeResult<()> {
ide_host::ops::vcs::vcs_shelf_delete(&state.host, window.label(), id, path)
pub async fn vcs_shelf_delete(
window: Window,
state: State<'_, AppState>,
id: String,
path: Option<String>,
) -> IdeResult<()> {
let (host, label) = (state.host.clone(), window.label().to_owned());
blocking(move || ide_host::ops::vcs::vcs_shelf_delete(&host, &label, id, path)).await
}
#[tauri::command]
#[specta::specta]
pub fn vcs_shelf_rename(window: Window, state: State<'_, AppState>, id: String, name: String) -> IdeResult<()> {
ide_host::ops::vcs::vcs_shelf_rename(&state.host, window.label(), id, name)
pub async fn vcs_shelf_rename(window: Window, state: State<'_, AppState>, id: String, name: String) -> IdeResult<()> {
let (host, label) = (state.host.clone(), window.label().to_owned());
blocking(move || ide_host::ops::vcs::vcs_shelf_rename(&host, &label, id, name)).await
}
#[tauri::command]
#[specta::specta]
pub fn vcs_shelf_patch(
pub async fn vcs_shelf_patch(
window: Window,
state: State<'_, AppState>,
id: String,
path: Option<String>,
) -> IdeResult<String> {
ide_host::ops::vcs::vcs_shelf_patch(&state.host, window.label(), id, path)
let (host, label) = (state.host.clone(), window.label().to_owned());
blocking(move || ide_host::ops::vcs::vcs_shelf_patch(&host, &label, id, path)).await
}
#[tauri::command]
#[specta::specta]
pub fn vcs_changelists_load(window: Window, state: State<'_, AppState>) -> IdeResult<ChangelistState> {
ide_host::ops::vcs::vcs_changelists_load(&state.host, window.label())
pub async fn vcs_changelists_load(window: Window, state: State<'_, AppState>) -> IdeResult<ChangelistState> {
let (host, label) = (state.host.clone(), window.label().to_owned());
blocking(move || ide_host::ops::vcs::vcs_changelists_load(&host, &label)).await
}
// Synchronous on purpose: the UI saves without waiting (debounced), and on the main thread
// the saves land in order, so an older state can't overwrite a newer one.
#[tauri::command]
#[specta::specta]
pub fn vcs_changelists_save(window: Window, state: State<'_, AppState>, changelists: ChangelistState) -> IdeResult<()> {
+46
Переглянути файл
@@ -0,0 +1,46 @@
//! Tauri runs a command declared without `async` on the main thread, so disk I/O or a
//! large diff there freezes the window (input, painting, every other IPC call).
/// Commands that read or write project files, Local History or `.env` files, or diff two
/// arbitrary texts: they must be `async` and do that work on the blocking pool.
const OFF_MAIN_THREAD: &[(&str, &[&str])] = &[
("doc.rs", &["history_list", "history_content"]),
(
"vcs.rs",
&[
"vcs_diff_hunks",
"vcs_shelves",
"vcs_shelf_delete",
"vcs_shelf_rename",
"vcs_shelf_patch",
"vcs_changelists_load",
],
),
(
"run.rs",
&[
"run_save_configs",
"run_templates",
"run_save_templates",
"run_import_sources",
"run_import_configs",
"run_env_files",
],
),
];
#[test]
fn disk_and_diff_commands_stay_off_the_main_thread() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/commands");
let mut on_main = Vec::new();
for (file, commands) in OFF_MAIN_THREAD {
let source = std::fs::read_to_string(dir.join(file)).unwrap();
for command in *commands {
assert!(source.contains(&format!("fn {command}(")), "{file} has no command {command}");
if !source.contains(&format!("pub async fn {command}(")) {
on_main.push(*command);
}
}
}
assert!(on_main.is_empty(), "synchronous commands run on the main thread: {on_main:?}");
}
+25
Переглянути файл
@@ -381,6 +381,11 @@ export const commands = {
* file and its folders); an open editor updates in place.
*/
historyRestore: (path: string, id: string) => typedError<null, IdeError>(__TAURI_INVOKE("history_restore", { path, id })),
/**
* Reports which of `combos` (single-stroke `Alt+Shift+R`-style bindings) are
* already registered to another app. Empty everywhere but Windows.
*/
systemProbeHotkeys: (combos: string[]) => typedError<string[], IdeError>(__TAURI_INVOKE("system_probe_hotkeys", { combos })),
/** `workspace/willRenameFiles` on every interested server (before the move). Cancellable. */
refactorFileMoveEdits: (request: RequestId, moves: FileMove[]) => typedError<FileRenameEdits, IdeError>(__TAURI_INVOKE("refactor_file_move_edits", { request, moves })),
/**
@@ -1571,6 +1576,7 @@ export const commands = {
remoteOf: (path: IpcPath) => __TAURI_INVOKE<[FsConnection, string] | null>("remote_of", { path }),
remoteDelete: (connection: FsConnection, path: string) => typedError<null, IdeError>(__TAURI_INVOKE("remote_delete", { connection, path })),
remoteMkdir: (connection: FsConnection, path: string) => typedError<null, IdeError>(__TAURI_INVOKE("remote_mkdir", { connection, path })),
remoteDownloadInto: (connection: FsConnection, paths: string[], target: IpcPath, request: RequestId) => typedError<TransferReport, IdeError>(__TAURI_INVOKE("remote_download_into", { connection, paths, target, request })),
remoteRename: (connection: FsConnection, from: string, to: string) => typedError<null, IdeError>(__TAURI_INVOKE("remote_rename", { connection, from, to })),
/** Browse before connecting, using the desktop's SSH configuration even in a remote window. */
remoteBootstrapList: (ssh: string, path: string) => typedError<RemoteEntry[], IdeError>(__TAURI_INVOKE("remote_bootstrap_list", { ssh, path })),
@@ -4177,6 +4183,13 @@ export type DocumentInfo = {
lineEnding: LineEnding,
readOnly: boolean,
dirty: boolean,
/**
* Whether "Reload with Encoding" can re-decode this document's source
* bytes: true for files and virtual documents the backend can re-read
* (archive entries, JDT sources with an editable attachment encoding,
* Git revisions); false for server-generated content (decompiled code).
*/
canReloadEncoding: boolean,
/**
* Larger than `ide_vfs::LARGE_FILE_THRESHOLD`: language servers, syntax
* structure and folding are disabled; the editor should drop heavy features.
@@ -9970,6 +9983,18 @@ export type VirtualDocument = {
description: string | null,
/** Generated Java from a decompiler, rather than real source or a disassembler stub. */
decompiled: boolean,
/**
* Charset the content was decoded with (a WHATWG label, e.g. `gbk`);
* `utf-8` for text the server decoded itself.
*/
encoding: string,
hasBom: boolean,
/**
* "Reload with Encoding" can re-decode the origin's bytes (archive
* entries, JDT sources with an editable attachment encoding); false for
* server-generated content such as decompiled code.
*/
canReloadEncoding: boolean,
};
/** Declared (or conventional) visibility of an outline symbol. */
+1
Переглянути файл
@@ -396,6 +396,7 @@ export function createMemoryBackend(files: Record<string, string> = {}): MemoryB
lineEnding: d.lineEnding,
readOnly: d.readOnly,
dirty: d.text !== d.saved || d.hasBom !== d.savedBom,
canReloadEncoding: !d.uri,
...(d.uri ? { uri: d.uri } : {}),
...(d.uiMode ? { uiMode: d.uiMode } : {}),
});
+88 -4
Переглянути файл
@@ -81,7 +81,10 @@ export const commentLine: Command = (view) => {
lineToken(state, doc.line(b.startLine).from + leadingWs(doc.line(b.startLine).text)),
);
// Languages without line comments (CSS, HTML…): block comments.
if (tokens.some((t) => !t)) return blockCommentLines(view);
if (tokens.some((t) => !t)) {
const first = doc.line(blocks[0]!.startLine);
return lineBound(state, first.from + leadingWs(first.text)) ? commentEachLine(view) : blockCommentLines(view);
}
let allCommented = true;
let commentedNonEmpty = false;
@@ -348,11 +351,92 @@ function uncommentBlock(view: EditorView, ranges: { from: number; to: number }[]
/** ⌘⇧/: block comments the selection, or takes the block comment back out. */
export const commentBlock: Command = (view) => {
if (view.state.readOnly) return false;
const ranges = view.state.selection.ranges.map((r) => ({ from: r.from, to: r.to }));
return uncommentBlock(view, ranges) || toggleBlockComment(view);
const { state } = view;
if (state.readOnly) return false;
const ranges = state.selection.ranges.map((r) => ({ from: r.from, to: r.to }));
if (uncommentBlock(view, ranges)) return true;
const multiline = state.selection.ranges.some(
(r) => state.doc.lineAt(r.from).number != state.doc.lineAt(r.to).number,
);
const bound = multiline ? lineBound(state, state.selection.main.from) : null;
if (!bound) return toggleBlockComment(view);
const { open, close } = bound.multiline;
view.dispatch(
state.changeByRange((r) => ({
changes: [
{ from: r.from, insert: open },
{ from: r.to, insert: close },
],
range: EditorSelection.range(r.from + open.length, r.to + open.length),
})),
{ scrollIntoView: true, userEvent: "input.comment" },
);
return true;
};
/**
* Language data `lineBoundComments` of a language whose block comment can't span lines
* (Django's `{# #}`): `multiline` is the comment to use across lines.
*/
interface LineBoundComments {
multiline: CommentPair;
}
function lineBound(state: EditorState, pos: number): LineBoundComments | null {
return state.languageDataAt<LineBoundComments>("lineBoundComments", pos)[0] ?? null;
}
/**
* ⌘/ where a block comment can't span lines: each non-blank line gets its own, or loses it
* when every one has one. A caret alone moves down, as with line comments.
*/
function commentEachLine(view: EditorView): boolean {
const { state } = view;
const doc = state.doc;
const lines = new Set<number>();
for (const r of state.selection.ranges) {
const first = doc.lineAt(r.from).number;
let last = doc.lineAt(r.to).number;
if (last > first && doc.line(last).from === r.to) last--;
for (let n = first; n <= last; n++) lines.add(n);
}
const targets = [...lines]
.sort((a, b) => a - b)
.map((n) => doc.line(n))
.filter((line) => line.text.trim());
const pairs = targets.map((line) => commentPairs(state, line.from + leadingWs(line.text))[0]);
if (!targets.length || pairs.some((p) => !p)) return blockCommentLines(view);
const commented = targets.every((line, i) => {
const text = line.text.trim();
return text.startsWith(pairs[i]!.open) && text.endsWith(pairs[i]!.close);
});
const changes: ChangeSpec[] = targets.map((line, i) => {
const { open, close } = pairs[i]!;
const start = line.from + leadingWs(line.text);
const end = line.from + line.text.trimEnd().length;
if (!commented)
return [
{ from: start, insert: `${open} ` },
{ from: end, insert: ` ${close}` },
];
const inner = state.sliceDoc(start + open.length, end - close.length);
return { from: start, to: end, insert: inner.replace(/^ /, "").replace(/ $/, "") };
});
const caret = state.selection.ranges.length === 1 && state.selection.main.empty;
const changeSet = state.changes(changes);
const next = caret ? doc.lineAt(state.selection.main.head).number + 1 : 0;
view.dispatch({
changes: changeSet,
selection:
caret && next <= doc.lines
? EditorSelection.cursor(changeSet.mapPos(doc.line(next).from + leadingWs(doc.line(next).text)))
: state.selection.map(changeSet),
scrollIntoView: true,
userEvent: commented ? "delete.uncomment" : "input.comment",
});
return true;
}
/** ⌘/ in a language without line comments (CSS, HTML…): the block comment of the lines. */
function blockCommentLines(view: EditorView): boolean {
const ranges = selectedLines(view.state);
+112
Переглянути файл
@@ -0,0 +1,112 @@
// Django templates: the template layer. Tags (`{% … %}`), variables (`{{ … }}`)
// and comments are parsed here; the text between them is HTML, mounted by
// `./index.ts`. Block tags of Django and its contrib libraries nest
// (`{% if %}…{% elif %}…{% endif %}`); other tags, custom ones included, stand
// alone. Regenerate with `pnpm gen:django-grammar`.
@context blocks from "./tokens"
@top Template { node* }
node {
Text |
Comment |
Interpolation |
Tag |
Element |
CommentElement |
VerbatimElement
}
// A block tag, its middle tags (`elif`, `else`, `empty`, `plural`) and its end tag.
Element { OpenTag node* (MidTag node*)* EndTag }
// `{% comment %}…{% endcomment %}`: the content is not parsed.
CommentElement { CommentTag CommentBody? EndTag }
// `{% verbatim %}…{% endverbatim %}`: the content is HTML whose tags and
// variables Django leaves alone.
VerbatimElement { VerbatimTag VerbatimBody? EndTag }
@skip { space } {
OpenTag { OpenTagStart TagName argument* TagEnd }
CommentTag { CommentTagStart TagName argument* TagEnd }
VerbatimTag { VerbatimTagStart TagName argument* TagEnd }
MidTag { MidTagStart TagName argument* TagEnd }
EndTag { EndTagStart TagName argument* TagEnd }
Tag { TagStart TagName argument* TagEnd }
Interpolation { "{{" expression? "}}" }
argument {
expression |
CompareOp |
AssignOp { "=" } |
kw<"and"> | kw<"or"> | kw<"not"> | kw<"in"> | kw<"is"> |
kw<"as"> | kw<"with"> | kw<"only"> | kw<"from">
}
expression[@isGroup=Expression] {
VariableName |
MemberExpression |
String |
Number |
@specialize[@name=BooleanLiteral]<identifier, "True" | "False"> |
@specialize[@name=None]<identifier, "None"> |
FilterExpression
}
MemberExpression { (VariableName | MemberExpression) "." (PropertyName | Number) }
FilterExpression { expression !filter FilterOp FilterName (":" filterArgument)? }
filterArgument { VariableName | MemberExpression | String | Number }
}
@precedence { filter @left }
VariableName { identifier }
PropertyName { identifier }
FilterName { identifier }
// `with` is a keyword in arguments and a block tag's name.
TagName { identifier | kw<"with"> }
FilterOp { "|" }
TagEnd { "%}" }
kw<term> { @specialize[@name={term}]<identifier, term> }
// Tried in this order, before the tokens below: a raw block's content before
// the tags in it.
@external tokens comment from "./tokens" { Comment }
@external tokens commentBody from "./tokens" { CommentBody }
@external tokens verbatimBody from "./tokens" { VerbatimBody }
@external tokens text from "./tokens" { Text }
@external tokens tagStart from "./tokens" {
OpenTagStart, CommentTagStart, VerbatimTagStart, MidTagStart, EndTagStart, TagStart
}
@tokens {
space { $[ \t\n\r]+ }
identifier { $[a-zA-Z_\u{a1}-\u{10ffff}] $[a-zA-Z0-9_\u{a1}-\u{10ffff}]* }
Number { "-"? @digit+ ("." @digit+)? }
String {
'"' (!["\\] | "\\" _)* '"'? |
"'" (!['\\] | "\\" _)* "'"?
}
CompareOp { "==" | "!=" | "<" | ">" | "<=" | ">=" }
@precedence { CompareOp, "=" }
"{{" "}}" "." ":" "="
}
@detectDelim
+251
Переглянути файл
@@ -0,0 +1,251 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { EditorSelection, EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { CompletionContext, type CompletionResult, type CompletionSource } from "@codemirror/autocomplete";
import { foldable, getIndentation, indentUnit, matchBrackets } from "@codemirror/language";
import { buildParserFile } from "@lezer/generator";
import { highlightTree, tagHighlighter, tags as t } from "@lezer/highlight";
import { completedSyntaxState, completedSyntaxTree } from "@/test/syntax";
import { commentBlock, commentLine } from "../comments";
import { findLanguageDescription, loadSupport } from "../languages";
import { django, djangoLanguage } from "./index";
const highlighter = tagHighlighter([
{ tag: t.special(t.brace), class: "delim" },
{ tag: t.blockComment, class: "comment" },
{ tag: t.operatorKeyword, class: "opkw" },
{ tag: t.keyword, class: "keyword" },
{ tag: t.bool, class: "bool" },
{ tag: t.null, class: "null" },
{ tag: t.function(t.variableName), class: "filter" },
{ tag: t.variableName, class: "var" },
{ tag: t.propertyName, class: "prop" },
{ tag: t.attributeName, class: "attr" },
{ tag: t.string, class: "str" },
{ tag: t.number, class: "num" },
{ tag: t.tagName, class: "tag" },
]);
function state(doc: string, selection?: EditorSelection | number): EditorState {
return completedSyntaxState(
EditorState.create({
doc,
selection: typeof selection == "number" ? EditorSelection.cursor(selection) : selection,
extensions: [django(), indentUnit.of(" ")],
}),
);
}
/** `text:class` of the styled tokens of `doc`, HTML included. */
function tokens(doc: string): string[] {
const out: string[] = [];
highlightTree(completedSyntaxTree(state(doc)), highlighter, (from, to, cls) =>
out.push(`${doc.slice(from, to)}:${cls}`),
);
return out;
}
/** The error nodes of `doc`'s template layer, with the text they start. */
function errors(doc: string): string[] {
const out: string[] = [];
djangoLanguage.parser.parse(doc).iterate({
enter: (node) => void (node.type.isError && out.push(doc.slice(node.from, node.from + 12))),
});
return out;
}
/** The top-level node names of `doc`'s template layer. */
function outline(doc: string): string {
return djangoLanguage.parser.parse(doc).toString();
}
/** A view on `doc` with `|` marking the caret. */
function view(marked: string): EditorView {
const at = marked.indexOf("|");
const doc = marked.slice(0, at) + marked.slice(at + 1);
return new EditorView({ state: state(doc, at) });
}
/** Types `text` at the caret through the input handlers, like a keystroke. */
function type(v: EditorView, text: string): string {
const { from, to } = v.state.selection.main;
const handled = v.state.facet(EditorView.inputHandler).some((h) => h(v, from, to, text, () => v.state.update({})));
if (!handled)
v.dispatch({ changes: { from, to, insert: text }, selection: EditorSelection.cursor(from + text.length) });
const head = v.state.selection.main.head;
return v.state.sliceDoc(0, head) + "|" + v.state.sliceDoc(head);
}
describe("Django template grammar", () => {
it("parses Django's tags, filters with arguments and its operators without errors", () => {
const doc = [
`{% extends "base.html" %}{% load static i18n %}`,
`{% block content %}{{ block.super }}`,
`<a href="{% url 'app:detail' pk=obj.pk as link %}">{{ value|date:"Y-m-d"|default:'-' }}</a>`,
`<img src="{% static 'img/a.png' %}">{% csrf_token %}`,
`{% for x in items|dictsort:"name" %}{{ forloop.counter0 }} {{ x.0 }}{% empty %}none{% endfor %}`,
`{% if a and not b or c in d %}x{% elif e is not None and n >= -1 %}y{% else %}z{% endif %}`,
`{% blocktranslate with n=count trimmed %}{{ n }} item{% plural %}{{ n }} items{% endblocktranslate %}`,
`{% with total=business.employees.count %}{{ total }}{% endwith %}`,
`{% include "row.html" with item=item only %}{% cycle 'odd' 'even' %}{% now "Y" %}`,
`{% render_field form.email class="input" %}{% endblock content %}`,
].join("\n");
expect(errors(doc)).toEqual([]);
});
it("nests block tags and leaves other tags alone", () => {
expect(outline("{% if a %}{% for x in b %}{{ x }}{% endfor %}{% else %}-{% endif %}")).toBe(
"Template(Element(OpenTag(OpenTagStart,TagName,VariableName,TagEnd)," +
"Element(OpenTag(OpenTagStart,TagName,VariableName,in,VariableName,TagEnd)," +
'Interpolation("{{",VariableName,"}}"),EndTag(EndTagStart,TagName,TagEnd)),' +
"MidTag(MidTagStart,TagName,TagEnd),Text,EndTag(EndTagStart,TagName,TagEnd)))",
);
expect(outline("{% csrf_token %}{% else %}")).toBe(
"Template(Tag(TagStart,TagName,TagEnd),Tag(TagStart,TagName,TagEnd))",
);
});
it("closes a block only with its own end tag", () => {
// `endfor` closes nothing here: the `if` stays open to the end.
const tree = outline("{% if a %}{% endfor %}");
expect(tree).toContain("Element(OpenTag(");
expect(tree).toContain("Tag(TagStart,TagName,TagEnd)");
});
it("doesn't parse the content of comment and verbatim", () => {
expect(errors("{% comment 'why' %}{% if %} {{ {% endcomment %}")).toEqual([]);
expect(outline("{% verbatim %}{{ raw }}{% if %}{% endverbatim %}")).toBe(
"Template(VerbatimElement(VerbatimTag(VerbatimTagStart,TagName,TagEnd),VerbatimBody,EndTag(EndTagStart,TagName,TagEnd)))",
);
});
it("highlights the template layer and the markup around it", () => {
expect(tokens(`<p class="x">{{ user.name|lower }}{# hi #}</p>`)).toEqual([
"p:tag",
"class:attr",
'"x":str',
"{{:delim",
"user:var",
"name:prop",
"lower:filter",
"}}:delim",
"{# hi #}:comment",
"p:tag",
]);
expect(tokens(`{% if a is None and b %}{% endif %}`)).toEqual([
"{%:delim",
"if:keyword",
"a:var",
"is:opkw",
"None:null",
"and:opkw",
"b:var",
"%}:delim",
"{%:delim",
"endif:keyword",
"%}:delim",
]);
});
it("parses the templates of the Django fixture without errors", () => {
const fixture = resolve(import.meta.dirname, "../../../../../fixtures/django-app");
for (const file of [
"shop/templates/shop/base.html",
"shop/templates/shop/product_list.html",
"shop/templates/shop/email/order.txt",
"pages/promo.html",
]) {
expect([file, errors(readFileSync(resolve(fixture, file), "utf8"))]).toEqual([file, []]);
}
});
it("is generated from django.grammar", () => {
const dir = import.meta.dirname;
const { parser, terms } = buildParserFile(readFileSync(resolve(dir, "django.grammar"), "utf8"), {
fileName: "src/editor/core/cm/django/django.grammar",
moduleStyle: "es",
typeScript: true,
});
expect(parser).toBe(readFileSync(resolve(dir, "parser.ts"), "utf8"));
expect(terms).toBe(readFileSync(resolve(dir, "parser.terms.ts"), "utf8"));
});
});
describe("Django template editing", () => {
it("folds blocks, comments and verbatim content", () => {
const doc = "{% block content %}\n <p>x</p>\n{% endblock %}\n{% comment %}\nold\n{% endcomment %}\n";
const s = state(doc);
expect(foldable(s, 0, doc.indexOf("\n"))).toEqual({ from: doc.indexOf("%}") + 2, to: doc.indexOf("{% endblock") });
const comment = doc.indexOf("{% comment");
expect(foldable(s, comment, doc.indexOf("\n", comment))).toEqual({
from: doc.indexOf("%}", comment) + 2,
to: doc.indexOf("{% endcomment"),
});
});
it("indents a block's content and puts its middle and end tags back", () => {
const doc = "{% if a %}\nx\n{% else %}\ny\n{% endif %}";
const s = state(doc);
const lineStart = (n: number) => s.doc.line(n).from;
expect(getIndentation(s, lineStart(2))).toBe(2);
expect(getIndentation(s, lineStart(3))).toBe(0);
expect(getIndentation(s, lineStart(5))).toBe(0);
});
it("matches a block tag with its end tag by name", () => {
const doc = "{% for x in xs %}{{ x }}{% endfor %}";
const match = matchBrackets(state(doc), 4, 1);
expect(match?.matched).toBe(true);
expect(doc.slice(match!.end!.from, match!.end!.to)).toBe("endfor");
});
it("closes {% %}, {{ }} and {# #} after a brace", () => {
expect(type(view("<p>{|</p>"), "%")).toBe("<p>{% | %}</p>");
expect(type(view("<p>{|}</p>"), "{")).toBe("<p>{{ | }}</p>");
expect(type(view("{|"), "#")).toBe("{# | #}");
// Not in a script, where `{` is JavaScript's.
expect(type(view("<script>let a = {|</script>"), "{")).toBe("<script>let a = {{|</script>");
});
it("adds the end tag of a block tag when its %} is typed", () => {
expect(type(view("{% block content %|"), "}")).toBe("{% block content %}|{% endblock %}");
expect(type(view("{% if a %|}"), "}")).toBe("{% if a %}|{% endif %}");
// It has one already.
expect(type(view("{% if a %|\n{% endif %}"), "}")).toBe("{% if a %}|\n{% endif %}");
expect(type(view("{% if a %|{% if b %}{% endif %}"), "}")).toBe("{% if a %}|{% endif %}{% if b %}{% endif %}");
// Not a block tag.
expect(type(view("{% csrf_token %|"), "}")).toBe("{% csrf_token %}|");
});
it("completes the end tags of the open blocks, innermost first", () => {
const doc = "{% block a %}{% for x in y %}{% end";
const s = state(doc);
const sources = s.languageDataAt<CompletionSource>("autocomplete", doc.length);
const result = sources.map((src) => src(new CompletionContext(s, doc.length, false))).find(Boolean) as
| CompletionResult
| undefined;
expect(result?.options.map((o) => o.label)).toEqual(["endfor", "endblock"]);
});
it("comments each line with {# #}, and several lines with a comment tag", () => {
const v = view("|<p>a</p>\n <p>b</p>");
v.dispatch({ selection: EditorSelection.range(0, v.state.doc.length) });
commentLine(v);
expect(v.state.doc.toString()).toBe("{# <p>a</p> #}\n {# <p>b</p> #}");
v.dispatch({ selection: EditorSelection.range(0, v.state.doc.length) });
commentLine(v);
expect(v.state.doc.toString()).toBe("<p>a</p>\n <p>b</p>");
v.dispatch({ selection: EditorSelection.range(0, v.state.doc.length) });
commentBlock(v);
expect(v.state.doc.toString()).toBe("{% comment %}<p>a</p>\n <p>b</p>{% endcomment %}");
commentBlock(v);
expect(v.state.doc.toString()).toBe("<p>a</p>\n <p>b</p>");
});
it("is the Django Template mode", async () => {
const desc = findLanguageDescription("Django Template", "/p/templates/index.html");
expect((await loadSupport(desc!))?.language).toBe(djangoLanguage);
});
});
+223
Переглянути файл
@@ -0,0 +1,223 @@
/**
* Django templates: `django.grammar` parses the template layer (tags,
* variables, comments), and the text between its elements is one HTML
* document, an overlay as in JSP, so markup split by a tag still nests and
* `href="{% url 'home' %}"` is an attribute with a hole. Block tags fold,
* indent their content and match their end tags; typing `{%`, `{{` or `{#`
* closes the pair, and finishing a block tag adds its end tag.
*/
import {
LRLanguage,
LanguageSupport,
bracketMatchingHandle,
foldNodeProp,
indentNodeProp,
syntaxTree,
type TreeIndentContext,
} from "@codemirror/language";
import { html } from "@codemirror/lang-html";
import type { CompletionContext, CompletionResult } from "@codemirror/autocomplete";
import { EditorSelection, Prec, type EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { styleTags, tags as t } from "@lezer/highlight";
import { NodeProp, parseMixed, type SyntaxNode, type SyntaxNodeRef } from "@lezer/common";
import { parser } from "./parser";
import { BLOCK_TAGS } from "./tokens";
export { BLOCK_TAGS };
const baseHTML = html({ selfClosingTags: true });
/** `{# #}` can't span lines: a multi-line block comment is a `comment` tag. */
const DJANGO_COMMENT = { block: { open: "{#", close: "#}" } };
const COMMENT_TAG = { open: "{% comment %}", close: "{% endcomment %}" };
const OPENERS = ["OpenTag", "CommentTag", "VerbatimTag"];
const startTagOf = (element: SyntaxNode) => element.firstChild;
const endTagOf = (element: SyntaxNode) => (element.lastChild?.name == "EndTag" ? element.lastChild : null);
/** The content of a block: from the end of its start tag to its end tag (or the end of the block). */
function foldBlock(element: SyntaxNode, state: EditorState) {
const start = startTagOf(element);
const end = endTagOf(element);
if (!start) return null;
// An end tag the parser inserted to recover is empty.
const to = end && end.to > end.from ? end.from : element.to;
return start.to < to && state.doc.lineAt(start.to).number < state.doc.lineAt(to).number
? { from: start.to, to }
: null;
}
const CLOSING_LINE = /^\s*\{%-?\s*(end\w*|elif|else|empty|plural)\b/;
/** A block's content one unit in from its start tag's line; its middle and end tags at that line's indent. */
function indentBlock(cx: TreeIndentContext) {
const start = startTagOf(cx.node);
if (!start) return null;
const base = cx.lineIndent(start.from);
return CLOSING_LINE.test(cx.textAfter) ? base : base + cx.unit;
}
const tagName = (node: SyntaxNode) => node.getChild("TagName");
const templateParser = parser.configure({
props: [
styleTags({
'TagStart OpenTagStart CommentTagStart VerbatimTagStart MidTagStart EndTagStart TagEnd "{{" "}}"': t.special(
t.brace,
),
TagName: t.keyword,
"Comment CommentBody": t.blockComment,
VariableName: t.variableName,
PropertyName: t.propertyName,
FilterName: t.function(t.variableName),
FilterOp: t.operator,
String: t.string,
Number: t.number,
BooleanLiteral: t.bool,
None: t.null,
CompareOp: t.compareOperator,
AssignOp: t.definitionOperator,
"and or not in is": t.operatorKeyword,
"as with only from": t.keyword,
'"."': t.derefOperator,
'":"': t.punctuation,
}),
foldNodeProp.add({
"Element CommentElement VerbatimElement": foldBlock,
}),
indentNodeProp.add({
Element: indentBlock,
// The raw content keeps its own indentation.
"CommentElement VerbatimElement": () => null,
}),
NodeProp.closedBy.add({ "OpenTag CommentTag VerbatimTag": ["EndTag"] }),
NodeProp.openedBy.add({ EndTag: OPENERS }),
bracketMatchingHandle.add({ "OpenTag CommentTag VerbatimTag EndTag": tagName }),
],
});
// HTML's document gives no indentation past its last text; there the block around it does.
const htmlBase = (baseHTML.language as LRLanguage).parser;
const htmlDocumentIndent = htmlBase.nodeSet.types.find((type) => type.name == "Document")?.prop(indentNodeProp);
const htmlParser = htmlBase.configure({
props: [indentNodeProp.add({ Document: (cx) => htmlDocumentIndent?.(cx) ?? cx.continue() })],
});
// The template text, and the content of `verbatim`, are one HTML document.
const htmlMixed = {
parser: htmlParser,
overlay: (node: SyntaxNodeRef) => node.name == "Text" || node.name == "VerbatimBody",
};
export const djangoLanguage = LRLanguage.define({
name: "django",
parser: templateParser.configure({ wrap: parseMixed((node) => (node.type.isTop ? htmlMixed : null)) }),
});
/** Where the template layer reaches: Django's own nodes, or the HTML outside `<script>` and `<style>`. */
function inTemplate(state: EditorState, pos: number): boolean {
return state.languageDataAt<boolean>("djangoTemplate", pos).includes(true);
}
const PAIRS: Record<string, string> = { "%": "%", "{": "}", "#": "#" };
/**
* After a `{`, typing `%`, `{` or `#` makes the pair: `{% | %}`, `{{ | }}`, `{# | #}`. The
* `}` that closing brackets put after the first brace is reused.
*/
const closePair = EditorView.inputHandler.of((view, from, to, text) => {
const close = PAIRS[text];
const { state } = view;
if (!close || from != to || state.selection.ranges.length > 1 || state.readOnly) return false;
if (state.sliceDoc(from - 1, from) != "{" || state.sliceDoc(from - 2, from - 1) == "{") return false;
if (!inTemplate(state, from)) return false;
const reuse = state.sliceDoc(from, from + 1) == "}";
const insert = `${text} ${close}${reuse ? "" : "}"}`;
view.dispatch({
changes: { from, insert },
selection: EditorSelection.cursor(from + text.length + 1),
userEvent: "input.type",
});
return true;
});
const TAG_BEFORE = /\{%-?\s*([A-Za-z_]\w*)[^%]*%$/;
/** Whether the block `name` opened before `pos` has no end tag after it, nested blocks counted. */
function unclosed(state: EditorState, name: string, pos: number): boolean {
const rest = state.sliceDoc(pos);
const re = new RegExp(`\\{%-?\\s*(end)?${name}\\b`, "g");
let depth = 0;
for (let m; (m = re.exec(rest));) {
if (!m[1]) depth++;
else if (depth-- == 0) return false;
}
return true;
}
/** Typing the `}` of a block tag's `%}` adds its end tag after the caret when it has none. */
const closeBlock = EditorView.inputHandler.of((view, from, to, text) => {
const { state } = view;
if (text != "}" || from != to || state.selection.ranges.length > 1 || state.readOnly) return false;
const line = state.doc.lineAt(from);
const m = TAG_BEFORE.exec(state.sliceDoc(line.from, from));
const name = m?.[1];
if (!name || !BLOCK_TAGS.has(name) || !inTemplate(state, from - m[0].length)) return false;
// A `}` closing brackets put there is typed over.
const after = state.sliceDoc(from, from + 1) == "}" ? 1 : 0;
if (!unclosed(state, name, from + after)) return false;
view.dispatch({
changes: { from, to: from + after, insert: `}{% end${name} %}` },
selection: EditorSelection.cursor(from + 1),
userEvent: "input.type",
});
return true;
});
/** The blocks open at `pos`, innermost first. */
function openBlocks(state: EditorState, pos: number): string[] {
const names: string[] = [];
for (let node: SyntaxNode | null = syntaxTree(state).resolve(pos, -1); node; node = node.parent) {
if (!/^(Element|CommentElement|VerbatimElement)$/.test(node.name)) continue;
const start = startTagOf(node);
const name = start && tagName(start);
if (start && name && start.to <= pos) names.push(state.sliceDoc(name.from, name.to));
}
return names;
}
/** `{% end…`: the end tags of the blocks open at the caret, innermost first. */
function endTagCompletion(context: CompletionContext): CompletionResult | null {
const word = context.matchBefore(/\{%-?\s*\w*$/);
if (!word) return null;
const name = /\w*$/.exec(word.text)![0];
if (!name && !context.explicit) return null;
const from = word.to - name.length;
// Template syntax, not UI text.
const ends = openBlocks(context.state, from).map((block) => `end${block}`);
const options = ends.map((end, i) => ({ label: end, type: "keyword", boost: 99 - i }));
return options.length ? { from, options, validFor: /^\w*$/ } : null;
}
export function django(): LanguageSupport {
return new LanguageSupport(djangoLanguage, [
baseHTML.support,
// Markup is commented out with a template comment, which Django drops from the page.
baseHTML.language.data.of({ commentTokens: DJANGO_COMMENT, lineBoundComments: { multiline: COMMENT_TAG } }),
baseHTML.language.data.of({ djangoTemplate: true, autocomplete: endTagCompletion }),
djangoLanguage.data.of({
djangoTemplate: true,
autocomplete: endTagCompletion,
commentTokens: DJANGO_COMMENT,
lineBoundComments: { multiline: COMMENT_TAG },
}),
// A multi-line block comment, after `{# #}`, so that taking one out finds it.
Prec.lowest([
djangoLanguage.data.of({ commentTokens: { block: COMMENT_TAG } }),
baseHTML.language.data.of({ commentTokens: { block: COMMENT_TAG } }),
]),
Prec.high([closePair, closeBlock]),
]);
}
+45
Переглянути файл
@@ -0,0 +1,45 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
export const
Comment = 1,
CommentBody = 2,
VerbatimBody = 3,
Text = 4,
OpenTagStart = 5,
CommentTagStart = 6,
VerbatimTagStart = 7,
MidTagStart = 8,
EndTagStart = 9,
TagStart = 10,
Template = 11,
Interpolation = 14,
VariableName = 15,
MemberExpression = 16,
PropertyName = 18,
Number = 19,
String = 20,
BooleanLiteral = 21,
None = 22,
FilterExpression = 23,
FilterOp = 24,
FilterName = 25,
Tag = 27,
TagName = 28,
_with = 29,
CompareOp = 30,
and = 33,
or = 34,
not = 35,
_in = 36,
is = 37,
as = 38,
only = 39,
from = 40,
TagEnd = 41,
Element = 42,
OpenTag = 43,
MidTag = 44,
EndTag = 45,
CommentElement = 46,
CommentTag = 47,
VerbatimElement = 48,
VerbatimTag = 49
+25
Переглянути файл
@@ -0,0 +1,25 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import {LRParser} from "@lezer/lr"
import {comment, commentBody, verbatimBody, text, tagStart, blocks} from "./tokens"
const spec_identifier = {__proto__:null,True:42, False:42, None:44, with:58, and:66, or:68, not:70, in:72, is:74, as:76, only:78, from:80}
export const parser = LRParser.deserialize({
version: 14,
states: "+WQVO!ZOOO!PQpO'#CjO!WQpO'#CwO!WQpO'#DXO!`O!ZO'#DWO!WQpO'#D]O!jObO'#D[O!WQpO'#D_O!rOdO'#D^OOOi'#Df'#DfOOOi'#D`'#D`QVO!ZOOOOQO'#Ck'#CkO!zQpO'#DgO$[QpO,59UOOQO'#Dg'#DgOOOi,59U,59UOOQO'#Cx'#CxO$dQpO,59cO$dQpO,59sO!WQpO'#DYO%^O!ZO'#DbO%hO`O,59rO!WQpO'#DZOOOi,59r,59rO!`O!ZO,59rO$dQpO,59wOOOi,59v,59vO!mO`O,59vO$dQpO,59yOOOi,59x,59xO!mO`O,59xOOOi-E7^-E7^O%pQpO,59WOOQO'#Ct'#CtO%xQpO,59_OOOi1G.p1G.pO%}QpO'#DlOOQO'#C{'#C{OOQO'#Dl'#DlOOQO'#Da'#DaO$dQpO1G.}OOOo'#DV'#DVOOOi1G.}1G.}O$dQpO1G/_OOOi1G/_1G/_O$dQpO,59tO'XO!ZO,59|OOO`-E7`-E7`OOOi1G/^1G/^O$dQpO,59uO%hO`O1G/^O$dQpO1G/cOOOb1G/c1G/cOOOi1G/b1G/bO$dQpO1G/eOOOd1G/e1G/eOOOi1G/d1G/dOOQO'#Cn'#CnOOQO1G.r1G.rOOQO'#Cu'#CuO'cQpO1G.yOOQO-E7_-E7_OOOi7+$i7+$iOOOi7+$y7+$yO$dQpO1G/`OOOi1G/`1G/`O$dQpO1G/aOOOi1G/a1G/aOOOi7+$x7+$xOOOb7+$}7+$}OOOd7+%P7+%PO(sQpO7+$eOOOi7+$z7+$zOOOi7+${7+${O)OQpO'#DjOOQO'#Dj'#DjOOQO<<HP<<HP",
stateData: "*c~O!XOS~OPXOSXOTROUTOVVOYQO]PO~Oc_Od_Oe_Of_O![[O~O[`O~PnOmaO![aO~OWdOXgO~PVOQlOXgO~ORoOXgO~OaqO[!ZX!]!ZXc!ZXd!ZXe!ZXf!ZXm!ZXn!ZXp!ZXq!ZXr!ZXs!ZXt!ZXu!ZXv!ZXw!ZXx!ZX![!ZX!_!ZX~O[tO!]rO~OmwOnwOpvOqwOrwOswOtwOuwOvwOwwOxwO!_zO~PnOW!UXX!UX~PVOWdOXgO~Oc!]O![![O~O![!^O~O!]rOc!`Xd!`Xe!`Xf!`Xm!`Xn!`Xp!`Xq!`Xr!`Xs!`Xt!`Xu!`Xv!`Xw!`Xx!`X![!`X!_!`X~OW!UaX!Ua~PVOj!jO[gi!]gicgidgiegifgimgingipgiqgirgisgitgiugivgiwgixgi![gi!_gi~Oc!nOd!nO![[O~OaqO[!^X!]!^Xc!^Xd!^Xe!^Xf!^Xm!^Xn!^Xp!^Xq!^Xr!^Xs!^Xt!^Xu!^Xv!^Xw!^Xx!^X![!^X!_!^X~Onp~",
goto: "(T!aPPPPPPPPPPPPPP!b!j!jP!|PPPP#P#`#dP!b#gPP#yPPPPPPPPP$X!b$}%V%]!b%s!b%{&T&c&}PPP'X'aPP'rP'u]XOSZei!Pj]Pbcjmy|!O!S!U!X!c!eR!m!jR!]qk_Pbcjmy|!O!S!U!X!c!eTs^uR!_sQbQQcRQjTQmVQ!OdR!Sgiwbcjmy|!O!S!U!X!c!eQ{bQ}cQ!VjQ!YmQ!ayQ!b|Q!d!OQ!f!SQ!h!UQ!i!XQ!k!cR!l!e]SOSZei!PXeSfi!TQhSQkUQnWS!RfiQ!WlQ!ZoR!g!T]UOSZei!P]WOSZei!PQZOQiSUpZi!PR!PeQybQ|cQ!UjQ!Xm[!`y|!U!X!c!eQ!c!OR!e!SQfSS!Qf!TR!Ti]YOSZei!PQ^Piubcjmy|!O!S!U!X!c!eR!o!jixbcjmy|!O!S!U!X!c!e",
nodeNames: "⚠ Comment CommentBody VerbatimBody Text OpenTagStart CommentTagStart VerbatimTagStart MidTagStart EndTagStart TagStart Template }} {{ Interpolation VariableName MemberExpression . PropertyName Number String BooleanLiteral None FilterExpression FilterOp FilterName : Tag TagName with CompareOp AssignOp = and or not in is as only from TagEnd Element OpenTag MidTag EndTag CommentElement CommentTag VerbatimElement VerbatimTag",
maxTerm: 62,
context: blocks,
nodeProps: [
["openedBy", 12,"{{"],
["closedBy", 13,"}}"],
["group", -7,15,16,19,20,21,22,23,"Expression"]
],
skippedNodes: [0],
repeatNodeCount: 3,
tokenData: ")]~RhXY!mYZ!m]^!mpq!mqr#Ors#Zuv${wx%W}!O&s!O!P'd!Q![&y![!]'i!^!_'n!_!`'v!`!a'n!c!}(O#R#S(O#T#o(O#o#p(p#p#q({#q#r)Q$g;'S(O;'S;=`(j<%lO(O~!rS!X~XY!mYZ!m]^!mpq!m~#RP!_!`#U~#ZOn~~#`Vd~Or#Zrs#us#O#Z#O#P#z#P;'S#Z;'S;=`$u<%lO#Z~#zOd~~#}RO;'S#Z;'S;=`$W;=`O#Z~$]Wd~Or#Zrs#us#O#Z#O#P#z#P;'S#Z;'S;=`$u;=`<%l#Z<%lO#Z~$xP;=`<%l#Z~%OP#q#r%R~%WO!_~~%]Vd~Ow%Wwx#ux#O%W#O#P%r#P;'S%W;'S;=`&m<%lO%W~%uRO;'S%W;'S;=`&O;=`O%W~&TWd~Ow%Wwx#ux#O%W#O#P%r#P;'S%W;'S;=`&m;=`<%l%W<%lO%W~&pP;=`<%l%W~&vP!Q![&y~'OQc~!O!P'U!Q![&y~'XP!Q!['[~'aPc~!Q!['[~'iOa~~'nOj~~'sPn~!_!`#U~'{Pp~!_!`#U~(TV![~!Q![(O!c!}(O#R#S(O#T#o(O$g;'S(O;'S;=`(j<%lO(O~(mP;=`<%l(O~(sP#o#p(v~({O]~~)QO!]~~)TP#q#r)W~)]O[~",
tokenizers: [comment, commentBody, verbatimBody, text, tagStart, 0],
topRules: {"Template":[0,11]},
specialized: [{term: 58, get: (value: keyof typeof spec_identifier) => spec_identifier[value] || -1}],
tokenPrec: 430
})
+152
Переглянути файл
@@ -0,0 +1,152 @@
/**
* External tokens of `django.grammar`: the text between template elements, the
* comments, the raw content of `comment` and `verbatim`, and the start of each
* tag, classified by its name against the blocks open around it.
*/
import { ContextTracker, ExternalTokenizer, type InputStream, type Stack } from "@lezer/lr";
import {
Comment,
CommentBody,
CommentElement,
CommentTagStart,
Element,
EndTagStart,
MidTagStart,
OpenTagStart,
TagStart,
Text,
VerbatimBody,
VerbatimElement,
VerbatimTagStart,
} from "./parser.terms";
const BRACE_L = 123,
BRACE_R = 125,
HASH = 35,
PERCENT = 37;
/**
* Block tags of Django and its contrib libraries (`i18n`, `l10n`, `tz`, `cache`), with the
* middle tags each one allows.
*/
export const BLOCK_TAGS: ReadonlyMap<string, readonly string[]> = new Map([
["autoescape", []],
["block", []],
["blocktrans", ["plural"]],
["blocktranslate", ["plural"]],
["cache", []],
["comment", []],
["filter", []],
["for", ["empty"]],
["if", ["elif", "else"]],
["ifchanged", ["else"]],
["language", []],
["localize", []],
["localtime", []],
["partialdef", []],
["spaceless", []],
["timezone", []],
["verbatim", []],
["with", []],
]);
/** The block tags open around a position, innermost first. */
class Block {
readonly hash: number;
constructor(
readonly name: string,
readonly parent: Block | null,
) {
let hash = parent ? parent.hash : 0;
for (let i = 0; i < name.length; i++) hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0;
this.hash = hash;
}
}
const isNameStart = (ch: number) => (ch >= 65 && ch <= 90) || (ch >= 97 && ch <= 122) || ch == 95;
const isName = (ch: number) => isNameStart(ch) || (ch >= 48 && ch <= 57);
const isSpace = (ch: number) => ch == 32 || ch == 9 || ch == 10 || ch == 13;
/** The name of a tag whose `{%` is `offset` code units ahead. */
function tagName(input: InputStream, offset = 0): string {
let i = offset + 2;
while (isSpace(input.peek(i))) i++;
let name = "";
for (let ch; isName((ch = input.peek(i))); i++) {
if (!name && !isNameStart(ch)) break;
name += String.fromCharCode(ch);
}
return name;
}
const tagAt = (input: InputStream, offset = 0) => input.peek(offset) == BRACE_L && input.peek(offset + 1) == PERCENT;
/** Whether `{{`, `{%` or `{#` starts `offset` code units ahead. */
function elementStart(input: InputStream, offset = 0): boolean {
if (input.peek(offset) != BRACE_L) return false;
const next = input.peek(offset + 1);
return next == BRACE_L || next == PERCENT || next == HASH;
}
export const blocks = new ContextTracker<Block | null>({
start: null,
shift(context, term, _stack, input) {
return term == OpenTagStart || term == CommentTagStart || term == VerbatimTagStart
? new Block(tagName(input), context)
: context;
},
reduce(context, term) {
return (term == Element || term == CommentElement || term == VerbatimElement) && context ? context.parent : context;
},
hash: (context) => (context ? context.hash : 0),
strict: false,
});
/** `{# … #}`, to the end of the line when unterminated (it can't span lines). */
export const comment = new ExternalTokenizer((input) => {
if (input.next != BRACE_L || input.peek(1) != HASH) return;
input.advance(2);
for (let ch; (ch = input.peek(0)) >= 0 && ch != 10;) {
if (ch == HASH && input.peek(1) == BRACE_R) {
input.advance(2);
break;
}
input.advance();
}
input.acceptToken(Comment);
});
/** Template text: up to a tag, a variable or a comment. */
export const text = new ExternalTokenizer((input) => {
const start = input.pos;
while (input.next >= 0 && !elementStart(input)) input.advance();
if (input.pos > start) input.acceptToken(Text);
});
/** The `{%` of a tag, a token telling what the tag is in the blocks open around it. */
export const tagStart = new ExternalTokenizer((input, stack: Stack) => {
if (!tagAt(input)) return;
const name = tagName(input);
const open: Block | null = stack.context;
let term = TagStart;
if (name == "comment") term = CommentTagStart;
else if (name == "verbatim") term = VerbatimTagStart;
else if (BLOCK_TAGS.has(name)) term = OpenTagStart;
else if (open && name == `end${open.name}`) term = EndTagStart;
else if (open && BLOCK_TAGS.get(open.name)?.includes(name)) term = MidTagStart;
if (!stack.canShift(term)) term = TagStart;
if (stack.canShift(term)) input.acceptToken(term, 2);
});
/** The content of a raw block: up to the `{% end<name>` closing it. */
function rawBody(name: string, token: number) {
const end = `end${name}`;
return new ExternalTokenizer((input) => {
const start = input.pos;
while (input.next >= 0 && !(tagAt(input) && tagName(input) == end)) input.advance();
if (input.pos > start) input.acceptToken(token);
});
}
export const commentBody = rawBody("comment", CommentBody);
export const verbatimBody = rawBody("verbatim", VerbatimBody);
+15
Переглянути файл
@@ -33,6 +33,21 @@ describe("goModParser", () => {
]);
});
it("highlights tool, godebug and ignore directives", () => {
const state = initialGoModState();
expect(tokenize("tool golang.org/x/tools/cmd/stringer", state)).toEqual([
{ text: "tool", token: "keyword" },
{ text: "golang.org/x/tools/cmd/stringer", token: "string" },
]);
expect(tokenize("tool (", state)[0]).toEqual({ text: "tool", token: "keyword" });
expect(tokenize("\tgithub.com/air-verse/air", state)).toEqual([
{ text: "github.com/air-verse/air", token: "string" },
]);
tokenize(")", state);
expect(tokenize("godebug default=go1.21", state)[0]).toEqual({ text: "godebug", token: "keyword" });
expect(tokenize("ignore ./node_modules", state)[0]).toEqual({ text: "ignore", token: "keyword" });
});
it("highlights require blocks with module paths and versions", () => {
const state = initialGoModState();
+14 -1
Переглянути файл
@@ -1,6 +1,19 @@
import { StreamLanguage, type StreamParser } from "@codemirror/language";
const KEYWORDS = new Set(["module", "go", "toolchain", "require", "replace", "exclude", "retract", "use"]);
/** go.mod and go.work directives (`tool` since Go 1.24, `ignore` since Go 1.25). */
const KEYWORDS = new Set([
"module",
"go",
"toolchain",
"godebug",
"require",
"replace",
"exclude",
"retract",
"tool",
"ignore",
"use",
]);
export interface GoModState {
currentKeyword: string | null;
+6
Переглянути файл
@@ -131,6 +131,12 @@ const OWN_MODES = [
load: () => import("./erb").then((m) => m.erb()),
}),
LanguageDescription.of({ name: "JSP", load: () => import("./jsp").then((m) => m.jsp()) }),
LanguageDescription.of({
name: "Django Template",
alias: ["django-html", "djhtml"],
extensions: ["djhtml"],
load: () => import("./django").then((m) => m.django()),
}),
LanguageDescription.of({
name: "Astro",
extensions: ["astro"],
+3
Переглянути файл
@@ -148,6 +148,9 @@ describe("custom editor document lifetime", () => {
title: "Class",
description: null,
decompiled: true,
encoding: "utf-8",
hasBom: false,
canReloadEncoding: false,
});
await rejected;
expect(findTab(useTabs.getState(), "virtual:jdt://old/Class")).toBeNull();
+3
Переглянути файл
@@ -86,6 +86,8 @@ export interface DocMeta {
lineEnding: LineEnding;
readOnly: boolean;
dirty: boolean;
/** "Reload with Encoding" can re-decode this document's source bytes. */
canReloadEncoding: boolean;
banner: Banner;
indent: { tabSize: number; insertSpaces: boolean };
}
@@ -254,6 +256,7 @@ export class DocModel {
// Library sources (External Libraries) open read-only, like IDEA's.
readOnly: info.readOnly || (!this.virtual && isLibraryPath(info.path)),
dirty: info.dirty,
canReloadEncoding: info.canReloadEncoding,
banner: bannerFor(info),
indent: { tabSize: 4, insertSpaces: true },
};
+76
Переглянути файл
@@ -28,6 +28,8 @@ class FakeServer {
edits: Array<{ base: number; changes: TextChange[] }> = [];
private queue: Array<() => void> = [];
snapshots = 0;
/** Snapshots still to reject (a transient IPC or remote-host failure). */
failSnapshots = 0;
failNext: unknown = null;
constructor(public text: string) {}
@@ -52,6 +54,10 @@ class FakeServer {
}),
snapshot: async () => {
this.snapshots++;
if (this.failSnapshots > 0) {
this.failSnapshots--;
throw { kind: "unavailable", message: "snapshot failed" };
}
return { text: this.text, version: this.version };
},
};
@@ -218,6 +224,76 @@ describe("DocSync", () => {
}
});
it("keeps syncing when the snapshot of a rebase fails once", async () => {
vi.useFakeTimers();
try {
const { server, view, sync, type, frame, host } = setup("one\ntwo\n");
type(8, 8, "three\n");
frame();
server.patch([{ from: 0, to: 3, insert: "ONE" }]); // event lost
server.failSnapshots = 1;
let idle = false;
void sync.whenIdle().then(() => (idle = true));
for (let i = 0; i < 4; i++) {
await server.processAll();
await vi.advanceTimersByTimeAsync(100);
}
expect(server.snapshots).toBe(2);
expect(host.onError).toHaveBeenCalledWith(expect.objectContaining({ kind: "unavailable" }));
expect(server.text).toBe("ONE\ntwo\nthree\n");
expect(view.text.toString()).toBe(server.text);
expect(sync.idle).toBe(true);
expect(idle).toBe(true);
expect(host.onDiscarded).not.toHaveBeenCalled();
// Later typing still reaches the backend.
type(0, 0, "#");
frame();
await server.processAll();
expect(server.text).toBe("#ONE\ntwo\nthree\n");
expect(sync.idle).toBe(true);
} finally {
vi.useRealTimers();
}
});
it("stops retrying when every snapshot fails, and keeps the local text to send later", async () => {
vi.useFakeTimers();
try {
const { server, view, sync, type, frame, host } = setup("one\ntwo\n");
type(8, 8, "three\n");
frame();
server.patch([{ from: 0, to: 3, insert: "ONE" }]); // event lost
server.failSnapshots = Infinity;
let idle = false;
void sync.whenIdle().then(() => (idle = true));
for (let i = 0; i < 20 && !idle; i++) {
await server.processAll();
await vi.advanceTimersByTimeAsync(100);
}
// A save waiting on the document is released instead of hanging, and nothing is in flight.
expect(idle).toBe(true);
expect(sync.inflight).toBeNull();
expect(server.pending()).toBe(0);
expect(host.onError).toHaveBeenCalled();
expect(view.text.toString()).toBe("one\ntwo\nthree\n");
// Once the backend answers again, the local text and new typing land where they belong.
server.failSnapshots = 0;
type(0, 0, "#");
frame();
for (let i = 0; i < 4; i++) {
await server.processAll();
await vi.advanceTimersByTimeAsync(100);
}
expect(server.text).toBe("#ONE\ntwo\nthree\n");
expect(view.text.toString()).toBe(server.text);
expect(sync.idle).toBe(true);
} finally {
vi.useRealTimers();
}
});
it("hard-resyncs on unexpected errors and reports discarded changes", async () => {
const { server, view, sync, type, frame, host } = setup("abc");
server.failNext = { kind: "permissionDenied", message: "read-only" };
+10 -3
Переглянути файл
@@ -251,9 +251,11 @@ export class DocSync {
try {
snap = await this.transport.snapshot();
} catch (e) {
if (gen !== this.generation || this.disposed) return;
this.host.onError?.(e);
this.resyncing = false;
this.resolveIdle(true);
// Keep the edit going: the backend acks it once it caught up, or rejects it again,
// which retries the snapshot (a hard resync after MAX_REBASES).
this.resend();
return;
}
if (gen !== this.generation || this.disposed) return;
@@ -313,8 +315,13 @@ export class DocSync {
} catch (e) {
if (gen !== this.generation) return;
this.host.onError?.(e);
// Nothing to adopt: keep the local text as pending changes against `confirmed`, so the
// next flush sends them in the coordinates the backend knows.
const pending = minimalChanges(this.confirmed.toString(), this.localText.toString());
this.inflight = null;
this.buffer = null;
this.buffer = pending.length ? ChangeSet.of(pending, this.confirmed.length) : null;
this.inflightStale = false;
this.rebases = 0;
this.resyncing = false;
this.resolveIdle(true);
return;
Loaded 100 of 183 files, more files were not shown because too many files have changed in this diff. Show more