Compare commits

...
Author SHA1 Message Date
l0ng-ai 961344c0f9 fix(debugger): run debug adapters on the tool search path
Debug adapters were found on the tool search path but ran with the
app's own PATH. When the login shell's PATH didn't reach the app, what an
adapter starts in turn was missing although Run found it: js-debug's
wrapper failed with "exec: node: not found", and Delve's go build with
"exec: go: executable file not found in $PATH". Adapters now get the
search path language servers get, unless the launch sets its own PATH
(a selected SDK's bin first), and adapter binaries are resolved on it.

Fixes #1690
Fixes #1707
2026-11-06 14:40:08 +08:00
l0ng-ai f76c297fe9 fix(terminal): keep Windows shell paths intact (#1723)
The terminal's Shell path setting was split with POSIX rules, where a backslash escapes the next character, so C:\Windows\system32\cmd.exe reached the backend as C:Windowssystem32cmd.exe and every new terminal failed to start. On Windows a backslash is now a path separator, and an unquoted path up to its .exe/.cmd/.bat/.com is the program even with spaces; paths picked from the Installed Shells menu or Browse are quoted when they contain whitespace. This was the only place the setting is parsed.

Fixes #1327
2026-11-06 14:24:59 +08:00
l0ng-ai c75a630ede test(vcs): check the files a commit takes from its options
Commits take the checked files through vcs_commit's options instead of
staging them first; the Commit e2e tests now read them from there. Also
translates "Remote connection cancelled".
2026-11-06 14:14:44 +08:00
l0ng-ai 266e4eeb03 feat(updates): choose the update source, with a CNB mirror (#1673)
* feat(updates): choose the update source, with a CNB mirror

System Settings › Updates › Update source: Automatic, GitHub or CNB (China
mirror). Automatic uses CNB for the Chinese UI and GitHub otherwise. The
chosen source is tried first and the other one when it fails or has no
manifest, for the check, the startup check and the install.

* feat(updates): offer the other update source after a failed download

A failed or cancelled update download now offers "Retry with CNB" (or
"Retry with GitHub"): it switches the update source and downloads again
from there. Checks need no such action: they already try both sources.

* fix(updates): keep GitHub untranslated in the text catalog
2026-11-06 13:30:19 +08:00
l0ng-ai c1aa092d75 fix(tools): install GitHub releases when the API is rate-limited (#1715)
Language servers and debuggers published as GitHub releases were looked up only through api.github.com, which allows 60 unauthenticated requests an hour per IP address; behind a shared address installs failed with 403. When the API request fails, the release is now resolved from its web pages (latest-release redirect for the tag, the asset list for files and SHA-256 digests, the release notes for linked builds). This covers every GitHub-sourced tool, not only Kotlin. If both fail, the error names the release page and the Language Servers Path setting for a manual install.

Fixes #1651
2026-11-06 13:24:59 +08:00
Lee Dong Geon b370bb0564 perf(debugger): send each file's breakpoints together and redraw the gutter less (#1679)
At session start and around Force Step Over / Force Run to Cursor, setBreakpoints went out one file at a time, each waiting for the previous answer, so every file with breakpoints added a round trip before the program ran or the stop was handled. The requests now go out together, and files whose breakpoints are all disabled are skipped (a fresh adapter has none). The breakpoint gutter now rebuilds only when its own file's breakpoint states change or a session starts or ends, not on every step.

Fixes #1677

Co-authored-by: ldg030201 <[email protected]>
2026-11-06 13:17:54 +08:00
l0ng-ai c8859b4d3f ci(release): mirror published releases to CNB (#1683)
After a release is published, copy its assets to cnb.cool/l0ng-ai/openidea and
upload a latest.json whose bundle URLs point there, last, so the mirror never
names a bundle it lacks. Keeps the newest 10 releases. The CNB mirror workflow
can also be run by hand for an existing tag, and is skipped without CNB_TOKEN.
2026-11-06 13:17:43 +08:00
Lee Dong Geon ff187a10f6 fix(debugger): put no breakpoint on blank or comment-only lines (#1678)
A gutter click, Toggle Line Breakpoint or Toggle Temporary Line Breakpoint on a blank or comment-only line added a breakpoint the debugger can never stop at. Those lines now take no new breakpoint; comments come from the syntax tree, so this works for every language with a parser, and only blank lines are skipped otherwise. An existing breakpoint on such a line can still be removed.

Fixes #1676

Co-authored-by: ldg030201 <[email protected]>
2026-11-06 13:13:50 +08:00
Jaw d984695e79 fix(project-tree): search a file's own symbols, not its package, in Safe Delete and Find Usages (#1688)
Safe Delete and Find Usages on a file searched every top-level outline item, including a Java `package` declaration. References to the package are imports of any class in it, so deleting an unused class reported its siblings' usages as its own.

Packages and namespaces are shared with other files, so their members are searched in their place (Java packages, C++/C#/PHP namespaces). Modules a file declares itself (a TypeScript namespace, a Rust `mod`) are still searched as before.

Fixes #1656

Co-authored-by: jaw52 <[email protected]>
2026-11-06 13:11:16 +08:00
Deng Liuanddengliu b7d886ea34 fix(welcome): allow cancelling project reopen and prevent startup reopen loop (#1706)
Reopening the last project at startup had no way out: a slow or unreachable remote host left the Opening screen spinning with no Cancel, and force-quitting kept the project remembered, so the next launch hung on it again.

The remembered project is now cleared before reopening and written back only once it has opened, and the Opening screen has a Cancel button that disconnects a remote connection and returns to the Welcome screen. A local open that finishes after Cancel is closed again, unless another project was opened meanwhile.

Co-authored-by: dengliu <[email protected]>
2026-11-06 13:10:09 +08:00
l0ng-ai 09c0af504e perf(agents): switch back to a long conversation without rebuilding it (#1710)
Agent task tabs now stay mounted while another tab is active (the five most
recently used per pane). A hidden one skips rendering with
content-visibility: hidden, which keeps its layout and scroll offset, so
showing it again costs a few milliseconds instead of building and laying out
the whole conversation. Hidden tabs don't mark their task read and close Find.

Thinking blocks and tool output now sit in a wrapper that skips off-screen
layout like other entries; on <details> itself WebKit wouldn't expand them,
so they were laid out in full on every resize and reopen.
2026-11-06 13:08:53 +08:00
l0ng-ai dadda6cf59 fix(agents): render local and web images in replies (#1711) 2026-11-06 13:08:38 +08:00
l0ng-ai b1d8d61cb1 fix(window): keep macOS traffic lights still during live resize (#1712)
During a live resize AppKit lays out and draws the title bar inside each drag event and resets the window buttons to their defaults, moving the zoom button without a frame change notification. Correcting the frames afterwards from a run loop observer let every step draw the default positions first, so the buttons kept jittering while the window edge was held.

The window now gets an empty unified toolbar, compact or regular to match the web title bar height, so AppKit lays out and centres the buttons itself and nothing moves them afterwards. The run loop observer and the manual frame corrections are removed.
2026-11-06 13:08:22 +08:00
60 changed files with 1829 additions and 277 deletions

No files matched your search

+52
View File
@@ -0,0 +1,52 @@
name: CNB mirror
# Copies a published release to cnb.cool/l0ng-ai/openidea, the updater's CNB source
# (scripts/cnb-mirror.ts). Release runs it after publishing; run it by hand to mirror
# an existing tag again. Skipped without the CNB_TOKEN secret.
on:
workflow_call:
inputs:
tag: { type: string, required: true }
workflow_dispatch:
inputs:
tag:
description: "Published release to mirror (e.g. v0.1.38)"
required: true
permissions:
contents: read
concurrency:
group: cnb-mirror-${{ inputs.tag }}
jobs:
mirror:
runs-on: ubuntu-latest
timeout-minutes: 120
env:
TAG: ${{ inputs.tag }}
CNB_TOKEN: ${{ secrets.CNB_TOKEN }}
GH_TOKEN: ${{ github.token }}
steps:
- name: Check token
id: token
run: |
if [ -z "$CNB_TOKEN" ]; then
echo "::notice::CNB_TOKEN unset: not mirroring $TAG"
echo "skip=true" >> "$GITHUB_OUTPUT"
fi
- uses: actions/checkout@v5
if: steps.token.outputs.skip != 'true'
with: { sparse-checkout: scripts }
- uses: actions/setup-node@v5
if: steps.token.outputs.skip != 'true'
with: { node-version: 22 }
- name: Download release
if: steps.token.outputs.skip != 'true'
run: |
gh release download "$TAG" -R "$GITHUB_REPOSITORY" -D assets
gh release view "$TAG" -R "$GITHUB_REPOSITORY" --json body,isPrerelease > assets/release.json
du -sh assets
- name: Upload to CNB
if: steps.token.outputs.skip != 'true'
run: node scripts/cnb-mirror.ts "$TAG" assets
+7
View File
@@ -355,3 +355,10 @@ jobs:
await github.rest.repos.updateRelease({
...context.repo, release_id: Number(process.env.RELEASE_ID), draft: false,
});
# The China mirror the updater's CNB source reads; a failure here leaves the GitHub release as is.
cnb-mirror:
needs: publish
uses: ./.github/workflows/cnb-mirror.yml
with: { tag: "${{ github.event.inputs.tag || github.ref_name }}" }
secrets: inherit
Generated
+1 -2
View File
@@ -4212,6 +4212,7 @@ dependencies = [
name = "ide-dap"
version = "0.1.37"
dependencies = [
"futures",
"ide-core",
"ide-jsonrpc",
"ide-language",
@@ -6012,7 +6013,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
"dispatch2",
"objc2 0.6.5",
]
@@ -6403,7 +6403,6 @@ dependencies = [
"ide-workspace",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"objc2-web-kit 0.3.2",
"parking_lot",
+3
View File
@@ -268,6 +268,8 @@ pub struct GeneralSettings {
pub power_save_mode: bool,
/// File › Rename Project… (project layer): the name shown for the project; empty = its folder name.
pub project_name: String,
/// Where updates come from: `auto` (the CNB mirror for the Chinese UI, else GitHub) | `github` | `cnb`.
pub update_source: String,
}
impl Default for GeneralSettings {
@@ -282,6 +284,7 @@ impl Default for GeneralSettings {
date_format: DateFormatSettings::default(),
power_save_mode: false,
project_name: String::new(),
update_source: "auto".into(),
}
}
}
+1
View File
@@ -6,6 +6,7 @@ edition.workspace = true
license.workspace = true
[dependencies]
futures.workspace = true
tempfile.workspace = true
ide-core.workspace = true
ide-language.workspace = true
+27 -1
View File
@@ -196,10 +196,18 @@ fn managed_dirs(root: &Path) -> Vec<PathBuf> {
dirs
}
/// The `PATH` an adapter runs with, unless its launch sets one: the search path language servers
/// get, so what it starts in turn (js-debug's wrapper runs `node`, Delve runs `go build`) is
/// found like Run finds it, even when the login shell's `PATH` didn't reach the app.
fn adapter_path(env: &[(String, String)]) -> Option<std::ffi::OsString> {
let own = env.iter().any(|(k, _)| if cfg!(windows) { k.eq_ignore_ascii_case("PATH") } else { k == "PATH" });
(!own).then(ide_core::tool_path::search_path)
}
/// Directories searched for adapter binaries: managed tools first, then `PATH` and the usual install spots.
fn search_dirs() -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = tools_root().map(|r| managed_dirs(&r)).unwrap_or_default();
dirs.extend(std::env::var_os("PATH").map(|p| std::env::split_paths(&p).collect::<Vec<_>>()).unwrap_or_default());
dirs.extend(std::env::split_paths(&ide_core::tool_path::search_path()));
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
for rel in [".cargo/bin", ".local/bin", "go/bin", ".dotnet/tools", ".bun/bin", ".npm-global/bin", ".volta/bin"]
{
@@ -464,6 +472,9 @@ impl AdapterLauncher for ProcessLauncher {
None => args,
};
let mut cmd = Command::new(&binary);
if let Some(path) = adapter_path(&spec.env) {
cmd.env("PATH", path);
}
ide_core::process::prepare(cmd.as_std_mut());
cmd.args(&args)
.envs(spec.env.iter().map(|(k, v)| (k, v)))
@@ -592,6 +603,21 @@ async fn connect_with_retries(
mod tests {
use super::*;
#[test]
fn adapters_run_on_the_tool_search_path() {
let search = ide_core::tool_path::search_path();
assert_eq!(adapter_path(&[]), Some(search.clone()));
assert_eq!(adapter_path(&[("GOFLAGS".into(), "-mod=mod".into())]), Some(search));
// A launch that sets its own (a selected Go SDK's bin first) keeps it.
assert_eq!(adapter_path(&[("PATH".into(), "/sdk/go/bin:/usr/bin".into())]), None);
// The search path reaches the usual install spots even when this process's `PATH` doesn't.
#[cfg(unix)]
{
let dirs: Vec<PathBuf> = std::env::split_paths(&ide_core::tool_path::search_path()).collect();
assert!(dirs.contains(&PathBuf::from("/usr/local/bin")), "{dirs:?}");
}
}
#[tokio::test]
async fn program_output_is_forwarded_without_the_adapter_banner() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
+50 -23
View File
@@ -19,6 +19,7 @@ mod session;
use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher};
use connection::{Connection, DapError};
use futures::future::join_all;
use ide_core::{DebugSessionId, IdeError, IdeResult, SharedSink};
use ide_language::LanguageRegistry;
use parking_lot::Mutex;
@@ -510,6 +511,18 @@ pub(crate) fn capabilities_from(caps: &Value) -> DebugCapabilities {
}
}
/// States of a file's line breakpoints that no session has been sent.
fn unsent(breakpoints: &[Breakpoint]) -> Vec<BreakpointState> {
breakpoints
.iter()
.map(|b| BreakpointState {
line: b.line,
verified: false,
message: (!b.enabled).then(|| "Disabled".to_owned()),
})
.collect()
}
/// States for breakpoints no session has verified yet.
fn unverified(enabled: impl Iterator<Item = bool>) -> Vec<BreakpointState> {
enabled
@@ -877,12 +890,20 @@ impl DebugManager {
let _ = tokio::time::timeout(INIT_TIMEOUT, initialized.wait_for(|v| *v)).await;
}
let files: Vec<(PathBuf, Vec<Breakpoint>)> =
self.breakpoints.lock().iter().map(|(p, b)| (p.clone(), b.clone())).collect();
// A fresh adapter has no breakpoints, so a file with none enabled needs no request; the
// others go out together, as VS Code sends them, instead of one round trip per file.
let (files, idle): (Vec<_>, Vec<_>) = self
.breakpoints
.lock()
.iter()
.map(|(p, b)| (p.clone(), b.clone()))
.partition(|(_, b)| b.iter().any(|b| b.enabled));
let sent = join_all(files.iter().map(|(path, bps)| s.send_breakpoints(path, bps, None))).await;
// Breakpoint states follow the owner's first session.
let first = self.live_sessions().into_iter().find(|f| f.owner.key == s.owner.key).map(|f| f.id) == Some(s.id);
for (path, bps) in files {
match s.send_breakpoints(&path, &bps, None).await {
let idle = idle.into_iter().map(|(path, bps)| (path, Ok(unsent(&bps))));
for (path, result) in files.into_iter().map(|(path, _)| path).zip(sent).chain(idle) {
match result {
Ok(states) => {
if first {
self.states.lock().insert(path.clone(), states);
@@ -990,16 +1011,7 @@ impl DebugManager {
Err(e) => tracing::debug!("setBreakpoints on session {} failed: {e}", s.id.0),
}
}
let states = result.unwrap_or_else(|| {
breakpoints
.iter()
.map(|b| BreakpointState {
line: b.line,
verified: false,
message: (!b.enabled).then(|| "Disabled".to_owned()),
})
.collect()
});
let states = result.unwrap_or_else(|| unsent(&breakpoints));
self.states.lock().insert(path, states.clone());
Ok(states)
}
@@ -1327,13 +1339,26 @@ impl DebugManager {
/// Sends empty breakpoint lists (line, function, data) to `session` until its next
/// stop, when the stored ones are restored. `except` is left to the caller.
async fn suspend_breakpoints(&self, session: &Arc<Session>, except: Option<&Path>) -> IdeResult<()> {
let files: Vec<PathBuf> = self.breakpoints.lock().keys().cloned().collect();
for path in files {
if Some(path.as_path()) == except {
continue;
// While paused the adapter holds the stored lists: only files with something enabled change.
let files: Vec<PathBuf> = self
.breakpoints
.lock()
.iter()
.filter(|(path, bps)| Some(path.as_path()) != except && bps.iter().any(|b| b.enabled))
.map(|(path, _)| path.clone())
.collect();
let sent = join_all(files.iter().map(|path| session.send_breakpoints(path, &[], None))).await;
let mut failed = None;
for (path, result) in files.into_iter().zip(sent) {
match result {
Ok(_) => {
session.pending_restore.lock().files.insert(path);
}
Err(e) => failed = failed.or(Some(e)),
}
session.send_breakpoints(&path, &[], None).await?;
session.pending_restore.lock().files.insert(path);
}
if let Some(e) = failed {
return Err(e.into());
}
if !self.function_breakpoints.lock().is_empty() && session.supports("supportsFunctionBreakpoints") {
session.send_function_breakpoints(&[]).await?;
@@ -1349,9 +1374,11 @@ impl DebugManager {
/// Re-sends what [`suspend_breakpoints`](Self::suspend_breakpoints) / run to cursor replaced.
pub(crate) async fn restore_breakpoints(&self, session: &Arc<Session>) {
let pending = std::mem::take(&mut *session.pending_restore.lock());
for path in pending.files {
let bps = self.stored_breakpoints(&path);
if let Err(e) = session.send_breakpoints(&path, &bps, None).await {
let files: Vec<(PathBuf, Vec<Breakpoint>)> =
pending.files.into_iter().map(|path| (path.clone(), self.stored_breakpoints(&path))).collect();
let sent = join_all(files.iter().map(|(path, bps)| session.send_breakpoints(path, bps, None))).await;
for ((path, _), result) in files.iter().zip(sent) {
if let Err(e) = result {
tracing::debug!("restoring breakpoints of {} failed: {e}", path.display());
}
}
+36 -8
View File
@@ -17,6 +17,8 @@ struct Script {
start_debugging: bool,
ignore_configuration_done: bool,
source_path: Option<String>,
/// Answer `setBreakpoints` only once this many are waiting (a client that waits for each never gets one).
hold_set_breakpoints: usize,
}
struct FakeLauncher {
@@ -49,6 +51,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
let mut r = BufReader::new(r);
let mut seq = 1000;
let mut pending_launch: Option<i64> = None;
let mut held: Vec<Value> = Vec::new();
macro_rules! send {
($v:expr) => {{
let mut v: Value = $v;
@@ -101,14 +104,20 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
send!(json!({"type": "event", "event": "initialized"}));
}
"setBreakpoints" => {
let bps: Vec<Value> = msg["arguments"]["breakpoints"]
.as_array()
.unwrap()
.iter()
.enumerate()
.map(|(i, b)| json!({"id": i + 1, "verified": true, "line": b["line"]}))
.collect();
ok!(msg, json!({"breakpoints": bps}));
held.push(msg);
if held.len() < script.hold_set_breakpoints {
continue;
}
for msg in std::mem::take(&mut held) {
let bps: Vec<Value> = msg["arguments"]["breakpoints"]
.as_array()
.unwrap()
.iter()
.enumerate()
.map(|(i, b)| json!({"id": i + 1, "verified": true, "line": b["line"]}))
.collect();
ok!(msg, json!({"breakpoints": bps}));
}
}
"setExceptionBreakpoints" => ok!(msg, json!({})),
"setFunctionBreakpoints" | "setDataBreakpoints" => {
@@ -921,6 +930,7 @@ async fn force_stepping_suspends_breakpoints_until_the_next_stop() {
let b = PathBuf::from("/p/b.py");
h.manager.set_breakpoints(a.clone(), vec![bp(1)]).await.unwrap();
h.manager.set_breakpoints(b.clone(), vec![bp(3)]).await.unwrap();
h.manager.set_breakpoints(PathBuf::from("/p/off.py"), vec![Breakpoint { enabled: false, ..bp(5) }]).await.unwrap();
h.manager.set_function_breakpoints(vec![function_bp("run", true)]).await.unwrap();
let id = h.start(config()).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
@@ -949,6 +959,24 @@ async fn force_stepping_suspends_breakpoints_until_the_next_stop() {
h.manager.run_to(id, 1, a.clone(), 9, true).await.unwrap();
assert_eq!(sent_for("/p/a.py").last().unwrap(), &json!([{"line": 10}]));
assert_eq!(sent_for("/p/b.py").last().unwrap(), &json!([]));
assert!(sent_for("/p/off.py").is_empty(), "nothing enabled there, nothing to suspend or restore");
}
#[tokio::test]
async fn startup_sends_every_file_at_once_and_skips_files_with_nothing_enabled() {
let h = harness(Script { hold_set_breakpoints: 2, ..Script::default() });
h.manager.set_breakpoints(PathBuf::from("/p/a.py"), vec![bp(1)]).await.unwrap();
h.manager.set_breakpoints(PathBuf::from("/p/b.py"), vec![bp(3)]).await.unwrap();
let off = PathBuf::from("/p/off.py");
h.manager.set_breakpoints(off.clone(), vec![Breakpoint { enabled: false, ..bp(5) }]).await.unwrap();
// Waiting for each answer before the next request would stall until the request timeout.
tokio::time::timeout(Duration::from_secs(5), h.start(config())).await.expect("requests pipelined").unwrap();
let paths: Vec<Value> = requests(&h, "setBreakpoints").into_iter().map(|r| r["source"]["path"].clone()).collect();
assert_eq!(paths, [json!("/p/a.py"), json!("/p/b.py")]);
assert_eq!(
h.manager.breakpoint_states(&off),
[BreakpointState { line: 5, verified: false, message: Some("Disabled".into()) }]
);
}
#[tokio::test]
+41 -4
View File
@@ -1221,10 +1221,26 @@ impl Tools {
cancel: &CancellationToken,
) -> IdeResult<sources::Download> {
match source {
ReleaseSource::GitHub { repo } => {
let json = http.get_text(&sources::github_latest_url(repo), cancel)?;
sources::github_pick(&json, assets, self.platform)
}
ReleaseSource::GitHub { repo } => match http.get_text(&sources::github_latest_url(repo), cancel) {
Ok(json) => sources::github_pick(&json, assets, self.platform),
Err(e) if e.kind == ErrorKind::Cancelled => Err(e),
// Without a token the API allows 60 requests an hour per IP address, often used
// up behind a shared one (403/429); the release's web pages have no such limit.
Err(api) => self.resolve_github_web(http, repo, assets, cancel).map_err(|web| {
if web.kind == ErrorKind::Cancelled {
return web;
}
tracing::warn!("GitHub release lookup for {repo} failed: {api}; web pages: {web}");
IdeError::new(
api.kind,
format!(
"{}\nDownload it from {} and set its Path in Settings › Languages & Frameworks › Language Servers.",
api.message,
sources::github_latest_page(repo)
),
)
}),
},
ReleaseSource::HashiCorp { product } => {
let json = http.get_text(&sources::hashicorp_latest_url(product), cancel)?;
sources::hashicorp_pick(&json, assets, self.platform)
@@ -1251,6 +1267,27 @@ impl Tools {
}
}
/// [`resolve_release`](Self::resolve_release) for a GitHub release, from its web pages.
fn resolve_github_web(
&self,
http: &Http,
repo: &str,
assets: &[platform::AssetRule],
cancel: &CancellationToken,
) -> IdeResult<sources::Download> {
let page = http.effective_url(&sources::github_latest_page(repo), cancel)?;
let tag = sources::github_tag_from_url(&page)
.ok_or_else(|| IdeError::unavailable(format!("{repo} has no published release")))?;
let list = http.get_text(&sources::github_assets_page(repo, &tag), cancel)?;
match sources::github_pick_web(repo, &tag, &list, None, assets, self.platform) {
Err(e) if e.kind == ErrorKind::Unavailable => {
let notes = http.get_text(&sources::github_tag_page(repo, &tag), cancel)?;
sources::github_pick_web(repo, &tag, &list, Some(&notes), assets, self.platform)
}
result => result,
}
}
fn install_release(
&self,
spec: &ToolSpec,
+27 -5
View File
@@ -263,6 +263,15 @@ fn curl_takes_revoke_best_effort(version: &str) -> bool {
(major, minor) >= (7, 70) && first.split_whitespace().any(|w| w.eq_ignore_ascii_case("schannel"))
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Request {
Get,
/// Adoptium uses 404 as well as an empty array for absent platform builds.
GetOrEmptyOn404,
/// Headers, then the URL redirects ended at.
Head,
}
/// HTTP via the system `curl` (shipped with macOS, Windows 10+ and every
/// mainstream Linux), so the IDE carries no TLS stack of its own.
pub struct Http {
@@ -336,26 +345,39 @@ impl Http {
self.get_text_limited(url, None, cancel)
}
/// Where `url` ends up after redirects (a HEAD request).
pub fn effective_url(&self, url: &str, cancel: &CancellationToken) -> IdeResult<String> {
let out = self.get_text_response(url, None, Request::Head, cancel)?;
Ok(out.trim_end().rsplit('\n').next().unwrap_or_default().trim().to_owned())
}
/// GETs `url` as text, failing when the response is larger than `max_bytes`.
pub fn get_text_limited(&self, url: &str, max_bytes: Option<u64>, cancel: &CancellationToken) -> IdeResult<String> {
self.get_text_response(url, max_bytes, false, cancel)
self.get_text_response(url, max_bytes, Request::Get, cancel)
}
/// Adoptium uses 404 as well as an empty array for absent platform builds.
pub fn get_text_or_empty_on_404(&self, url: &str, cancel: &CancellationToken) -> IdeResult<String> {
self.get_text_response(url, None, true, cancel)
self.get_text_response(url, None, Request::GetOrEmptyOn404, cancel)
}
fn get_text_response(
&self,
url: &str,
max_bytes: Option<u64>,
empty_on_404: bool,
request: Request,
cancel: &CancellationToken,
) -> IdeResult<String> {
let (mut cmd, token) = self.base(url, cancel)?;
if empty_on_404 {
cmd.args(["--write-out", "\n%{http_code}"]);
let empty_on_404 = request == Request::GetOrEmptyOn404;
match request {
Request::Get => {}
Request::GetOrEmptyOn404 => {
cmd.args(["--write-out", "\n%{http_code}"]);
}
Request::Head => {
cmd.args(["--head", "--write-out", "\n%{url_effective}"]);
}
}
if let Some(max) = max_bytes {
cmd.arg("--max-filesize").arg(max.to_string());
+184 -11
View File
@@ -47,38 +47,146 @@ pub fn github_latest_url(repo: &str) -> String {
format!("https://api.github.com/repos/{repo}/releases/latest")
}
/// The latest release's web page; it redirects to `…/releases/tag/<tag>`. Web pages don't
/// count against the API's rate limit for unauthenticated clients (60 requests an hour).
pub fn github_latest_page(repo: &str) -> String {
format!("https://github.com/{repo}/releases/latest")
}
/// The tag `github_latest_page` redirected to.
pub fn github_tag_from_url(url: &str) -> Option<String> {
let (_, tag) = url.split_once("/releases/tag/")?;
let tag = tag.split(['?', '#']).next().unwrap_or(tag).trim_end_matches('/');
(!tag.is_empty()).then(|| percent_decode(tag))
}
pub fn github_tag_page(repo: &str, tag: &str) -> String {
format!("https://github.com/{repo}/releases/tag/{tag}")
}
/// The asset list the release page loads (HTML).
pub fn github_assets_page(repo: &str, tag: &str) -> String {
format!("https://github.com/{repo}/releases/expanded_assets/{tag}")
}
/// One file of a release, from the API or its web page.
struct ReleaseAsset {
name: String,
url: String,
size: Option<u64>,
sha256: Option<String>,
}
/// Picks the platform asset from a GitHub `releases/latest` response. Links
/// in the release notes count as assets too (some projects host binaries on
/// a CDN and only link them).
pub fn github_pick(json: &str, rules: &[AssetRule], platform: Platform) -> IdeResult<Download> {
let rel: GhRelease = serde_json::from_str(json).map_err(|e| proto(format!("unexpected GitHub response: {e}")))?;
let version = rel.tag_name.trim_start_matches('v').to_string();
let names: Vec<&str> = rel.assets.iter().map(|a| a.name.as_str()).collect();
let assets: Vec<ReleaseAsset> = rel
.assets
.into_iter()
.map(|a| ReleaseAsset {
sha256: a.digest.as_deref().and_then(|d| d.strip_prefix("sha256:")).map(str::to_ascii_lowercase),
name: a.name,
url: a.browser_download_url,
size: a.size,
})
.collect();
pick_release(&rel.tag_name, &assets, rel.body.as_deref().unwrap_or(""), rules, platform)
}
/// [`github_pick`] from the release's web pages: `assets_html` from [`github_assets_page`] and,
/// for projects that only link their builds, `notes_html` from [`github_tag_page`].
pub fn github_pick_web(
repo: &str,
tag: &str,
assets_html: &str,
notes_html: Option<&str>,
rules: &[AssetRule],
platform: Platform,
) -> IdeResult<Download> {
let prefix = format!("href=\"/{repo}/releases/download/");
let mut assets: Vec<ReleaseAsset> = Vec::new();
let mut rest = assets_html;
while let Some(i) = rest.find(&prefix) {
let tail = &rest[i + "href=\"".len()..];
let Some(end) = tail.find('"') else { break };
let path = html_unescape(&tail[..end]);
rest = &tail[end..];
let name = percent_decode(file_name(&path));
if name.is_empty() || assets.iter().any(|a| a.name == name) {
continue;
}
// `<clipboard-copy aria-label="Copy to clipboard digest for <name>" … value="sha256:<hex>">`
let sha256 = assets_html.find(&format!("digest for {}\"", html_escape(&name))).and_then(|at| {
let tag_end = assets_html[at..].find('>').map_or(assets_html.len(), |e| at + e);
let value = assets_html[at..tag_end].split_once("value=\"sha256:")?.1;
let hex: String = value.chars().take_while(char::is_ascii_hexdigit).collect();
(hex.len() == 64).then(|| hex.to_ascii_lowercase())
});
assets.push(ReleaseAsset { url: format!("https://github.com{path}"), name, size: None, sha256 });
}
pick_release(tag, &assets, &html_unescape(notes_html.unwrap_or("")), rules, platform)
}
fn pick_release(
tag: &str,
assets: &[ReleaseAsset],
notes: &str,
rules: &[AssetRule],
platform: Platform,
) -> IdeResult<Download> {
let version = tag.trim_start_matches('v').to_string();
let names: Vec<&str> = assets.iter().map(|a| a.name.as_str()).collect();
if let Some(name) = select_asset(&names, rules, platform) {
let a = rel.assets.iter().find(|a| a.name == name).expect("selected from list");
let sha256 = a.digest.as_deref().and_then(|d| d.strip_prefix("sha256:")).map(str::to_ascii_lowercase);
let checksum_url = rel
.assets
let a = assets.iter().find(|a| a.name == name).expect("selected from list");
let checksum_url = assets
.iter()
.find(|c| c.name == format!("{name}.sha256") || c.name == format!("{name}.sha256sum"))
.map(|c| c.browser_download_url.clone());
.map(|c| c.url.clone());
return Ok(Download {
version,
name: a.name.clone(),
url: a.browser_download_url.clone(),
url: a.url.clone(),
size: a.size,
sha256,
sha256: a.sha256.clone(),
checksum_url,
});
}
let links = extract_links(rel.body.as_deref().unwrap_or(""));
let links = extract_links(notes);
let link_names: Vec<&str> = links.iter().map(|u| file_name(u)).collect();
if let Some(name) = select_asset(&link_names, rules, platform) {
let url = links.iter().find(|u| file_name(u) == name).expect("selected from list").clone();
let checksum_url = links.iter().find(|u| *u == &format!("{url}.sha256")).cloned();
return Ok(Download { version, name: name.to_string(), url, size: None, sha256: None, checksum_url });
}
Err(IdeError::unavailable(format!("release {} has no build for this platform", rel.tag_name)))
Err(IdeError::unavailable(format!("release {tag} has no build for this platform")))
}
fn html_unescape(s: &str) -> String {
s.replace("&quot;", "\"").replace("&#39;", "'").replace("&lt;", "<").replace("&gt;", ">").replace("&amp;", "&")
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;").replace('>', "&gt;")
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%'
&& let Some(b) = s.get(i + 1..i + 3).and_then(|h| u8::from_str_radix(h, 16).ok())
{
out.push(b);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
String::from_utf8(out).unwrap_or_else(|_| s.to_owned())
}
fn file_name(url: &str) -> &str {
@@ -336,6 +444,71 @@ mod tests {
assert_eq!(d.name, "kotlin-lsp-0.253.10629-linux-x64.zip");
}
#[test]
fn github_tag_from_redirect() {
let url = "https://github.com/Kotlin/kotlin-lsp/releases/tag/kotlin-lsp/v263.7084.0";
assert_eq!(github_tag_from_url(url).as_deref(), Some("kotlin-lsp/v263.7084.0"));
let url = "https://github.com/o/r/releases/tag/v1.0%2Bbuild";
assert_eq!(github_tag_from_url(url).as_deref(), Some("v1.0+build"));
// A repository without releases stays on the list.
assert_eq!(github_tag_from_url("https://github.com/o/r/releases"), None);
}
/// Trimmed from `github.com/<repo>/releases/expanded_assets/<tag>`.
const ASSETS_HTML: &str = r#"<li><div><a href="/LuaLS/lua-language-server/releases/download/3.13.5/lua-language-server-3.13.5-darwin-arm64.tar.gz" rel="nofollow" data-turbo="false" class="wb-break-all">
<span class="text-bold">lua-language-server-3.13.5-darwin-arm64.tar.gz</span></a></div>
<span class="Truncate-text">sha256:ABCDEF0123456789abcdef0123456789abcdef0123456789abcdef0123456789</span>
<clipboard-copy id="clipboard-button-sha256:abc" aria-label="Copy to clipboard digest for lua-language-server-3.13.5-darwin-arm64.tar.gz" type="button" value="sha256:ABCDEF0123456789abcdef0123456789abcdef0123456789abcdef0123456789" class="Button"></clipboard-copy></li>
<li><a href="/LuaLS/lua-language-server/releases/download/3.13.5/lua-language-server-3.13.5-linux-x64.tar.gz" rel="nofollow">lua-language-server-3.13.5-linux-x64.tar.gz</a></li>
<li><a href="/LuaLS/lua-language-server/archive/refs/tags/3.13.5.zip" rel="nofollow">Source code (zip)</a></li>"#;
#[test]
fn github_web_assets_with_digest() {
let rules = find("lua-language-server").unwrap().asset_rules();
let d = github_pick_web("LuaLS/lua-language-server", "3.13.5", ASSETS_HTML, None, rules, MAC_ARM).unwrap();
assert_eq!(d.version, "3.13.5");
assert_eq!(d.name, "lua-language-server-3.13.5-darwin-arm64.tar.gz");
assert_eq!(
d.url,
"https://github.com/LuaLS/lua-language-server/releases/download/3.13.5/lua-language-server-3.13.5-darwin-arm64.tar.gz"
);
assert_eq!(d.sha256.as_deref(), Some("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"));
let d = github_pick_web("LuaLS/lua-language-server", "3.13.5", ASSETS_HTML, None, rules, LINUX).unwrap();
assert_eq!(d.sha256, None);
let win = Platform { os: Os::Windows, arch: Arch::X64 };
let err = github_pick_web("LuaLS/lua-language-server", "3.13.5", ASSETS_HTML, None, rules, win).unwrap_err();
assert_eq!(err.kind, ErrorKind::Unavailable);
}
#[test]
fn github_web_sibling_checksum_and_notes_links() {
let html = r#"<a href="/artempyanykh/marksman/releases/download/2024-12-18/marksman-macos" rel="nofollow">
<a href="/artempyanykh/marksman/releases/download/2024-12-18/marksman-macos.sha256" rel="nofollow">"#;
let d = github_pick_web(
"artempyanykh/marksman",
"2024-12-18",
html,
None,
find("marksman").unwrap().asset_rules(),
MAC_ARM,
)
.unwrap();
assert_eq!(
d.checksum_url.as_deref(),
Some("https://github.com/artempyanykh/marksman/releases/download/2024-12-18/marksman-macos.sha256")
);
let notes = r#"<li><a href="https://download-cdn.jetbrains.com/kotlin-lsp/0.253.10629/kotlin-lsp-0.253.10629-mac-aarch64.zip?a=1&amp;b=2" rel="nofollow">macOS arm</a></li>"#;
let rules = find("kotlin-lsp").unwrap().asset_rules();
let d =
github_pick_web("Kotlin/kotlin-lsp", "kotlin-lsp/v0.253.10629", "", Some(notes), rules, MAC_ARM).unwrap();
assert_eq!(d.name, "kotlin-lsp-0.253.10629-mac-aarch64.zip");
assert_eq!(
d.url,
"https://download-cdn.jetbrains.com/kotlin-lsp/0.253.10629/kotlin-lsp-0.253.10629-mac-aarch64.zip?a=1&b=2"
);
}
#[test]
fn hashicorp() {
let json = r#"{"version":"0.36.4","url_shasums":"https://r/terraform-ls_0.36.4_SHA256SUMS","builds":[
+24 -3
View File
@@ -47,11 +47,16 @@ const REPLY = [
"```",
].join("\n");
async function openReply(page: Page, text: string) {
async function openReply(page: Page, text: string, imageData?: string) {
await page.goto("/");
await page.getByRole("tree", { name: "Project" }).waitFor();
await page.evaluate(
async ({ task, text }) => {
async ({ task, text, imageData }) => {
if (imageData) {
const routePath = "/src/platform/tauriCore.ts";
const { setInvokeRoute } = await import(routePath);
setInvokeRoute((cmd: string) => (cmd === "fs_read_image" ? Promise.resolve(imageData) : null));
}
// Paths go through variables: knip can't resolve these browser-absolute URLs.
const storePath = "/src/features/agents/store.ts";
const store = await import(storePath);
@@ -66,7 +71,7 @@ async function openReply(page: Page, text: string) {
});
store.dockTask(task.id);
},
{ task: TASK, text },
{ task: TASK, text, imageData },
);
const reply = page.locator("[class*='_markdown_']").first();
await reply.locator("h1, p").first().waitFor();
@@ -128,3 +133,19 @@ test("styles quotes, rules, images and task lists", async ({ page }) => {
// A checkbox stands in for the bullet.
expect(await css(reply, "li:has(> input)", "list-style-type")).toBe("none");
});
test("loads local and web reply images", async ({ page }) => {
await page.route("https://images.example.com/code.gif", (route) =>
route.fulfill({ contentType: "image/gif", body: Buffer.from(PIXEL.split(",")[1]!, "base64") }),
);
const reply = await openReply(
page,
"![Local QR](/tmp/code.png)\n\n![Web QR](https://images.example.com/code.gif)",
PIXEL,
);
for (const name of ["Local QR", "Web QR"]) {
await expect
.poll(() => reply.getByRole("img", { name }).evaluate((img: HTMLImageElement) => img.naturalWidth))
.toBe(1);
}
});
+3 -2
View File
@@ -69,7 +69,8 @@ test("⌘K commits the checked changes with the typed message", async ({ workben
await expect(page.getByText(/1 file committed: Return order DTOs from create/)).toBeVisible();
const [committed] = await ipcCalls(page, "vcs_commit");
expect(committed).toMatchObject({ repo: ROOT, message: "Return order DTOs from create" });
const staged = (await ipcCalls(page, "vcs_stage")).flatMap((a) => a.paths as string[]);
expect(staged).toEqual([`${ROOT}/build.gradle.kts`]);
// The commit takes the checked file itself; the index isn't staged beforehand.
expect((committed!.options as { stage: string[] }).stage).toEqual([`${ROOT}/build.gradle.kts`]);
expect(await ipcCalls(page, "vcs_stage")).toHaveLength(0);
await expect(changes.getByRole("checkbox", { name: "Include build.gradle.kts in commit" })).toBeHidden();
});
+6 -10
View File
@@ -8,12 +8,11 @@ import { expect, ipcCalls, stubDesktop, test } from "./fixtures";
const ROOT = "/dev/acme-api";
const NAMES = Array.from({ length: 20 }, (_, i) => `src/f${i}.ts`);
/** Twenty modified tracked files; `vcs_stage` + `vcs_commit` take the staged ones out of the status. */
/** Twenty modified tracked files; `vcs_commit` takes the files it commits out of the status. */
async function stubTwentyChanges(page: Page) {
await stubDesktop(page);
await page.evaluate(
async ({ root, names }) => {
const staged = new Set<string>();
const committed = new Set<string>();
const all = names.map((rel) => ({
path: `${root}/${rel}`,
@@ -33,14 +32,10 @@ async function stubTwentyChanges(page: Page) {
operation: null,
});
h.vcs_status = () => all.filter((f) => !committed.has(f.path));
h.vcs_stage = (args) => {
for (const path of args.paths as string[]) staged.add(path);
return null;
};
h.vcs_stage = () => null;
h.vcs_unstage = () => null;
h.vcs_commit = () => {
for (const path of staged) committed.add(path);
staged.clear();
h.vcs_commit = (args) => {
for (const path of (args.options as { stage: string[] }).stage) committed.add(path);
return "2222222";
};
const vcsModule = "/src/features/vcs/store.ts";
@@ -71,7 +66,8 @@ test("changes left out of a commit stay unchecked (#1539)", async ({ workbench:
await panel.getByRole("textbox", { name: "Commit message" }).fill("Commit 5 of 20");
await panel.getByRole("button", { name: "Commit", exact: true }).click();
await expect(page.getByText(/5 files committed: Commit 5 of 20/)).toBeVisible();
expect((await ipcCalls(page, "vcs_stage")).flatMap((a) => a.paths as string[]).sort()).toEqual(
const [commit] = await ipcCalls(page, "vcs_commit");
expect([...(commit!.options as { stage: string[] }).stage].sort()).toEqual(
NAMES.slice(0, 5)
.map((rel) => `${ROOT}/${rel}`)
.sort(),
+54
View File
@@ -0,0 +1,54 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { KEEP, mirrorManifest, releasesToPrune } from "./cnb-mirror.ts";
describe("mirrorManifest", () => {
const gh = "https://github.com/l0ng-ai/openidea/releases/download/v0.1.38";
const manifest = {
version: "0.1.38",
notes: "",
pub_date: "2026-11-06T00:00:00Z",
platforms: {
"darwin-aarch64": { signature: "sig-mac", url: `${gh}/OpenIDEA_aarch64.app.tar.gz` },
"windows-x86_64-nsis": { signature: "sig-win", url: `${gh}/OpenIDEA_0.1.38_x64-setup.exe` },
},
};
it("points every bundle at the CNB release and keeps the signatures", () => {
const cnb = "https://cnb.cool/l0ng-ai/openidea/-/releases/download/v0.1.38";
expect(mirrorManifest(manifest, "v0.1.38")).toEqual({
...manifest,
platforms: {
"darwin-aarch64": { signature: "sig-mac", url: `${cnb}/OpenIDEA_aarch64.app.tar.gz` },
"windows-x86_64-nsis": { signature: "sig-win", url: `${cnb}/OpenIDEA_0.1.38_x64-setup.exe` },
},
});
});
it("refuses a manifest of another release", () => {
expect(() => mirrorManifest(manifest, "v0.1.39")).toThrow(/darwin-aarch64/);
});
});
describe("releasesToPrune", () => {
const release = (n: number) => ({
id: String(n),
tag_name: `v0.1.${n}`,
created_at: `2026-11-${String(n).padStart(2, "0")}T00:00:00Z`,
});
it("keeps the newest releases and removes the rest", () => {
const releases = Array.from({ length: KEEP + 3 }, (_, i) => release(i + 1)).reverse();
expect(releasesToPrune(releases, `v0.1.${KEEP + 3}`).map((r) => r.tag_name)).toEqual([
"v0.1.3",
"v0.1.2",
"v0.1.1",
]);
});
it("removes nothing within the limit, and never the release being mirrored", () => {
expect(releasesToPrune([release(1), release(2)], "v0.1.2")).toEqual([]);
// A rerun for an old tag: it is the oldest, but it was just uploaded.
expect(releasesToPrune([release(1), release(2), release(3)], "v0.1.1", 2)).toEqual([]);
});
});
+159
View File
@@ -0,0 +1,159 @@
/**
* Mirrors a published release to CNB (cnb.cool/l0ng-ai/openidea), where the updater's
* CNB source reads `releases/latest/download/latest.json`:
* `node scripts/cnb-mirror.ts <tag> <dir>` with `CNB_TOKEN` set (scopes `repo-code:r`,
* `repo-release:rw`). `<dir>` holds the release's assets (`gh release download`) and
* `release.json` (`gh release view --json body,isPrerelease`). Every asset is uploaded,
* `latest.json` last and pointing at CNB, so the mirror's manifest never names a bundle it
* lacks. Only the newest `KEEP` releases stay on CNB. Safe to rerun: uploads overwrite.
*/
import { openAsBlob, readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
export const CNB_REPO = "l0ng-ai/openidea";
const GITHUB_REPO = "l0ng-ai/openidea";
const API = `https://api.cnb.cool/${CNB_REPO}`;
export const KEEP = 10;
const PARALLEL = 4;
const MANIFEST = "latest.json";
const RELEASE_INFO = "release.json";
interface Manifest {
version: string;
notes?: string;
pub_date?: string;
platforms: Record<string, { signature: string; url: string }>;
}
interface CnbRelease {
id: string;
tag_name: string;
created_at: string;
}
/** `manifest` with each bundle URL moved from the GitHub release to the CNB one. */
export function mirrorManifest(manifest: Manifest, tag: string): Manifest {
const from = `https://github.com/${GITHUB_REPO}/releases/download/${tag}/`;
const to = `https://cnb.cool/${CNB_REPO}/-/releases/download/${tag}/`;
const platforms: Manifest["platforms"] = {};
for (const [key, entry] of Object.entries(manifest.platforms)) {
if (!entry.url.startsWith(from)) throw new Error(`${key}: ${entry.url} is not an asset of ${tag}`);
platforms[key] = { ...entry, url: to + entry.url.slice(from.length) };
}
return { ...manifest, platforms };
}
/** Releases beyond the newest `keep` (by creation time), never `current`. */
export function releasesToPrune(releases: CnbRelease[], current: string, keep = KEEP): CnbRelease[] {
const newest = [...releases].sort((a, b) => b.created_at.localeCompare(a.created_at));
return newest.slice(keep).filter((r) => r.tag_name !== current);
}
function token(): string {
const value = process.env.CNB_TOKEN?.trim();
if (!value) throw new Error("CNB_TOKEN is not set");
return value;
}
async function api<T>(method: string, path: string, body?: unknown): Promise<T | null> {
const res = await fetch(path.startsWith("https://") ? path : API + path, {
method,
headers: {
authorization: `Bearer ${token()}`,
accept: "application/vnd.cnb.api+json",
...(body === undefined ? {} : { "content-type": "application/json" }),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
if (res.status === 404 && method === "GET") return null;
const text = await res.text();
if (!res.ok) throw new Error(`${method} ${path}: HTTP ${res.status} ${text}`);
return text ? (JSON.parse(text) as T) : null;
}
async function withRetry<T>(what: string, run: () => Promise<T>, attempts = 3): Promise<T> {
for (let i = 1; ; i++) {
try {
return await run();
} catch (e) {
if (i >= attempts) throw e;
console.warn(`${what}: ${e instanceof Error ? e.message : e}; retrying (${i}/${attempts - 1})`);
await new Promise((r) => setTimeout(r, 5000 * i));
}
}
}
async function ensureRelease(tag: string, body: string, prerelease: boolean): Promise<string> {
const existing = await api<CnbRelease>("GET", `/-/releases/tags/${encodeURIComponent(tag)}`);
if (existing) return existing.id;
const created = await api<CnbRelease>("POST", "/-/releases", {
tag_name: tag,
target_commitish: "main",
name: tag,
body,
prerelease,
make_latest: prerelease ? "false" : "true",
});
return created!.id;
}
async function upload(releaseId: string, name: string, file: Blob) {
const target = await api<{ upload_url: string; verify_url: string }>(
"POST",
`/-/releases/${releaseId}/asset-upload-url`,
{ asset_name: name, size: file.size, overwrite: true },
);
const put = await fetch(target!.upload_url, { method: "PUT", body: file });
if (!put.ok) throw new Error(`PUT ${name}: HTTP ${put.status} ${await put.text()}`);
await api("POST", target!.verify_url);
}
async function uploadAll(releaseId: string, dir: string, names: string[]) {
const queue = [...names];
const worker = async () => {
for (let name = queue.shift(); name; name = queue.shift()) {
const path = join(dir, name);
const mb = (statSync(path).size / 1048576).toFixed(1);
const started = Date.now();
await withRetry(name, async () => upload(releaseId, name, await openAsBlob(path)));
console.log(`uploaded ${name} (${mb} MB, ${Math.round((Date.now() - started) / 1000)} s)`);
}
};
await Promise.all(Array.from({ length: PARALLEL }, worker));
}
async function prune(current: string) {
const releases = (await api<CnbRelease[]>("GET", "/-/releases?page_size=100")) ?? [];
for (const old of releasesToPrune(releases, current)) {
await api("DELETE", `/-/releases/${old.id}`);
await api("DELETE", `/-/git/tags/${encodeURIComponent(old.tag_name)}`).catch((e) => console.warn(String(e)));
console.log(`removed ${old.tag_name}`);
}
}
async function main() {
const [tag, dir] = process.argv.slice(2);
if (!tag || !dir) throw new Error("usage: node scripts/cnb-mirror.ts <tag> <dir>");
const info = JSON.parse(readFileSync(join(dir, RELEASE_INFO), "utf8")) as { body?: string; isPrerelease?: boolean };
const manifest = JSON.parse(readFileSync(join(dir, MANIFEST), "utf8")) as Manifest;
const mirrored = mirrorManifest(manifest, tag);
const assets = readdirSync(dir).filter(
(n) => n !== MANIFEST && n !== RELEASE_INFO && statSync(join(dir, n)).isFile(),
);
const releaseId = await ensureRelease(tag, info.body ?? "", !!info.isPrerelease);
console.log(`CNB release ${tag} (${releaseId}): uploading ${assets.length} assets`);
await uploadAll(releaseId, dir, assets);
const blob = new Blob([JSON.stringify(mirrored, null, 2)], { type: "application/json" });
await withRetry(MANIFEST, () => upload(releaseId, MANIFEST, blob));
console.log(`uploaded ${MANIFEST}: ${Object.keys(mirrored.platforms).length} platforms`);
await prune(tag);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
main().catch((e) => {
console.error(e instanceof Error ? e.message : e);
process.exit(1);
});
}
+1 -2
View File
@@ -82,8 +82,7 @@ block2 = "0.6"
# Plugin secrets in the login keychain.
security-framework = "3.7"
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSButton", "NSEvent", "NSResponder", "NSScreen", "NSView", "NSWindow"] }
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "CFRunLoop"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSEvent", "NSResponder", "NSScreen", "NSToolbar", "NSView", "NSWindow"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSNotification", "NSOperation", "NSString"] }
# Used only by the debug font-restriction override; Cargo dependency cfg cannot test debug_assertions.
objc2-web-kit = { version = "0.3", default-features = false, features = ["std", "WKWebViewConfiguration", "WKPreferences"] }
+65 -17
View File
@@ -330,36 +330,74 @@ fn update_endpoint_env() -> Option<String> {
/// tauri.conf.json, or the `OPENIDEA_UPDATE_ENDPOINT` environment variable. Empty by
/// default, and "Check for Updates…" is hidden then.
pub fn updates_configured<R: tauri::Runtime>(app: &AppHandle<R>) -> bool {
let in_config = app
.config()
!config_endpoints(app).is_empty() || update_endpoint_env().is_some()
}
/// Where updates are downloaded from (System Settings › Updates).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum UpdateSource {
/// The GitHub releases named in tauri.conf.json.
#[default]
Github,
/// The CNB mirror of the same releases, for networks where GitHub is slow.
Cnb,
}
const CNB_ENDPOINT: &str = "https://cnb.cool/l0ng-ai/openidea/-/releases/latest/download/latest.json";
fn config_endpoints<R: tauri::Runtime>(app: &AppHandle<R>) -> Vec<String> {
app.config()
.plugins
.0
.get("updater")
.and_then(|u| u.get("endpoints"))
.and_then(|e| e.as_array())
.is_some_and(|e| !e.is_empty());
in_config || update_endpoint_env().is_some()
.map(|e| e.iter().filter_map(|u| u.as_str().map(String::from)).collect())
.unwrap_or_default()
}
fn updater(app: &AppHandle) -> IdeResult<Option<tauri_plugin_updater::Updater>> {
/// The endpoints tried in order: `source` first, the other source when it fails or has
/// no manifest. Bundles are signed, so either source's manifest and downloads verify.
/// No official endpoint (a build without updates) means no mirror either.
fn source_endpoints(official: Vec<String>, source: UpdateSource) -> Vec<String> {
if official.is_empty() {
return official;
}
let mirror = vec![CNB_ENDPOINT.to_string()];
match source {
UpdateSource::Github => [official, mirror].concat(),
UpdateSource::Cnb => [mirror, official].concat(),
}
}
fn updater(app: &AppHandle, source: UpdateSource) -> IdeResult<Option<tauri_plugin_updater::Updater>> {
use tauri_plugin_updater::UpdaterExt;
if !updates_configured(app) {
return Ok(None);
}
let env = update_endpoint_env();
let endpoints = match update_endpoint_env() {
Some(url) => vec![url],
None => source_endpoints(config_endpoints(app), source),
};
let endpoints = endpoints
.iter()
.map(|u| u.parse().map_err(|e| IdeError::invalid(format!("update endpoint {u}: {e}"))))
.collect::<IdeResult<Vec<_>>>()?;
let proxy = ide_core::proxy_settings::proxy();
let mut builder = app.updater_builder().configure_client(move |b| ide_http::client::with_proxy(b, &proxy));
if let Some(url) = env {
let url = url.parse().map_err(|e| IdeError::invalid(format!("OPENIDEA_UPDATE_ENDPOINT: {e}")))?;
builder = builder.endpoints(vec![url]).map_err(|e| IdeError::invalid(e.to_string()))?;
}
builder.build().map(Some).map_err(|e| IdeError::unavailable(e.to_string()))
app.updater_builder()
.configure_client(move |b| ide_http::client::with_proxy(b, &proxy))
.endpoints(endpoints)
.map_err(|e| IdeError::invalid(e.to_string()))?
.build()
.map(Some)
.map_err(|e| IdeError::unavailable(e.to_string()))
}
#[tauri::command]
#[specta::specta]
pub async fn app_check_update(app: AppHandle) -> IdeResult<UpdateCheck> {
let Some(updater) = updater(&app)? else {
pub async fn app_check_update(app: AppHandle, source: UpdateSource) -> IdeResult<UpdateCheck> {
let Some(updater) = updater(&app, source)? else {
return Ok(UpdateCheck { enabled: false, update: None });
};
let update = updater.check().await.map_err(|e| IdeError::unavailable(e.to_string()))?;
@@ -379,12 +417,12 @@ pub async fn app_check_update(app: AppHandle) -> IdeResult<UpdateCheck> {
/// not reported: the user didn't ask for this check.
#[tauri::command]
#[specta::specta]
pub async fn app_startup_update_check(app: AppHandle) -> Option<UpdateInfo> {
pub async fn app_startup_update_check(app: AppHandle, source: UpdateSource) -> Option<UpdateInfo> {
static DONE: AtomicBool = AtomicBool::new(false);
if cfg!(debug_assertions) || DONE.swap(true, Ordering::SeqCst) {
return None;
}
match app_check_update(app).await {
match app_check_update(app, source).await {
Ok(check) => check.update,
Err(e) => {
tracing::warn!("startup update check failed: {e}");
@@ -452,6 +490,7 @@ impl Drop for InstallingGuard {
pub async fn app_install_update(
app: AppHandle,
state: State<'_, AppState>,
source: UpdateSource,
request: RequestId,
on_progress: Channel<UpdateProgress>,
) -> IdeResult<()> {
@@ -459,7 +498,7 @@ pub async fn app_install_update(
InstallingGuard::acquire().ok_or_else(|| IdeError::unavailable("an update is already being installed"))?;
let token = state.host.begin_request(request);
let result = async {
let updater = updater(&app)?.ok_or_else(|| IdeError::unavailable("updates are not configured"))?;
let updater = updater(&app, source)?.ok_or_else(|| IdeError::unavailable("updates are not configured"))?;
let update = tokio::select! {
r = updater.check() => r.map_err(|e| IdeError::unavailable(e.to_string()))?,
_ = token.cancelled() => return Err(IdeError::cancelled()),
@@ -522,6 +561,15 @@ mod tests {
assert!(ProgressThrottle::default().chunk(1, Some(0)).is_some());
}
#[test]
fn chosen_source_is_tried_first_and_the_other_after_it() {
let github = "https://github.com/l0ng-ai/openidea/releases/latest/download/latest.json".to_string();
assert_eq!(source_endpoints(vec![github.clone()], UpdateSource::Github), [github.clone(), CNB_ENDPOINT.into()]);
assert_eq!(source_endpoints(vec![github.clone()], UpdateSource::Cnb), [CNB_ENDPOINT.into(), github]);
// A build without an update endpoint stays without one.
assert!(source_endpoints(Vec::new(), UpdateSource::Cnb).is_empty());
}
#[test]
fn only_one_install_at_a_time() {
let first = InstallingGuard::acquire().expect("first install starts");
-1
View File
@@ -200,7 +200,6 @@ pub fn run() {
tauri::WindowEvent::Destroyed => {
#[cfg(target_os = "macos")]
{
titlebar::forget(window);
macos::forget_fullscreen(window.label());
}
if windows::auxiliary_parent(window.label()).is_none() {
+22 -98
View File
@@ -1,106 +1,30 @@
//! Keep the native window buttons aligned with the web toolbar, in AppKit points.
//! Size the native title bar around the window buttons to match the web toolbar.
//!
//! An empty unified toolbar makes AppKit lay out and vertically centre the buttons itself, so
//! nothing moves them behind its back and they stay still during live resize.
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::rc::Rc;
use objc2::MainThreadMarker;
use objc2_app_kit::{NSToolbar, NSWindow, NSWindowToolbarStyle};
use block2::RcBlock;
use objc2_app_kit::{NSWindow, NSWindowButton, NSWindowStyleMask};
use objc2_core_foundation::{
CFIndex, CFOptionFlags, CFRetained, CFRunLoop, CFRunLoopActivity, CFRunLoopObserver, kCFRunLoopCommonModes,
};
use objc2_foundation::NSPoint;
/// AppKit lays out the title bar in a run loop observer and puts the buttons back at their
/// defaults on resize, title changes and fullscreen exit. Core Animation commits at 2000000, so
/// correcting the frames just before it keeps AppKit's positions off screen.
const BEFORE_COMMIT: CFIndex = 1_999_999;
struct Titlebar {
height: Rc<Cell<f64>>,
spacing: f64,
observer: CFRetained<CFRunLoopObserver>,
}
impl Drop for Titlebar {
fn drop(&mut self) {
self.observer.invalidate();
}
}
thread_local! {
static TITLEBARS: RefCell<HashMap<String, Titlebar>> = RefCell::default();
}
/// Unified compact (38pt) and unified (52pt) toolbars; pick the one closer to the web bar.
const COMPACT_MAX_HEIGHT: f64 = 45.0;
pub fn set_height(window: &tauri::WebviewWindow, height: f64) -> tauri::Result<()> {
let label = window.label().to_owned();
// with_webview runs on the AppKit thread; the observer is also delivered there.
// with_webview runs on the AppKit thread.
window.with_webview(move |webview| unsafe {
let Some(mtm) = MainThreadMarker::new() else { return };
let native = &*webview.ns_window().cast::<NSWindow>();
let Some(close) = native.standardWindowButton(NSWindowButton::CloseButton) else { return };
let Some(minimize) = native.standardWindowButton(NSWindowButton::MiniaturizeButton) else { return };
let Some(run_loop) = CFRunLoop::main() else { return };
let spacing = minimize.frame().origin.x - close.frame().origin.x;
TITLEBARS.with_borrow_mut(|bars| {
if let Some(bar) = bars.get(&label) {
bar.height.set(height);
center_buttons(native, height, bar.spacing);
return;
}
let shared = Rc::new(Cell::new(height));
let current = shared.clone();
let pointer = native as *const NSWindow;
let block = RcBlock::new(move |_: *mut CFRunLoopObserver, _: CFRunLoopActivity| {
center_buttons(&*pointer, current.get(), spacing)
});
let activities = CFRunLoopActivity::BeforeWaiting.0 as CFOptionFlags;
let Some(observer) = CFRunLoopObserver::with_handler(None, activities, true, BEFORE_COMMIT, Some(&block))
else {
return;
};
run_loop.add_observer(Some(&observer), kCFRunLoopCommonModes);
center_buttons(native, height, spacing);
bars.insert(label, Titlebar { height: shared, spacing, observer });
});
if native.toolbar().is_none() {
let toolbar = NSToolbar::new(mtm);
native.setToolbar(Some(&toolbar));
}
let style = if height < COMPACT_MAX_HEIGHT {
NSWindowToolbarStyle::UnifiedCompact
} else {
NSWindowToolbarStyle::Unified
};
if native.toolbarStyle() != style {
native.setToolbarStyle(style);
}
})
}
pub fn forget(window: &tauri::Window) {
let label = window.label().to_owned();
let _ = window.run_on_main_thread(move || {
TITLEBARS.with_borrow_mut(|bars| bars.remove(&label));
});
}
unsafe fn center_buttons(window: &NSWindow, height: f64, spacing: f64) {
// Fullscreen uses AppKit's own revealable title bar.
if window.styleMask().contains(NSWindowStyleMask::FullScreen) {
return;
}
let Some(close) = window.standardWindowButton(NSWindowButton::CloseButton) else { return };
let Some(container) = (unsafe { close.superview().and_then(|view| view.superview()) }) else { return };
let Some(content) = window.contentView() else { return };
let mut frame = container.frame();
let top = frame.origin.y + frame.size.height;
if (frame.size.height - height).abs() > 0.01 {
frame.size.height = height;
frame.origin.y = top - height;
container.setFrame(frame);
}
let bounds = content.bounds();
let y = bounds.origin.y + if content.isFlipped() { height / 2.0 } else { bounds.size.height - height / 2.0 };
for (index, kind) in [NSWindowButton::CloseButton, NSWindowButton::MiniaturizeButton, NSWindowButton::ZoomButton]
.into_iter()
.enumerate()
{
let Some(button) = window.standardWindowButton(kind) else { continue };
let Some(parent) = (unsafe { button.superview() }) else { continue };
let center = parent.convertPoint_fromView(NSPoint::new(0.0, y), Some(&content));
let frame = button.frame();
let x = 14.0 + index as f64 * spacing;
let target = center.y - frame.size.height / 2.0;
if (frame.origin.x - x).abs() > 0.01 || (frame.origin.y - target).abs() > 0.01 {
button.setFrameOrigin(NSPoint::new(x, target));
}
}
}
+1 -1
View File
@@ -26,7 +26,7 @@
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' asset: data: blob: https://avatars.githubusercontent.com; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src ipc: http://ipc.localhost; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; frame-src http://127.0.0.1:* openidea-plugin: http://openidea-plugin.localhost"
"csp": "default-src 'self'; img-src 'self' asset: data: blob: http: https:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src ipc: http://ipc.localhost; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; frame-src http://127.0.0.1:* openidea-plugin: http://openidea-plugin.localhost"
}
},
"bundle": {
+13 -4
View File
@@ -910,24 +910,24 @@ export const commands = {
menuSetDisabled: (ids: string[]) => __TAURI_INVOKE<void>("menu_set_disabled", { ids }),
appLogLocation: () => typedError<LogLocation, IdeError>(__TAURI_INVOKE("app_log_location")),
appBuildInfo: () => __TAURI_INVOKE<BuildInfo>("app_build_info"),
appCheckUpdate: () => typedError<UpdateCheck, IdeError>(__TAURI_INVOKE("app_check_update")),
appCheckUpdate: (source: UpdateSource) => typedError<UpdateCheck, IdeError>(__TAURI_INVOKE("app_check_update", { source })),
/**
* The background check made once per process when the first window starts;
* later calls (other windows) and debug builds get `None`. Failures are logged,
* not reported: the user didn't ask for this check.
*/
appStartupUpdateCheck: () => __TAURI_INVOKE<{
appStartupUpdateCheck: (source: UpdateSource) => __TAURI_INVOKE<{
version: string,
currentVersion: string,
notes: string | null,
date: string | null,
} | null>("app_startup_update_check"),
} | null>("app_startup_update_check", { source }),
/**
* Downloads and installs the available update, streaming progress to `on_progress`.
* Cancellable through `request` until the download completes; the UI then restarts
* with `app_restart`. One install runs at a time.
*/
appInstallUpdate: (request: RequestId, onProgress: Channel<UpdateProgress>) => typedError<null, IdeError>(__TAURI_INVOKE("app_install_update", { request, onProgress })),
appInstallUpdate: (source: UpdateSource, request: RequestId, onProgress: Channel<UpdateProgress>) => typedError<null, IdeError>(__TAURI_INVOKE("app_install_update", { source, request, onProgress })),
debugExceptionFilters: () => typedError<SessionExceptionFilters[], IdeError>(__TAURI_INVOKE("debug_exception_filters")),
debugSetExceptionBreakpoints: (choices: { [key in string]: boolean }) => typedError<null, IdeError>(__TAURI_INVOKE("debug_set_exception_breakpoints", { choices })),
debugSetVariable: (session: DebugSessionId, container: number, name: string, evaluateName: string | null, frameId: number | null, value: string) => typedError<Variable, IdeError>(__TAURI_INVOKE("debug_set_variable", { session, container, name, evaluateName, frameId, value })),
@@ -3866,6 +3866,8 @@ export type GeneralSettings = {
powerSaveMode?: boolean,
/** File › Rename Project… (project layer): the name shown for the project; empty = its folder name. */
projectName?: string,
/** Where updates come from: `auto` (the CNB mirror for the Chinese UI, else GitHub) | `github` | `cnb`. */
updateSource?: string,
};
/** A repository of the signed-in account (owned, collaborator, or organization member). */
@@ -7598,6 +7600,13 @@ export type UpdateProgress =
/** Download done; the new version is being written over this one. */
{ type: "installing" };
/** Where updates are downloaded from (System Settings › Updates). */
export type UpdateSource =
/** The GitHub releases named in tauri.conf.json. */
"github" |
/** The CNB mirror of the same releases, for networks where GitHub is slow. */
"cnb";
export type Variable = {
name: string,
value: string,
@@ -30,6 +30,19 @@
min-height: 0;
min-width: 0;
position: relative;
isolation: isolate;
}
/*
* Kept-alive tabs share the content box; hiding one only skips its rendering, not its layout.
* Nothing inherited changes (pointer-events, inert): that would restyle the whole conversation.
*/
.kept {
position: absolute;
inset: 0;
}
.keptHidden {
content-visibility: hidden;
z-index: -1;
}
.host {
flex: 1;
+42 -4
View File
@@ -19,11 +19,13 @@ import {
getFileDecoration,
prepareMenuGroups,
textEditorWrapperFor,
EditorShownProvider,
useContributions,
useFileDecorations,
useEditorDocumentRevision,
useTextEditorWrappers,
type EditorInput,
type EditorKindContribution,
} from "@/workbench/contributions";
import { useDistractionFree } from "@/workbench/distractionFree";
import { EmptyEditor } from "@/workbench/EditorArea";
@@ -276,6 +278,7 @@ function PaneView({ paneId }: { paneId: string }) {
if (!pane) return null;
const active = pane.tabs.find((t) => t.id === pane.active) ?? null;
const kept = keptTabs(pane.tabs, pane.mru, kinds);
const onFocus = () => {
setContext("editorFocus", true);
@@ -355,7 +358,14 @@ function PaneView({ paneId }: { paneId: string }) {
aria-labelledby={active ? `editor-${paneId}-tab-${active.id}` : undefined}
ref={contentRef}
>
{active ? <PaneContent paneId={paneId} tab={active} tabsVisible={!hidden} /> : <EmptyEditor />}
{kept.map((tab) => (
<KindEditor key={tab.id} tab={tab} shown={tab === active} kept />
))}
{!active ? (
<EmptyEditor />
) : (
!kept.includes(active) && <PaneContent paneId={paneId} tab={active} tabsVisible={!hidden} />
)}
{dropZone && <div className={clsx(styles.dropZone, styles[`drop-${dropZone}`])} aria-hidden="true" />}
</div>
</section>
@@ -370,7 +380,6 @@ function virtualTooltip(input: EditorInput): string | null {
}
function PaneContent({ paneId, tab, tabsVisible }: { paneId: string; tab: Tab; tabsVisible: boolean }) {
const kinds = useContributions((s) => s.editorKinds);
const crumbs = !useDistractionFree((s) => s.active);
const wrappers = useTextEditorWrappers((s) => s.wrappers);
if (isTextInput(tab.input)) {
@@ -402,12 +411,41 @@ function PaneContent({ paneId, tab, tabsVisible }: { paneId: string; tab: Tab; t
</>
);
}
return <KindEditor key={tab.id} tab={tab} shown />;
}
/** Tabs of a pane kept mounted while hidden: the most recently used ones of `keepAlive` kinds. */
const KEPT_TABS = 5;
function keptTabs(tabs: Tab[], mru: string[], kinds: Map<string, EditorKindContribution>): Tab[] {
const recent = mru
.map((id) => tabs.find((t) => t.id === id))
.filter((t): t is Tab => !!t && !!kinds.get(t.input.kind)?.keepAlive)
.slice(0, KEPT_TABS);
// In tab order, so switching tabs doesn't move their DOM (which would lose scroll offsets).
return tabs.filter((t) => recent.includes(t));
}
/**
* `shown`: the pane's visible tab, which focusing the editor area reaches. A hidden `kept` tab
* keeps its layout (content-visibility), so showing it again needn't lay it out from scratch;
* its content can't be focused or hit while skipped.
*/
function KindEditor({ tab, shown, kept = false }: { tab: Tab; shown: boolean; kept?: boolean }) {
const kinds = useContributions((s) => s.editorKinds);
const kind = kinds.get(tab.input.kind);
if (!kind) return <EmptyState icon="file" title={tx`No editor for “${tab.input.kind}”`} />;
const Component = kind.component;
return (
<div className={styles.kind} data-editor-focus="" tabIndex={-1} key={tab.id}>
<Component input={tab.input} />
<div
className={clsx(styles.kind, kept && styles.kept, !shown && styles.keptHidden)}
data-editor-focus={shown ? "" : undefined}
tabIndex={-1}
aria-hidden={shown ? undefined : true}
>
<EditorShownProvider value={shown}>
<Component input={tab.input} />
</EditorShownProvider>
</div>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import { useState } from "react";
import { registerEditorKind, type EditorInput } from "@/workbench/contributions";
import { useTabs } from "../controller";
import type { Tab } from "../model/tabs";
import { EditorAreaView } from "./EditorArea";
vi.mock("../controller", async (original) => ({
...(await original<object>()),
editorController: () => ({ publishContext: vi.fn(), tabMenu: () => [] }),
}));
vi.mock("./TextEditorHost", () => ({
TextEditorHost: () => <input aria-label="Source" />,
}));
vi.mock("./FindBar", () => ({ FindBar: () => null }));
vi.mock("./Banner", () => ({
EditorBanners: () => null,
VirtualBanner: () => null,
}));
vi.mock("./StatusItems", () => ({ CodeBreadcrumbs: () => null }));
const mounts: string[] = [];
function Kept({ input }: { input: EditorInput }) {
const name = input.key;
useState(() => mounts.push(name));
return <div data-testid={name}>{name}</div>;
}
const disposers = [
registerEditorKind({
kind: "kept",
component: Kept,
title: (i) => i.key,
keepAlive: true,
}),
registerEditorKind({ kind: "plain", component: Kept, title: (i) => i.key }),
];
/** Shown, or kept mounted behind the shown tab (its rendering skipped). */
const shown = (id: string) => !screen.getByTestId(id).closest("[aria-hidden]");
const tab = (id: string, kind: string): Tab => ({
id,
input: { kind, key: id },
preview: false,
pinned: false,
});
const original = useTabs.getState();
function show(tabs: Tab[], active: string) {
act(() =>
useTabs.setState((s) => {
const mru = [active, ...(s.panes.p1?.mru ?? []).filter((id) => id !== active && tabs.some((t) => t.id === id))];
return {
layout: { kind: "pane", paneId: "p1" },
panes: { p1: { id: "p1", tabs, active, mru } },
activePane: "p1",
closed: [],
};
}),
);
}
afterEach(() => {
cleanup();
mounts.length = 0;
useTabs.setState(original, true);
});
afterAll(() => disposers.forEach((d) => d()));
describe("keep-alive editor kinds", () => {
it("keep their recent tabs mounted and hidden while another tab is active", () => {
const tabs = [tab("a", "kept"), tab("b", "kept"), tab("c", "plain")];
show(tabs, "a");
render(<EditorAreaView />);
show(tabs, "b");
show(tabs, "c");
expect(shown("a")).toBe(false);
expect(shown("b")).toBe(false);
expect(shown("c")).toBe(true);
show(tabs, "a");
show(tabs, "c");
// Only the plain tab is built again; the kept ones are shown as they were.
expect(mounts).toEqual(["a", "b", "c", "c"]);
expect(screen.queryAllByTestId("c")).toHaveLength(1);
});
it("let the least recently used kept tabs go beyond the limit", () => {
const tabs = Array.from({ length: 7 }, (_, i) => tab(`k${i}`, "kept"));
show(tabs, "k0");
render(<EditorAreaView />);
for (const t of tabs) show(tabs, t.id);
expect(screen.queryByTestId("k0")).toBeNull();
expect(screen.queryByTestId("k1")).toBeNull();
expect(shown("k2")).toBe(false);
expect(shown("k6")).toBe(true);
});
});
+77 -4
View File
@@ -3,7 +3,7 @@ import { EditorState, Text } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { history, undo } from "@codemirror/commands";
import type { MessageRequest } from "@/workbench/messages";
import type { LocalSymbol, Location, Locals, OutlineItem, Range, StructuralRanges, SyntaxNode } from "@/bindings";
import type { LocalSymbol, Location, Locals, OutlineItem, Pos, Range, StructuralRanges, SyntaxNode } from "@/bindings";
// --- backend fakes ---------------------------------------------------------
@@ -15,11 +15,12 @@ const unavailable = () => ({ status: "error" as const, error: { kind: "unavailab
let goto: Answer<Location[]> = ok([]);
let outline: Answer<OutlineItem[]> = ok([]);
let references: Answer<Location[]> = ok([]);
/** Called with the position searched from. */
let references: (pos: Pos) => ReturnType<Answer<Location[]>> = ok([]);
const lspGoto = vi.fn(async () => goto());
const docOutline = vi.fn(async () => outline());
const lspReferences = vi.fn(async () => {
const r = references();
const lspReferences = vi.fn(async (_request: number, _doc: number, pos: Pos) => {
const r = references(pos);
return r.status === "ok" ? { status: "ok" as const, data: { locations: r.data, incomplete: null } } : r;
});
const lspCodeActions = vi.fn(async () => ({ status: "ok", data: [] }));
@@ -411,6 +412,78 @@ describe("fileUsages", () => {
expect(docClose).toHaveBeenCalledTimes(2);
});
// #1656: packages and namespaces are shared with other files; their members are what the file declares.
describe("searches members of packages and namespaces, not the container", () => {
const at = (path: string, line: number): Location => ({
path,
range: { start: { line, col: 0 }, end: { line, col: 1 } },
});
const within = (parent: OutlineItem, ...children: OutlineItem[]): OutlineItem => ({ ...parent, children });
/** References per searched name; `searched` records the order. */
function serve(text: string, refs: Record<string, Location[]>) {
const searched: string[] = [];
references = (pos) => {
const off = doc(text).line(pos.line + 1).from + pos.col;
const name = Object.keys(refs).find((n) => text.startsWith(n, off)) ?? "?";
searched.push(name);
return { status: "ok", data: refs[name] ?? [] };
};
return searched;
}
it("Java: the package's other classes imported elsewhere are not usages", async () => {
const text = "package com.example.config;\n\npublic class MessageSourceConfig {}\n";
outline = ok([
item(text, "com.example.config", "package com.example.config;", "package"),
item(text, "MessageSourceConfig", "public class MessageSourceConfig {}", "class"),
]);
// References to the package: an import of its sibling class CommunicationConfig.
const searched = serve(text, {
"com.example.config": [at("/p/server/CommandSender.java", 2)],
MessageSourceConfig: [],
});
expect(await fileUsages(["/p/config/MessageSourceConfig.java"])).toEqual({ status: "ok", usages: [] });
expect(searched).toEqual(["MessageSourceConfig"]);
});
it("C++: a class inside a namespace is searched", async () => {
const text = "namespace config {\nclass Used {};\nnamespace detail {\nint helper();\n}\n}\n";
const ns = item(
text,
"config",
"namespace config {\nclass Used {};\nnamespace detail {\nint helper();\n}\n}",
"namespace",
);
const detail = item(text, "detail", "namespace detail {\nint helper();\n}", "namespace");
outline = ok([
within(
ns,
item(text, "Used", "class Used {};", "class"),
within(detail, item(text, "helper", "int helper();")),
),
]);
const searched = serve(text, { Used: [at("/p/main.cpp", 4)], helper: [at("/p/main.cpp", 5)] });
expect(await fileUsages(["/p/config/used.h"])).toEqual({
status: "ok",
usages: [at("/p/main.cpp", 4), at("/p/main.cpp", 5)],
});
expect(searched).toEqual(["Used", "helper"]);
});
it("modules a file declares (TS namespace, Rust mod) are still searched", async () => {
const text = "export namespace Utils {\n export const version = 1;\n}\n";
outline = ok([
within(
item(text, "Utils", "export namespace Utils {\n export const version = 1;\n}", "module"),
item(text, "version", "version = 1", "variable"),
),
]);
const searched = serve(text, { Utils: [at("/p/main.ts", 5)] });
expect(await fileUsages(["/p/lib/ns.ts"])).toEqual({ status: "ok", usages: [at("/p/main.ts", 5)] });
expect(searched).toEqual(["Utils"]);
});
});
it("is unsupported when the language server can't search", async () => {
outline = ok([item(text, "a", "pub fn a() {}")]);
references = unavailable;
+14 -3
View File
@@ -507,9 +507,20 @@ export type FileUsages =
/** The language server can't tell (none running, no document symbols…). */
| { status: "unsupported"; reason: string };
/** At most this many top-level symbols per file are searched. */
/** At most this many symbols per file are searched. */
const MAX_SYMBOLS = 60;
/**
* The symbols a file declares at its top level. Packages and namespaces are shared
* with other files (IDEA searches a Java file's classes, not its package), so their
* members stand in for them.
*/
function declaredSymbols(items: readonly OutlineItem[]): OutlineItem[] {
return items.flatMap((it) =>
it.kind === "package" || it.kind === "namespace" ? declaredSymbols(it.children) : [it],
);
}
async function withDoc<T>(path: string, fn: (doc: number) => Promise<T>): Promise<T> {
const open = editorsFor(path)[0];
if (open) return fn(open.ctx.doc);
@@ -522,7 +533,7 @@ async function withDoc<T>(path: string, fn: (doc: number) => Promise<T>): Promis
}
/**
* References from outside `paths` to the top-level symbols the files declare.
* References from outside `paths` to the symbols the files declare ({@link declaredSymbols}).
* Unsupported when no reference search could run for any of them.
*/
export async function fileUsages(paths: readonly string[], signal?: AbortSignal): Promise<FileUsages> {
@@ -536,7 +547,7 @@ export async function fileUsages(paths: readonly string[], signal?: AbortSignal)
try {
await withDoc(path, async (doc) => {
const outline = await call(commands.docOutline(doc));
for (const item of outline.slice(0, MAX_SYMBOLS)) {
for (const item of declaredSymbols(outline).slice(0, MAX_SYMBOLS)) {
if (signal?.aborted) return;
let refs: Location[];
try {
+98 -3
View File
@@ -1,15 +1,23 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { clearMocks, mockIPC } from "@tauri-apps/api/mocks";
import { Markdown } from "./Markdown";
const { openFile, openExternalUrl } = vi.hoisted(() => ({
openFile: vi.fn().mockResolvedValue(undefined),
openExternalUrl: vi.fn().mockResolvedValue(undefined),
}));
const readImage = vi.fn<(path: string) => Promise<string>>();
const PIXEL = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
vi.mock("@/workbench/contributions", () => ({ editorService: () => ({ openFile }) }));
vi.mock("@/platform/openUrl", () => ({ openExternalUrl }));
beforeEach(() => vi.clearAllMocks());
beforeEach(() => {
vi.clearAllMocks();
readImage.mockReset().mockResolvedValue(PIXEL);
mockIPC((cmd, args) => (cmd === "fs_read_image" ? readImage((args as { path: string }).path) : null));
});
afterEach(clearMocks);
it.each([
"<style>body{outline:7px solid red}</style>",
@@ -71,3 +79,90 @@ it.each(["#section", "//example.com/image.jpg", "javascript:alert(1)", "/tmp/%ZZ
expect(openExternalUrl).not.toHaveBeenCalled();
},
);
it.each([
["/Users/thomas/vmos-shots/tiktok-home.jpg", "/repo", "/Users/thomas/vmos-shots/tiktok-home.jpg"],
["images/home.jpg", "/repo", "/repo/images/home.jpg"],
["/tmp/截图%20one.jpg", "/repo", "/tmp/截图 one.jpg"],
["file:///tmp/auth%20code.png", "/repo", "/tmp/auth code.png"],
["C:/shots/code.png", "C:/repo", "C:/shots/code.png"],
["file:///C:/shots/code.png", "C:/repo", "C:/shots/code.png"],
])("loads a reply image from the project host: %s", async (src, cwd, path) => {
render(<Markdown text={`![QR code](${src})`} cwd={cwd} />);
await waitFor(() => expect(readImage).toHaveBeenCalledWith(path));
await waitFor(() => expect(screen.getByRole("img", { name: "QR code" })).toHaveAttribute("src", PIXEL));
});
it("loads raw HTML images without allowing alternate sources or forged paths", async () => {
const { container } = render(
<Markdown
text={
'<picture><source srcset="/wrong.png"><img alt="QR" src="/tmp/code.png" srcset="/wrong.png 2x" data-image-src="/forged.png" onerror="alert(1)"></picture>'
}
cwd="/repo"
/>,
);
await waitFor(() => expect(screen.getByRole("img")).toHaveAttribute("src", PIXEL));
expect(readImage).toHaveBeenCalledExactlyOnceWith("/tmp/code.png");
expect(container.querySelector("source, [srcset], [onerror]")).toBeNull();
});
it.each([PIXEL, "https://example.com/code.png", "http://localhost:9000/code.png"])(
"keeps an inline or web image: %s",
(src) => {
render(<Markdown text={`![QR](${src})`} cwd="/repo" />);
expect(screen.getByRole("img")).toHaveAttribute("src", src);
expect(screen.getByRole("img")).toHaveAttribute("referrerpolicy", "no-referrer");
expect(readImage).not.toHaveBeenCalled();
},
);
it("reuses image reads while a reply streams, but reads again for another message", async () => {
const { rerender, unmount } = render(<Markdown text="![QR](/tmp/stream.png)" cwd="/repo" />);
await waitFor(() => expect(screen.getByRole("img")).toHaveAttribute("src", PIXEL));
rerender(<Markdown text="![QR](/tmp/stream.png)\n\nScan to authorize." cwd="/repo" />);
await waitFor(() => expect(screen.getByRole("img")).toHaveAttribute("src", PIXEL));
expect(readImage).toHaveBeenCalledTimes(1);
unmount();
render(<Markdown text="![QR](/tmp/stream.png)" cwd="/repo" />);
await waitFor(() => expect(readImage).toHaveBeenCalledTimes(2));
});
it("reports read failure and retries on the next render", async () => {
readImage.mockRejectedValueOnce({ kind: "io", message: "missing", data: null });
const { rerender } = render(<Markdown text="![QR](/tmp/missing.png)" cwd="/repo" />);
await waitFor(() => expect(screen.getByRole("img")).toHaveAttribute("alt", "Unable to load image: /tmp/missing.png"));
expect(screen.getByRole("img")).not.toHaveAttribute("src");
rerender(<Markdown text="![QR](/tmp/missing.png)\n\nNow saved." cwd="/repo" />);
await waitFor(() => expect(screen.getByRole("img")).toHaveAttribute("src", PIXEL));
});
it("does not update detached images after a streamed reply replaces them", async () => {
let resolve!: (data: string) => void;
readImage.mockReturnValueOnce(
new Promise((done) => {
resolve = done;
}),
);
const { rerender } = render(<Markdown text="![QR](/tmp/pending.png)" cwd="/repo" />);
const oldImage = screen.getByRole("img");
rerender(<Markdown text="Image removed." cwd="/repo" />);
await act(async () => resolve(PIXEL));
expect(oldImage).not.toHaveAttribute("src");
});
it.each(["javascript:alert(1)", "data:text/html,test", "/tmp/%ZZ.png", "/tmp/code%00.png"])(
"does not load unsafe or malformed image sources: %s",
(src) => {
render(<Markdown text={`<img alt="QR" src="${src}">`} cwd="/repo" />);
expect(screen.getByRole("img")).not.toHaveAttribute("src");
expect(readImage).not.toHaveBeenCalled();
},
);
it("reports network or decode failures without leaving a broken source", () => {
render(<Markdown text="![QR](https://example.com/gone.png)" cwd="/repo" />);
fireEvent.error(screen.getByRole("img"));
expect(screen.getByRole("img")).not.toHaveAttribute("src");
expect(screen.getByRole("img")).toHaveAttribute("alt", "Unable to load image: https://example.com/gone.png");
});
+29 -5
View File
@@ -13,6 +13,7 @@ import { openExternalUrl } from "@/platform/openUrl";
import { copyText } from "@/ui";
import { editorService } from "@/workbench/contributions";
import { fileHref, pathLink, type PathLink } from "./conversation";
import { loadImages } from "./markdownImages";
import styles from "./Session.module.css";
const marked = new Marked({ gfm: true, breaks: true });
@@ -21,6 +22,26 @@ const marked = new Marked({ gfm: true, breaks: true });
const rendered = new Map<string, string>();
const RENDERED_MAX = 500;
let purifier: ReturnType<typeof DOMPurify> | null = null;
function purify(): ReturnType<typeof DOMPurify> {
if (purifier) return purifier;
const p = DOMPurify(window);
const sources = new WeakMap<Node, string>();
// Preserve image-only file URLs without relaxing URI sanitizing for links or other tags.
p.addHook("beforeSanitizeAttributes", (node) => {
if (node.tagName !== "IMG") return;
sources.set(node, node.getAttribute("src") ?? "");
node.removeAttribute("src");
});
p.addHook("afterSanitizeAttributes", (node) => {
const src = sources.get(node);
if (src) node.setAttribute("data-image-src", src);
});
purifier = p;
return p;
}
export function markdownHtml(text: string): string {
let html = rendered.get(text);
if (html === undefined) {
@@ -35,11 +56,11 @@ function renderHtml(text: string): string {
const html = marked.parse(text, { async: false }) as string;
// Replies can quote repository content. Styles would reach the whole IDE, and the click
// handlers below trust `data-copy` / `data-path`, which only `enhance` may set.
const root = DOMPurify.sanitize(html, {
const root = purify().sanitize(html, {
USE_PROFILES: { html: true },
RETURN_DOM: true,
FORBID_TAGS: ["style", "form"],
FORBID_ATTR: ["style"],
FORBID_TAGS: ["style", "form", "source"],
FORBID_ATTR: ["style", "srcset"],
ALLOW_DATA_ATTR: false,
}) as HTMLElement;
root.querySelectorAll("li").forEach(wrapLooseText);
@@ -92,8 +113,9 @@ export function openPathLink(link: PathLink) {
}
/** Highlighting, Copy buttons and file links over freshly rendered `root`. */
function enhance(root: HTMLElement, cwd: string): () => void {
function enhance(root: HTMLElement, cwd: string, images: Map<string, Promise<string>>): () => void {
let alive = true;
const disposeImages = loadImages(root, cwd, images);
root.querySelectorAll<HTMLElement>("pre > code").forEach((code) => {
const pre = code.parentElement!;
const lang = [...code.classList].find((c) => c.startsWith("language-"))?.slice("language-".length) ?? null;
@@ -125,13 +147,15 @@ function enhance(root: HTMLElement, cwd: string): () => void {
});
return () => {
alive = false;
disposeImages();
};
}
export function Markdown({ text, cwd }: { text: string; cwd: string }) {
const html = useMemo(() => markdownHtml(text), [text]);
const ref = useRef<HTMLDivElement>(null);
useLayoutEffect(() => (ref.current ? enhance(ref.current, cwd) : undefined), [html, cwd]);
const images = useRef(new Map<string, Promise<string>>());
useLayoutEffect(() => (ref.current ? enhance(ref.current, cwd, images.current) : undefined), [html, cwd]);
return (
<div
ref={ref}
+4 -1
View File
@@ -157,7 +157,10 @@
.stream:focus {
outline: none;
}
/* Skip off-screen output layout; editable messages must repaint immediately when focus/content changes. */
/*
* Skip off-screen output layout; editable messages must repaint immediately when focus/content changes.
* Thinking and tool output sit in a <div>: on <details> itself WebKit wouldn't expand them.
*/
.stream > :not(.message):not(details) {
content-visibility: auto;
contain-intrinsic-block-size: auto 80px;
+29 -18
View File
@@ -1,7 +1,9 @@
/** An agent task as an editor tab: the conversation, a composer and the task's changed files. */
import {
Fragment,
memo,
startTransition,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
@@ -15,7 +17,7 @@ import type { AgentChange, AgentEntry, AgentImage, AgentLimit, AgentRetry, Agent
import { executeAction } from "@/platform/actions";
import { call, commands, onIdeEvent } from "@/platform/ipc";
import { basename, relativeDir, useProject } from "@/platform/project";
import { editorService, notifications, type EditorInput } from "@/workbench/contributions";
import { editorService, notifications, useEditorShown, type EditorInput } from "@/workbench/contributions";
import {
Button,
EmptyState,
@@ -105,15 +107,17 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) {
if (!useAgents.getState().entries.has(id)) void loadEntries(id).catch(() => undefined);
void refreshChanges(id);
}, [id]);
// A kept-alive tab stays mounted behind the shown one: it isn't read there, nor searched.
const shown = useEditorShown();
useEffect(() => {
if (!docked || !changesOpen) return markVisible(id);
}, [id, docked, changesOpen]);
if (shown && (!docked || !changesOpen)) return markVisible(id);
}, [id, docked, changesOpen, shown]);
useEffect(() => {
if (view && !useAgents.getState().entries.has(entriesKey(id, view))) {
void loadEntries(id, view).catch(() => undefined);
}
}, [id, view]);
const openSubagent = (sub: string) => setPath((p) => [...p, sub]);
const openSubagent = useCallback((sub: string) => setPath((p) => [...p, sub]), []);
const [finding, setFinding] = useState(false);
const [findFocus, setFindFocus] = useState(0);
const [session] = useState<FocusedSession>(() => ({
@@ -157,7 +161,7 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) {
entries={viewEntries ?? null}
subagent={view ? (cards.at(-1) ?? null) : null}
onOpenSubagent={openSubagent}
finding={finding}
finding={finding && shown}
findFocus={findFocus}
onCloseFind={() => setFinding(false)}
/>
@@ -441,9 +445,11 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
useEffect(() => {
if (start > 0) startTransition(() => setShown((n) => n + ENTRY_BATCH));
}, [start]);
// Hidden behind another tab, it has no layout to scroll; shown again, it follows the end again.
const visible = useEditorShown();
useLayoutEffect(() => {
const el = ref.current;
if (!el || entries === null) return;
if (!el || entries === null || !visible) return;
if (position.current.stick) el.scrollTop = el.scrollHeight;
else if (!restored.current) el.scrollTop = position.current.top;
restored.current = true;
@@ -726,7 +732,8 @@ const TOOL_ICONS: Record<string, string> = {
think: "todo",
};
function Entry({
/** Memoized: the earlier entries rendered behind a reopened conversation leave the rest alone. */
const Entry = memo(function Entry({
task,
entry,
turn,
@@ -747,12 +754,14 @@ function Entry({
return <Markdown text={entry.text} cwd={task.cwd} />;
case "thought":
return (
<details className={styles.thought}>
<summary>
<T>Thinking</T>
</summary>
<div className={styles.thoughtText}>{entry.text}</div>
</details>
<div>
<details className={styles.thought}>
<summary>
<T>Thinking</T>
</summary>
<div className={styles.thoughtText}>{entry.text}</div>
</details>
</div>
);
case "tool":
return <ToolEntry task={task} entry={entry} />;
@@ -801,7 +810,7 @@ function Entry({
</div>
);
}
}
});
function UserMessage({
task,
@@ -1049,10 +1058,12 @@ function ToolEntry({ task, entry }: { task: AgentTask; entry: Extract<AgentEntry
);
if (!entry.output) return <div className={styles.tool}>{head}</div>;
return (
<details className={styles.toolDetails}>
<summary className={styles.tool}>{head}</summary>
<pre className={styles.output}>{entry.output}</pre>
</details>
<div>
<details className={styles.toolDetails}>
<summary className={styles.tool}>{head}</summary>
<pre className={styles.output}>{entry.output}</pre>
</details>
</div>
);
}
+2
View File
@@ -73,6 +73,8 @@ export function activate(): () => void {
icon: () => "toolwindow-agents",
// Tabs of tasks deleted meanwhile are closed when the tasks load (`connect`).
restorable: true,
// Rebuilding a long conversation on every switch back is slow.
keepAlive: true,
}),
registerEditorKind({
kind: HOME_KIND,
@@ -0,0 +1,37 @@
import { expect, it } from "vitest";
import { imageTarget } from "./markdownImages";
it.each([
["images/code.png", "/repo", "/repo/images/code.png"],
["../code.png?v=2#preview", "/repo", "/repo/../code.png"],
["/tmp/截图%20one%23.png", "/repo", "/tmp/截图 one#.png"],
["file://localhost/tmp/code.png", "/repo", "/tmp/code.png"],
["C:/shots/code.png", "/repo", "C:/shots/code.png"],
[String.raw`C:\shots\code.png`, "C:/repo", "C:/shots/code.png"],
[String.raw`images\code.png`, String.raw`C:\repo`, "C:/repo/images/code.png"],
[String.raw`\\server\share\code.png`, "C:/repo", "//server/share/code.png"],
["file://server/share/code.png", "C:/repo", "//server/share/code.png"],
["file:///C:/shots/code%20one.png", "C:/repo", "C:/shots/code one.png"],
])("resolves host image paths: %s", (src, cwd, path) => {
expect(imageTarget(src, cwd)).toEqual({ path });
});
it.each([
"",
"#section",
"javascript:alert(1)",
"JaVaScRiPt:alert(1)",
"data:text/html,test",
"ftp://example.com/x.png",
"/tmp/%ZZ.png",
"/tmp/code%00.png",
"file:///tmp/%ZZ.png",
"https://",
"https://example.com/\ncode.png",
])("rejects unsupported or malformed sources: %s", (src) => {
expect(imageTarget(src, "/repo")).toBeNull();
});
it("treats protocol-relative URLs as HTTPS, rather than host files", () => {
expect(imageTarget("//example.com/code.png", "/repo")).toEqual({ url: "https://example.com/code.png" });
});
+75
View File
@@ -0,0 +1,75 @@
import { call, commands } from "@/platform/ipc";
import { ipcPath } from "@/platform/paths";
import { tx } from "@/platform/i18n";
const hasControl = (text: string) => [...text].some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127);
type ImageTarget = { url: string } | { path: string };
/** Images name host files, not paths relative to the webview's own origin. */
export function imageTarget(source: string, cwd: string): ImageTarget | null {
const src = source.trim();
if (!src || hasControl(src)) return null;
if (/^data:image\//i.test(src)) return { url: src };
try {
if (/^https?:\/\//i.test(src) || src.startsWith("//")) {
const url = src.startsWith("//") ? `https:${src}` : src;
new URL(url);
return { url };
}
let path: string;
if (/^file:/i.test(src)) {
const url = new URL(src);
path = decodeURIComponent(url.pathname);
if (/^\/[a-z]:\//i.test(path)) path = path.slice(1);
if (url.hostname && url.hostname !== "localhost") path = `//${url.hostname}${path}`;
} else {
if (/^[a-z][a-z\d+.-]*:/i.test(src) && !/^[a-z]:[\\/]/i.test(src)) return null;
path = ipcPath(decodeURIComponent(src.split(/[?#]/, 1)[0]!));
}
if (!path || hasControl(path)) return null;
if (path.startsWith("/") || /^[a-z]:\//i.test(path)) return { path };
const base = ipcPath(cwd).replace(/\/$/, "");
if (/^[a-z]:\//i.test(base) || base.startsWith("//")) path = path.replace(/\\/g, "/");
return { path: `${base}/${path}` };
} catch {
return null;
}
}
/** Cache belongs to one message: streaming reuses reads, later replies can show a new QR at the same path. */
export function loadImages(root: HTMLElement, cwd: string, cache: Map<string, Promise<string>>): () => void {
let alive = true;
const images = [...root.querySelectorAll<HTMLImageElement>("img")];
for (const img of images) {
const source = img.dataset.imageSrc ?? "";
const fail = () => {
if (!alive) return;
img.onerror = null;
img.removeAttribute("src");
img.alt = tx`Unable to load image: ${source}`;
};
img.setAttribute("referrerpolicy", "no-referrer");
img.onerror = fail;
const target = imageTarget(source, cwd);
if (!target) {
fail();
} else if ("url" in target) {
img.src = target.url;
} else {
let pending = cache.get(target.path);
if (!pending) {
pending = call(commands.fsReadImage(target.path));
cache.set(target.path, pending);
void pending.catch(() => cache.delete(target.path));
}
void pending.then((url) => {
if (alive) img.src = url;
}, fail);
}
}
return () => {
alive = false;
for (const img of images) img.onerror = null;
};
}
+15
View File
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { registerActions } from "@/platform/actions";
import { useProject } from "@/platform/project";
import { EditorShownProvider } from "@/workbench/contributions";
import type { AgentChange, AgentEntry, AgentTask } from "@/bindings";
import { AgentsView } from "./AgentsView";
import { SessionEditor } from "./SessionEditor";
@@ -179,6 +180,20 @@ describe("a pending permission request in the conversation", () => {
});
describe("session state across tasks and hosts", () => {
it("leaves a task unread while its kept-alive tab is hidden, and reads it once shown", () => {
vi.spyOn(document, "hasFocus").mockReturnValue(true);
useAgents.setState({ unread: new Set(["A"]) });
const session = (shown: boolean) => (
<EditorShownProvider value={shown}>
<SessionEditor input={input("A")} />
</EditorShownProvider>
);
const { rerender } = render(session(false));
expect(useAgents.getState().unread.has("A")).toBe(true);
rerender(session(true));
expect(useAgents.getState().unread.has("A")).toBe(false);
});
it("fetches a conversation once and shows it again from the store when its tab reopens", async () => {
const entries = new Map(useAgents.getState().entries);
entries.delete("A");
+75 -7
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { UpdateCheck, UpdateProgress } from "@/bindings";
import type { Settings, UpdateCheck, UpdateProgress } from "@/bindings";
import type { NotificationService } from "@/workbench/contributions";
vi.mock("@tauri-apps/api/core", () => ({
@@ -20,7 +20,7 @@ function deferred<T>() {
let install: ReturnType<typeof deferred<Result<null>>>;
let check: ReturnType<typeof deferred<Result<UpdateCheck>>>;
let channel: Channel | null;
const appInstallUpdate = vi.fn((_request: number, ch: Channel) => {
const appInstallUpdate = vi.fn((_source: string, _request: number, ch: Channel) => {
channel = ch;
return install.promise;
});
@@ -28,18 +28,35 @@ const appCheckUpdate = vi.fn(() => check.promise);
const appRestart = vi.fn(async () => null);
const cancelRequest = vi.fn(async () => null);
const appBuildInfo = vi.fn(async () => ({ version: "0.1.21" }));
const settingsSet = vi.fn(async () => ({ status: "ok", data: { general: {}, appearance: {} } }));
vi.mock("@/platform/ipc", async (orig) => {
const actual = await orig<typeof import("@/platform/ipc")>();
return {
...actual,
isTauri: () => true,
commands: { ...actual.commands, appInstallUpdate, appCheckUpdate, appRestart, cancelRequest, appBuildInfo },
commands: {
...actual.commands,
appInstallUpdate,
appCheckUpdate,
appRestart,
cancelRequest,
appBuildInfo,
settingsSet,
},
};
});
const { checkForUpdates, installUpdate, notifyUpdate, progressText, CHECK_TASK_ID, INSTALL_TASK_ID, UPDATE_KEY } =
await import("../updates");
const {
checkForUpdates,
installUpdate,
notifyUpdate,
progressText,
updateSource,
CHECK_TASK_ID,
INSTALL_TASK_ID,
UPDATE_KEY,
} = await import("../updates");
const { provideNotificationService } = await import("@/workbench/contributions");
const { useProject } = await import("@/platform/project");
const { useTasks } = await import("@/workbench/tasks");
@@ -60,7 +77,7 @@ beforeEach(() => {
check = deferred();
provideNotificationService({ notify: (n) => void notes.push(n), expire: (k) => void expired.push(k) });
useTasks.setState({ tasks: [] });
useProject.setState({ project: null, lightEdit: false });
useProject.setState({ project: null, lightEdit: false, settings: null });
});
afterEach(() => vi.clearAllMocks());
@@ -128,12 +145,33 @@ describe("Install and Restart", () => {
install = deferred();
const cancelled = installUpdate("0.1.22");
task(INSTALL_TASK_ID)!.cancel!();
expect(cancelRequest).toHaveBeenCalledWith(appInstallUpdate.mock.calls.at(-1)![0]);
expect(cancelRequest).toHaveBeenCalledWith(appInstallUpdate.mock.calls.at(-1)![1]);
install.resolve({ status: "error", error: { kind: "cancelled", message: "cancelled" } });
await cancelled;
expect(notes.at(-1)).toMatchObject({ level: "info", title: "Update cancelled", key: UPDATE_KEY });
expect(appRestart).not.toHaveBeenCalled();
});
it.each([
["a failed", "unavailable", "github", "CNB", "cnb"],
["a failed", "unavailable", "cnb", "GitHub", "github"],
["a cancelled (likely slow)", "cancelled", "cnb", "GitHub", "github"],
])("offers %s download from %s again from the other source", async (_case, kind, from, label, to) => {
useProject.setState({ settings: { general: { updateSource: from }, appearance: {} } as unknown as Settings });
const first = installUpdate("0.1.22");
install.resolve({ status: "error", error: { kind, message: kind } });
await first;
const action = notes.at(-1)!.actions!.at(-1)!;
expect(action.label).toBe(`Retry with ${label}`);
install = deferred();
action.run();
await tick();
expect(settingsSet).toHaveBeenCalledWith("global", "general.updateSource", JSON.stringify(to));
expect(appInstallUpdate.mock.calls.at(-1)![0]).toBe(to);
install.resolve(ok(null));
await tick();
});
});
describe("Check for Updates", () => {
@@ -184,6 +222,36 @@ describe("Check for Updates", () => {
});
});
describe("updateSource", () => {
const withSettings = (updateSource: string | undefined, locale: string | undefined) =>
useProject.setState({ settings: { general: { updateSource }, appearance: { locale } } as unknown as Settings });
it.each([
["auto", "zh", "cnb"],
["auto", "zh-CN", "cnb"],
["auto", "en", "github"],
["auto", "ko", "github"],
[undefined, "zh", "cnb"],
["github", "zh", "github"],
["cnb", "en", "cnb"],
["bogus", "en", "github"],
])("%s with the %s UI uses %s", (setting, locale, expected) => {
withSettings(setting, locale);
expect(updateSource()).toBe(expected);
});
it("is passed to the check and the install", async () => {
withSettings("cnb", "en");
void checkForUpdates();
expect(appCheckUpdate).toHaveBeenCalledWith("cnb");
check.resolve(ok({ enabled: true, update: null }));
void installUpdate("0.1.22");
expect(appInstallUpdate.mock.calls.at(-1)![0]).toBe("cnb");
install.resolve(ok(null));
await tick();
});
});
describe("progressText", () => {
it("formats known and unknown lengths", () => {
expect(progressText({ type: "downloading", downloaded: 1024 * 1024, total: null })).toEqual({
+51 -9
View File
@@ -7,7 +7,7 @@
* repeated click joins the running check or install instead of starting another.
*/
import { Channel } from "@tauri-apps/api/core";
import { t } from "@/platform/i18n";
import { resolveLocale, t } from "@/platform/i18n";
import {
call,
commands,
@@ -16,6 +16,7 @@ import {
nextRequestId,
type UpdateInfo,
type UpdateProgress,
type UpdateSource,
} from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { notifications } from "@/workbench/contributions";
@@ -30,6 +31,39 @@ function errorText(e: unknown) {
return e instanceof Error ? e.message : String(e);
}
/** System Settings › Updates › Update source; `auto` picks the CNB mirror for the Chinese UI. */
export function updateSource(): UpdateSource {
const { settings } = useProject.getState();
const chosen = settings?.general?.updateSource;
if (chosen === "github" || chosen === "cnb") return chosen;
return resolveLocale(settings?.appearance?.locale) === "zh" ? "cnb" : "github";
}
const SOURCE_NAMES: Record<UpdateSource, string> = { github: "GitHub", cnb: "CNB" };
/** Makes `source` the update source; the retry it precedes uses it even if saving fails. */
async function chooseSource(source: UpdateSource) {
try {
useProject.setState({
settings: await call(commands.settingsSet("global", "general.updateSource", JSON.stringify(source))),
});
} catch {
/* the choice still applies to this retry */
}
}
/**
* Offered once a download failed or was cancelled (likely too slow): the same install from
* the other source. A check never needs it, since it already falls back to the other source.
*/
function otherSourceAction(version: string, source: UpdateSource) {
const other: UpdateSource = source === "cnb" ? "github" : "cnb";
return {
label: t("app.update.retryWith", { source: SOURCE_NAMES[other] }),
run: () => void chooseSource(other).then(() => installUpdate(version, other)),
};
}
let checking: Promise<void> | null = null;
let installing: Promise<void> | null = null;
@@ -56,7 +90,7 @@ async function runCheck() {
key: UPDATE_KEY,
});
try {
const r = await Promise.race([call(commands.appCheckUpdate()), abandoned]);
const r = await Promise.race([call(commands.appCheckUpdate(updateSource())), abandoned]);
if (r === "cancelled") return;
if (!r.enabled) {
notifications().notify({
@@ -85,7 +119,7 @@ async function runCheck() {
/** Silent check once per app start; only an available update is shown. */
export async function checkForUpdatesOnStartup(): Promise<void> {
if (!isTauri()) return;
const update = await commands.appStartupUpdateCheck().catch(() => null);
const update = await commands.appStartupUpdateCheck(updateSource()).catch(() => null);
if (update) notifyUpdate(update);
}
@@ -118,13 +152,13 @@ export function progressText(p: UpdateProgress): { message: string; fraction: nu
}
/** Downloads and installs `version`, then restarts. A second call while one runs joins it. */
export function installUpdate(version: string): Promise<void> {
export function installUpdate(version: string, source = updateSource()): Promise<void> {
if (!isTauri()) return Promise.resolve();
installing ??= runInstall(version).finally(() => (installing = null));
installing ??= runInstall(version, source).finally(() => (installing = null));
return installing;
}
async function runInstall(version: string) {
async function runInstall(version: string, source: UpdateSource) {
const title = t("app.update.downloading", { version });
const request = nextRequestId();
const cancel = () => void commands.cancelRequest(request).catch(() => {});
@@ -160,17 +194,25 @@ async function runInstall(version: string) {
// Replaces the notification whose action is now spent.
balloon([{ label: t("app.update.cancel"), run: cancel }]);
try {
await call(commands.appInstallUpdate(request, channel));
await call(commands.appInstallUpdate(source, request, channel));
} catch (e) {
if (isCancelled(e)) {
notifications().notify({ level: "info", title: t("app.update.cancelled"), key: UPDATE_KEY });
notifications().notify({
level: "info",
title: t("app.update.cancelled"),
key: UPDATE_KEY,
actions: [otherSourceAction(version, source)],
});
} else {
notifications().notify({
level: "error",
title: t("app.update.installFailed"),
body: errorText(e),
key: UPDATE_KEY,
actions: [{ label: t("app.update.retry"), run: () => void installUpdate(version) }],
actions: [
{ label: t("app.update.retry"), run: () => void installUpdate(version, source) },
otherSourceAction(version, source),
],
});
}
return;
@@ -53,7 +53,7 @@ import { executeAction, getAction } from "@/platform/actions";
import { intellijMacos } from "@/platform/keymaps/intellij-macos";
import type { DebugCapabilities } from "@/platform/ipc";
import { registerDebugActions } from "../actions";
import { breakpointsOf, loadBreakpoints } from "../breakpoints/store";
import { breakpointsOf, loadBreakpoints, toggleBreakpoint } from "../breakpoints/store";
import { loadSpecialBreakpoints, removeAllSpecial, useSpecialBreakpoints } from "../breakpoints/special";
import { publishContext, updateExecutionPoint, useExecutionPoint, useDebug, type DebugSession } from "../state";
import { focusDebugView, blurDebugView } from "../ui/focusedView";
@@ -253,6 +253,21 @@ describe("debugger actions (IDEA parity)", () => {
expect(opened.at(-1)).toEqual({ path: "/p/app.py", focus: true });
});
it("puts no new breakpoint on a blank line", async () => {
for (const action of ["debug.toggleBreakpoint", "debug.toggleTemporaryBreakpoint"]) {
for (const line of [1, 2]) {
caret("/p/a.py", "python", "x = 1\n\n \ny = 2", line);
await executeAction(action);
}
expect(breakpointsOf("/p/a.py"), action).toHaveLength(0);
}
// One placed before the line went blank can still be toggled off.
toggleBreakpoint("/p/a.py", 1);
caret("/p/a.py", "python", "x = 1\n\n \ny = 2", 1);
await executeAction("debug.toggleBreakpoint");
expect(breakpointsOf("/p/a.py")).toHaveLength(0);
});
it("toggles temporary, method and field breakpoints at the caret", async () => {
caret("/p/a.py", "python", "x = 1\ndef run():\n pass", 0);
await executeAction("debug.toggleTemporaryBreakpoint");
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { EditorState } from "@codemirror/state";
import { java } from "@codemirror/lang-java";
import { lineHasCode } from "../breakpoints/codeLines";
const lines = (state: EditorState) => Array.from({ length: state.doc.lines }, (_, i) => lineHasCode(state, i));
describe("lines that take a breakpoint", () => {
it("skips blank and comment-only lines", () => {
const doc = [
"class A {",
"",
" \t",
" // note",
" /** doc",
" * more",
" */",
" int f() { return 1; } // trailing",
" /* a */ int x = 1;",
" /* a */ /* b */",
"}",
].join("\n");
const state = EditorState.create({ doc, extensions: [java()] });
expect(lines(state)).toEqual([true, false, false, false, false, false, false, true, true, false, true]);
});
it("only skips blank lines in a file without a parser", () => {
expect(lines(EditorState.create({ doc: "x\n\n \n// y" }))).toEqual([true, false, false, true]);
});
});
+51 -2
View File
@@ -3,8 +3,14 @@ import { EditorState } from "@codemirror/state";
import { EditorView, GutterMarker, gutter, lineNumbers } from "@codemirror/view";
import { breakpointGutter, lookOf } from "../editor/breakpointGutter";
import { executionPoint } from "../editor/execution";
import { breakpointsOf, loadBreakpoints, toggleBreakpoint, updateBreakpoint } from "../breakpoints/store";
import { adoptSession, useExecutionPoint, useDebug, handleDebugEvent } from "../state";
import {
breakpointsOf,
loadBreakpoints,
toggleBreakpoint,
updateBreakpoint,
useBreakpoints,
} from "../breakpoints/store";
import { adoptSession, useExecutionPoint, useDebug, handleDebugEvent, type DebugSession } from "../state";
import { getContext } from "@/platform/context";
import styles from "../editor/editor.module.css";
@@ -53,6 +59,38 @@ describe("breakpoint gutter", () => {
});
});
describe("breakpoint gutter redraws", () => {
it("follows a session starting and ending, not every update of a running one", async () => {
localStorage.clear();
loadBreakpoints("/proj");
useDebug.setState({ sessions: {} });
const view = makeView("a\nb");
toggleBreakpoint(ctx.path, 0);
const id = breakpointsOf(ctx.path)[0]!.id;
useBreakpoints.setState({ status: { [id]: { verified: true, message: null, line: 0 } } });
await flush();
expect(view.dom.querySelector(`.${styles.verified}`)).toBeNull();
const session = { id: 9, status: "running" } as DebugSession;
useDebug.setState({ sessions: { 9: session } });
await flush();
expect(view.dom.querySelector(`.${styles.verified}`)).not.toBeNull();
const reads = vi.spyOn(useBreakpoints, "getState");
useDebug.setState({ sessions: { 9: { ...session, status: "paused" } } });
useBreakpoints.setState({
status: { [id]: useBreakpoints.getState().status[id]!, other: { verified: true, message: null, line: 3 } },
});
reads.mockClear();
await flush();
expect(reads).not.toHaveBeenCalled();
useDebug.setState({ sessions: { 9: { ...session, status: "terminated" } } });
await flush();
expect(view.dom.querySelector(`.${styles.verified}`)).toBeNull();
});
});
describe("output class gutter", () => {
it("disables ordinary, temporary and logging breakpoints and explains why", () => {
loadBreakpoints("/proj");
@@ -153,6 +191,17 @@ describe("breakpoint gutter clicks", () => {
expect(breakpointsOf(ctx.path)[0]).toMatchObject({ line: 0, temporary: true });
});
it("adds nothing on a blank line but still removes a breakpoint there", () => {
localStorage.clear();
loadBreakpoints("/proj");
const view = makeView("\na");
for (const init of [{}, { altKey: true }, { shiftKey: true }]) click(view, init);
expect(breakpointsOf(ctx.path)).toHaveLength(0);
toggleBreakpoint(ctx.path, 0);
click(view, {});
expect(breakpointsOf(ctx.path)).toHaveLength(0);
});
it("middle-click enables / disables an existing breakpoint", () => {
localStorage.clear();
loadBreakpoints("/proj");
+9 -2
View File
@@ -7,6 +7,7 @@ import { activeEditor } from "@/editor/api";
import type { EditorView } from "@codemirror/view";
import { maybeEditorService, notifications, showStatusInfo, toolWindowService } from "@/workbench/contributions";
import { classFileBreakpointReason } from "@/platform/classFiles";
import { lineHasCode } from "./breakpoints/codeLines";
import { canPlaceBreakpoint } from "./breakpoints/languages";
import {
removeAllBreakpoints,
@@ -628,6 +629,9 @@ export function registerDebugActions(): () => void {
return;
}
const ed = activeEditor();
// Like IDEA, no new breakpoint on a blank or comment-only line; an existing one still goes.
const taken = exists || specialAt(c.path).some((b) => b.line === c.line);
if (!taken && ed && !lineHasCode(ed.view.state, c.line)) return;
if (ed) toggleBreakpointSmart(c.path, c.line, language, ed.view.state.doc.toString());
else toggleBreakpoint(c.path, c.line);
},
@@ -640,8 +644,11 @@ export function registerDebugActions(): () => void {
description: "A line breakpoint that is removed after it is hit",
run: () => {
const c = cursor();
if (c && breakpointPathAllowed(c.path) && canPlaceBreakpoint(activeEditor()?.ctx.language ?? "", c.path))
toggleTemporaryBreakpoint(c.path, c.line);
const ed = activeEditor();
if (!c || !breakpointPathAllowed(c.path) || !canPlaceBreakpoint(ed?.ctx.language ?? "", c.path)) return;
const exists = useBreakpoints.getState().byFile[c.path]?.some((b) => b.line === c.line);
if (!exists && ed && !lineHasCode(ed.view.state, c.line)) return;
toggleTemporaryBreakpoint(c.path, c.line);
},
},
{
@@ -0,0 +1,33 @@
/**
* IDEA puts a new line breakpoint only where there is code: a gutter click or Toggle Line
* Breakpoint on a blank or comment-only line does nothing. Comments come from the syntax
* tree; in a file without a parser only blank lines are skipped.
*/
import { ensureSyntaxTree, syntaxTree } from "@codemirror/language";
import type { EditorState } from "@codemirror/state";
import type { SyntaxNode, Tree } from "@lezer/common";
const PARSE_BUDGET_MS = 20;
function commentAt(tree: Tree, pos: number): SyntaxNode | null {
for (let n: SyntaxNode | null = tree.resolveInner(pos, 1); n; n = n.parent) if (/comment/i.test(n.name)) return n;
return null;
}
/** Whether `line` (0-based) has anything besides whitespace and comments. */
export function lineHasCode(state: EditorState, line: number): boolean {
if (line < 0 || line >= state.doc.lines) return false;
const { from, to, text } = state.doc.line(line + 1);
if (!/\S/.test(text)) return false;
const tree = ensureSyntaxTree(state, to, PARSE_BUDGET_MS) ?? syntaxTree(state);
for (let pos = from; pos < to;) {
if (/\s/.test(text[pos - from]!)) {
pos++;
continue;
}
const comment = commentAt(tree, pos);
if (!comment) return true;
pos = Math.max(pos + 1, comment.to);
}
return false;
}
@@ -18,6 +18,7 @@ import {
import { classFileBreakpointReason } from "@/platform/classFiles";
import { tx } from "@/platform/i18n";
import { showStatusInfo } from "@/workbench/contributions";
import { lineHasCode } from "../breakpoints/codeLines";
import { canPlaceBreakpoint } from "../breakpoints/languages";
import {
removeSpecial,
@@ -185,14 +186,19 @@ export function breakpointGutter(ctx: EditorDocContext): Extension {
private unsubs: Array<() => void>;
private pending = false;
private destroyed = false;
private running = sessionsRunning();
constructor(readonly view: EditorView) {
this.unsubs = [
useBreakpoints.subscribe((s, prev) => {
if (s.byFile[path] !== prev.byFile[path] || s.status !== prev.status || s.muted !== prev.muted)
this.scheduleRebuild();
// States arrive per file: another file's don't change this one's markers.
const states = (s.byFile[path] ?? []).some((b) => s.status[b.id] !== prev.status[b.id]);
if (s.byFile[path] !== prev.byFile[path] || states || s.muted !== prev.muted) this.scheduleRebuild();
}),
// The markers only show whether a session runs, while each step updates `sessions` several times.
useDebug.subscribe((s, prev) => {
if (s.sessions !== prev.sessions) this.scheduleRebuild();
if (s.sessions === prev.sessions || sessionsRunning() === this.running) return;
this.running = !this.running;
this.scheduleRebuild();
}),
useSpecialBreakpoints.subscribe((s, prev) => {
if (s.methods !== prev.methods || s.fields !== prev.fields || s.status !== prev.status)
@@ -232,9 +238,10 @@ export function breakpointGutter(ctx: EditorDocContext): Extension {
}
/**
* IDEA's gutter clicks: click toggles; on an empty line ⇧-click adds a non-suspending
* logging breakpoint and opens its properties, ⌥-click a temporary one. On an existing
* breakpoint only a plain (or ⌘/Ctrl) click removes it; middle-click enables/disables.
* IDEA's gutter clicks: click toggles; on a line without a breakpoint ⇧-click adds a
* non-suspending logging breakpoint and opens its properties, ⌥-click a temporary one.
* Blank and comment-only lines take none. On an existing breakpoint only a plain
* (or ⌘/Ctrl) click removes it; middle-click enables/disables.
*/
onMouseDown = (e: MouseEvent) => {
if ((e.button !== 0 && e.button !== 1) || !inBreakpointArea(e)) return;
@@ -271,6 +278,7 @@ export function breakpointGutter(ctx: EditorDocContext): Extension {
else removeBreakpoint(path, bp!.id);
return;
}
if (!lineHasCode(this.view.state, line)) return;
if (e.altKey) {
addBreakpoint(path, line, { temporary: true });
return;
@@ -50,8 +50,11 @@ export interface DateFormatValid {
pretty: boolean;
}
export type UpdateSourceSetting = "auto" | "github" | "cnb";
export interface GeneralExtras {
openProjectIn: OpenProjectIn;
updateSource: UpdateSourceSetting;
defaultProjectDirectory: string;
saveOnIdle: boolean;
idleSeconds: number;
@@ -100,6 +103,7 @@ export const DEFAULT_APPEARANCE_EXTRAS: AppearanceExtras = {
export const DEFAULT_GENERAL_EXTRAS: GeneralExtras = {
openProjectIn: "ask",
updateSource: "auto",
defaultProjectDirectory: "",
saveOnIdle: false,
idleSeconds: 15,
@@ -160,6 +164,7 @@ export function validateGeneralExtras(raw: unknown): GeneralExtras {
const df = obj(g.dateFormat);
return {
openProjectIn: oneOf(g.openProjectIn, ["ask", "new", "same", "attach"] as const, d.openProjectIn),
updateSource: oneOf(g.updateSource, ["auto", "github", "cnb"] as const, d.updateSource),
defaultProjectDirectory: typeof g.defaultProjectDirectory === "string" ? g.defaultProjectDirectory : "",
saveOnIdle: bool(g.saveOnIdle, d.saveOnIdle),
idleSeconds: int(g.idleSeconds, d.idleSeconds, IDLE_LIMITS),
+19
View File
@@ -26,6 +26,7 @@ import {
type NotificationDisplay,
type NotificationGroupValid,
type OpenProjectIn,
type UpdateSourceSetting,
} from "../model/behaviorSchema";
import { ACCENT_PRESETS, LIMITS, type ThemeId } from "../model/schema";
import type { PageSpec } from "./spec";
@@ -381,6 +382,12 @@ const OPEN_IN: Array<{ value: OpenProjectIn; label: string }> = [
{ value: "ask", label: "Ask" },
];
const UPDATE_SOURCES: SelectOption<UpdateSourceSetting>[] = [
{ value: "auto", label: "Automatic" },
{ value: "github", label: "GitHub" },
{ value: "cnb", label: "CNB (China mirror)" },
];
function OpenProjectInRow() {
const [value, set] = useSetting<OpenProjectIn>("general.openProjectIn");
const name = useId();
@@ -724,6 +731,18 @@ export const BEHAVIOR_PAGES: PageSpec[] = [
},
],
},
{
title: "Updates",
options: [
{
kind: "select",
key: "general.updateSource",
label: "Update source",
hint: "Where updates are downloaded from. If it can't be reached, the other source is tried.",
options: UPDATE_SOURCES,
},
],
},
{
title: "Exit",
options: [{ kind: "toggle", key: "general.confirmExit", label: "Confirm before exiting the IDE" }],
+3 -2
View File
@@ -7,6 +7,7 @@
import { FontPicker } from "../components/FontPicker";
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import clsx from "clsx";
import { quotePath } from "@/platform/commandLine";
import { commands, isTauri } from "@/platform/ipc";
import {
DEFAULT_EXTERNAL_TOOL,
@@ -110,7 +111,7 @@ function ShellPathRow() {
const { open: pick } = await import("@tauri-apps/plugin-dialog");
const picked = await pick({ multiple: false, title: "Shell Path" });
const path = Array.isArray(picked) ? picked[0] : picked;
if (path) set(path);
if (path) set(quotePath(path));
};
return (
<Row
@@ -142,7 +143,7 @@ function ShellPathRow() {
anchor={menuRef}
placement="bottom-end"
aria-label="Installed shells"
items={shells.map((p) => ({ id: p, label: p, checked: p === value, run: () => set(p) }))}
items={shells.map((p) => ({ id: p, label: p, checked: quotePath(p) === value, run: () => set(quotePath(p)) }))}
/>
<IconButton
icon="folder-open"
+3 -3
View File
@@ -16,9 +16,9 @@ import { installTerminalPaste, pasteTerminalClipboard } from "./paste";
import { ImeCommitGate } from "./imeCommit";
import { ownTerminalInput } from "./imeInput";
import { findTerminalLinks, resolvePath, type TerminalLink } from "./links";
import { splitCommandLine } from "@/platform/commandLine";
import { parseShellCommand } from "@/platform/commandLine";
import { useProject } from "@/platform/project";
import { useRemote } from "@/platform/remote";
import { hostOs, useRemote } from "@/platform/remote";
import { usePlatform } from "@/platform/platformInfo";
import { workflowSettings, type TerminalSettingsValid } from "@/platform/workflowSettings";
import { editorPrefs } from "@/platform/editorSettings";
@@ -530,7 +530,7 @@ export class TerminalSession {
};
try {
const t = workflowSettings().terminal;
const [shellPath, ...shellArgs] = splitCommandLine(t.shellPath);
const [shellPath, ...shellArgs] = parseShellCommand(t.shellPath, hostOs() === "windows");
const shell = custom ? custom.program : shellPath;
const args = custom ? custom.args : shellArgs;
const info = await call(
+5 -2
View File
@@ -7,14 +7,14 @@ import { isTauri } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { useRemoteProgress } from "@/platform/remote";
import { stageLabel, useConnectDialog } from "@/features/remote-dev/model";
import { Icon, IconButton, Menu, ProgressBar, Spinner, type MenuItem, T } from "@/ui";
import { Icon, IconButton, Menu, ProgressBar, Spinner, type MenuItem, Button, T } from "@/ui";
import { useSettingsPages } from "@/workbench/contributions";
import { ErrorBoundary, Slot } from "@/workbench/ErrorBoundary";
import logo from "../../../src-tauri/icons/128x128.png";
import { CustomizePage } from "./CustomizePage";
import { ProjectsPage } from "./ProjectsPage";
import { loadRecent, useRecent } from "./recent";
import { useSession } from "./session";
import { cancelReopen, useSession } from "./session";
import styles from "./Welcome.module.css";
export type WelcomePage = "projects" | "customize" | "plugins";
@@ -135,6 +135,9 @@ function Reopening({ location }: { location: string }) {
<ProgressBar value={progress.fraction} aria-label={tx(stageLabel(progress.stage))} />
</div>
)}
<Button size="small" onClick={cancelReopen}>
<T>Cancel</T>
</Button>
</div>
);
}
+47 -2
View File
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const openProject = vi.fn();
const closeProject = vi.fn();
const connectRemote = vi.fn();
const UNC = String.raw`\\wsl.localhost\Ubuntu\home\me\app`;
@@ -24,14 +25,15 @@ vi.mock("@/platform/remote", async (orig) => {
vi.mock("@tauri-apps/api/window", () => ({ getCurrentWindow: () => ({ label: "main" }) }));
vi.mock("@/platform/project", async (orig) => {
const real = await orig<typeof import("@/platform/project")>();
return { ...real, openProject: (p: string) => openProject(p) };
return { ...real, openProject: (p: string) => openProject(p), closeProject: () => closeProject() };
});
const { reopenLastProject, _resetSessionForTests } = await import("./session");
const { cancelReopen, reopenLastProject, useSession, _resetSessionForTests } = await import("./session");
const { provideStartupLaunch, useProject } = await import("@/platform/project");
beforeEach(() => {
openProject.mockReset();
closeProject.mockReset().mockResolvedValue(undefined);
connectRemote.mockReset();
useProject.setState({ project: null, lightEdit: false });
sessionStorage.clear();
@@ -87,4 +89,47 @@ describe("reopenLastProject", () => {
expect(connectRemote).toHaveBeenCalledWith({ kind: "wsl", distro: "Ubuntu" }, "/home/me/app");
expect(openProject).not.toHaveBeenCalled();
});
it("clears the remembered project while reopening and lets the user cancel", async () => {
let resolveOpen!: () => void;
openProject.mockImplementation(
() =>
new Promise((r) => {
resolveOpen = () => {
useProject.setState({ project: { root: "/p/last" } as never });
r({ root: "/p/last" });
};
}),
);
provideStartupLaunch(Promise.resolve());
const promise = reopenLastProject();
await new Promise((r) => setTimeout(r, 0));
expect(localStorage.getItem("openidea.session.openProject")).toBeNull();
expect(useSession.getState().reopening).toBe("/p/last");
cancelReopen();
expect(useSession.getState().reopening).toBeNull();
resolveOpen();
await promise;
expect(closeProject).toHaveBeenCalledOnce();
expect(localStorage.getItem("openidea.session.openProject")).toBeNull();
});
it("keeps a project the user opened after cancelling", async () => {
let resolveOpen!: () => void;
openProject.mockImplementation(
() =>
new Promise((r) => {
resolveOpen = () => r({ root: "/p/last" });
}),
);
provideStartupLaunch(Promise.resolve());
const promise = reopenLastProject();
await new Promise((r) => setTimeout(r, 0));
cancelReopen();
useProject.setState({ project: { root: "/p/other" } as never });
resolveOpen();
await promise;
expect(closeProject).not.toHaveBeenCalled();
});
});
+35 -5
View File
@@ -6,8 +6,8 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
import { create } from "zustand";
import { commands, isCancelled, isTauri, type Settings } from "@/platform/ipc";
import { openProject, startupLaunchHandled, useProject } from "@/platform/project";
import { connectRemote, projectLocation, resolveRemoteLocation } from "@/platform/remote";
import { closeProject, openProject, startupLaunchHandled, useProject } from "@/platform/project";
import { connectRemote, projectLocation, resolveRemoteLocation, useRemoteProgress } from "@/platform/remote";
import { notifications } from "@/workbench/contributions";
const SESSION_KEY = "openidea.session.openProject";
@@ -82,6 +82,21 @@ export function lastSessionProject(): string | null {
}
let attempted = false;
let reopenEpoch = 0;
/** Cancels an in-flight startup project reopen or remote connection and returns to the Welcome screen. */
export function cancelReopen() {
reopenEpoch++;
write(SESSION_KEY, null);
windowProject(null);
useSession.setState({ reopening: null });
// A local open can't be interrupted; `reopen` closes it again once it finishes.
if (useRemoteProgress.getState().location) {
useRemoteProgress.setState({ progress: null, location: null });
useProject.setState({ opening: false, openingPath: null });
if (isTauri()) void commands.remoteDisconnect().catch(() => {});
}
}
/** On startup: reopens the project that was open at exit. Robust against a missing folder. */
export async function reopenLastProject(): Promise<void> {
@@ -114,16 +129,30 @@ export async function reopenLastProject(): Promise<void> {
}
async function reopen(path: string) {
const epoch = ++reopenEpoch;
const name = path.slice(path.lastIndexOf("/") + 1) || path;
// Clear before connecting so force-quitting a hung reopen does not loop on the next launch.
write(SESSION_KEY, null);
windowProject(null);
useSession.setState({ reopening: path });
try {
const remote = await resolveRemoteLocation(path);
if (remote) await connectRemote(remote.target, remote.path);
else await openProject(path);
else {
const project = await openProject(path);
// Cancelled while opening: close it again unless the user has opened another project since.
if (reopenEpoch !== epoch) {
if (useProject.getState().project?.root === project.root) await closeProject().catch(() => {});
return;
}
}
const opened = projectLocation(useProject.getState().project?.root) ?? path;
write(SESSION_KEY, opened);
windowProject(opened);
} catch (e) {
write(SESSION_KEY, null);
windowProject(null);
if (isCancelled(e)) return;
if (reopenEpoch !== epoch || isCancelled(e)) return;
notifications().notify({
level: "warning",
title: `Cannot reopen ${name}`,
@@ -131,12 +160,13 @@ async function reopen(path: string) {
key: "welcome.reopen",
});
} finally {
useSession.setState({ reopening: null });
if (reopenEpoch === epoch) useSession.setState({ reopening: null });
}
}
/** Tests only. */
export function _resetSessionForTests() {
attempted = false;
reopenEpoch = 0;
useSession.setState({ reopening: null, reopenPref: read(PREF_KEY) !== "false" });
}
+1
View File
@@ -11,6 +11,7 @@ export const CLIENT_SETTINGS_KEYS = [
"general.reopenLastProject",
"general.confirmExit",
"general.openProjectIn",
"general.updateSource",
"general.dateFormat",
"editor.fontFamily",
"editor.fontSize",
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { parseShellCommand, quotePath } from "./commandLine";
describe("parseShellCommand", () => {
it("keeps Windows paths as typed", () => {
expect(parseShellCommand(String.raw`C:\Windows\system32\cmd.exe`, true)).toEqual([
String.raw`C:\Windows\system32\cmd.exe`,
]);
expect(parseShellCommand(String.raw`C:\Windows\system32\cmd.exe /k C:\tools\init.bat`, true)).toEqual([
String.raw`C:\Windows\system32\cmd.exe`,
"/k",
String.raw`C:\tools\init.bat`,
]);
});
it("takes an unquoted Windows path with spaces up to its executable", () => {
expect(parseShellCommand(String.raw`C:\Program Files\PowerShell\7\pwsh.exe`, true)).toEqual([
String.raw`C:\Program Files\PowerShell\7\pwsh.exe`,
]);
expect(parseShellCommand(String.raw`C:\Program Files\Git\bin\bash.EXE --login -i`, true)).toEqual([
String.raw`C:\Program Files\Git\bin\bash.EXE`,
"--login",
"-i",
]);
});
it("honours quotes", () => {
expect(parseShellCommand(String.raw`"C:\Program Files\Git\bin\bash.exe" --login`, true)).toEqual([
String.raw`C:\Program Files\Git\bin\bash.exe`,
"--login",
]);
expect(parseShellCommand(`/bin/zsh --login`, false)).toEqual(["/bin/zsh", "--login"]);
expect(parseShellCommand(`"/Applications/My Shell/bin/fish" -l`, false)).toEqual([
"/Applications/My Shell/bin/fish",
"-l",
]);
expect(parseShellCommand(String.raw`/bin/sh -c a\ b`, false)).toEqual(["/bin/sh", "-c", "a b"]);
});
it("is empty for an empty setting", () => {
expect(parseShellCommand(" ", true)).toEqual([]);
expect(parseShellCommand("", false)).toEqual([]);
});
});
describe("quotePath", () => {
it("quotes only paths with whitespace", () => {
expect(quotePath(String.raw`C:\Windows\system32\cmd.exe`)).toBe(String.raw`C:\Windows\system32\cmd.exe`);
expect(quotePath(String.raw`C:\Program Files\PowerShell\7\pwsh.exe`)).toBe(
String.raw`"C:\Program Files\PowerShell\7\pwsh.exe"`,
);
expect(parseShellCommand(quotePath("/Applications/My Shell/fish"), false)).toEqual(["/Applications/My Shell/fish"]);
});
});
+22
View File
@@ -67,6 +67,28 @@ export function parseArgs(input: string, windowsPaths = false): string[] {
return out;
}
/**
* The program and arguments of a shell command line (Settings › Terminal › Shell path). On
* Windows a backslash is a path separator, and an unquoted path up to its `.exe` / `.cmd` /
* `.bat` / `.com` is the program even with spaces in it (`C:\Program Files\…\pwsh.exe -NoLogo`).
*/
export function parseShellCommand(line: string, windows: boolean): string[] {
const text = line.trim();
if (windows && !/["']/.test(text)) {
const end = /\.(?:exe|cmd|bat|com)(?=\s|$)/i.exec(text);
if (end) {
const stop = end.index + end[0].length;
return [text.slice(0, stop), ...parseArgs(text.slice(stop), true)];
}
}
return parseArgs(text, windows);
}
/** `path` as one word of a command line: quoted when it has whitespace. */
export function quotePath(path: string): string {
return /\s/.test(path) ? `"${path}"` : path;
}
/** Inverse of `parseArgs` (quotes only when needed). */
export function formatArgs(args: readonly string[]): string {
return args.map((a) => (a === "" ? "''" : /[\s'"\\$`]/.test(a) ? `'${a.replace(/'/g, `'\\''`)}'` : a)).join(" ");
+1
View File
@@ -81,6 +81,7 @@
"app.update.progressUnknown": "{done} MB",
"app.update.restart": "Restart",
"app.update.retry": "Retry",
"app.update.retryWith": "Retry with {source}",
"app.window.desktopOnly": "Multiple windows require the desktop app",
"app.window.openFailed": "Cannot open {name}",
"app.window.pickFileTitle": "Open File",
+8 -1
View File
@@ -36,5 +36,12 @@
"For web requests: the XDEBUG_SESSION cookie or query parameter; point xdebug.client_host at this machine when PHP runs in a container": "For web requests: the XDEBUG_SESSION cookie or query parameter; point xdebug.client_host at this machine when PHP runs in a container",
"e.g. /var/www/html": "e.g. /var/www/html",
"Listening for Xdebug on port {port}": "Listening for Xdebug on port {port}",
"Port {port} is already in use: another IDE or debug session may be listening on it. Stop it or choose another port.": "Port {port} is already in use: another IDE or debug session may be listening on it. Stop it or choose another port."
"Port {port} is already in use: another IDE or debug session may be listening on it. Stop it or choose another port.": "Port {port} is already in use: another IDE or debug session may be listening on it. Stop it or choose another port.",
"Unable to load image: {path}": "Unable to load image: {path}",
"Updates": "Updates",
"Update source": "Update source",
"Where updates are downloaded from. If it can't be reached, the other source is tried.": "Where updates are downloaded from. If it can't be reached, the other source is tried.",
"Automatic": "Automatic",
"CNB (China mirror)": "CNB (China mirror)",
"Remote connection cancelled": "Remote connection cancelled"
}
+7 -1
View File
@@ -6376,5 +6376,11 @@
"Force-with-lease push {source} to {target} was rejected. Force-with-lease push prevents overriding remote changes that haven't been fetched to the local repository. Fetch recent changes to verify that they can be discarded safely and repeat the push operation.": "将 {source} 以 force-with-lease 方式推送到 {target} 被拒绝。force-with-lease 推送可防止覆盖尚未获取到本地仓库的远程更改。请先获取最新更改,确认可以安全丢弃后再重新推送。",
"Force push anyway": "仍然强制推送",
"Stage and Retry": "暂存并重试",
"Show Files": "显示文件"
"Show Files": "显示文件",
"Unable to load image: {path}": "无法加载图片:{path}",
"Updates": "更新",
"Update source": "更新源",
"Where updates are downloaded from. If it can't be reached, the other source is tried.": "从哪里下载更新。无法连接时会改用另一个源。",
"CNB (China mirror)": "CNB(国内镜像)",
"Remote connection cancelled": "远程连接已取消"
}
+1
View File
@@ -94,6 +94,7 @@ const UNTRANSLATED = new Set<string>([
"oxfmt",
"Ruff",
"IntelliJ IDEA",
"GitHub",
"PHPStan",
// Docker run target placeholder values
"compose.yaml",
+1
View File
@@ -81,6 +81,7 @@
"app.update.progressUnknown": "{done} MB",
"app.update.restart": "重启",
"app.update.retry": "重试",
"app.update.retryWith": "改用 {source} 重试",
"app.window.desktopOnly": "多窗口需要桌面应用",
"app.window.openFailed": "无法打开 {name}",
"app.window.pickFileTitle": "打开文件",
+16 -4
View File
@@ -11,6 +11,7 @@ import { create } from "zustand";
import {
call,
commands,
IpcError,
isTauri,
type ChannelFrame,
type RemoteProgress,
@@ -156,14 +157,23 @@ export const useRemoteProgress = create<{ progress: RemoteProgress | null; locat
/** Opens `path` of `target` (an SSH host or a WSL distribution) in this window. */
export async function connectRemote(target: RemoteTarget, path: string) {
useProject.setState({ opening: true });
const location = remoteLocation(target, path);
const onProgress = new Channel<RemoteProgress>();
onProgress.onmessage = (progress) => useRemoteProgress.setState({ progress });
onProgress.onmessage = (progress) => {
if (useRemoteProgress.getState().location === location) {
useRemoteProgress.setState({ progress });
}
};
useRemoteProgress.setState({
progress: { stage: "connecting", fraction: null },
location: remoteLocation(target, path),
location,
});
try {
const r = await call(commands.remoteConnect(target, path, onProgress));
if (useRemoteProgress.getState().location !== location) {
await commands.remoteDisconnect().catch(() => {});
throw new IpcError({ kind: "cancelled", message: "Remote connection cancelled" });
}
enableRemote({ target: r.target, root: r.root, host: r.host, os: r.os, commands: r.commands });
if (takeLegacyRemoteTrust(r.target, path, r.host)) {
const status = await call(commands.projectTrustStatus(r.root));
@@ -189,8 +199,10 @@ export async function connectRemote(target: RemoteTarget, path: string) {
});
return r;
} finally {
useProject.setState({ opening: false });
useRemoteProgress.setState({ progress: null, location: null });
if (useRemoteProgress.getState().location === location) {
useProject.setState({ opening: false });
useRemoteProgress.setState({ progress: null, location: null });
}
}
}
+12
View File
@@ -122,6 +122,12 @@ export interface EditorKindContribution {
restorable?: boolean;
/** Can remain visible in a window without a project (for example, an external Git log). */
standalone?: boolean;
/**
* Its recently used tabs stay mounted, hidden, while another tab is active: switching back
* shows them as they were instead of building them again (long agent conversations). A hidden
* tab keeps running; `useEditorShown` tells it whether it is the one shown.
*/
keepAlive?: boolean;
/** Last tab showing this input closed (including split tabs and tab-limit eviction). */
onClose?(input: EditorInput): void;
/**
@@ -318,6 +324,12 @@ export const registerWorkbenchBanner = (c: WorkbenchBannerContribution) => regis
export const registerToolWindow = (c: ToolWindowContribution) => register("toolWindows", c.id, c);
export const registerStatusItem = (c: StatusItemContribution) => register("statusItems", c.id, c);
export const registerOverlay = (c: OverlayContribution) => register("overlays", c.id, c);
const EditorShown = createContext(true);
export const EditorShownProvider = EditorShown.Provider;
/** Whether this editor tab is the one its pane shows (a `keepAlive` tab stays mounted when it isn't). */
export const useEditorShown = () => useContext(EditorShown);
export const registerEditorKind = (c: EditorKindContribution) => register("editorKinds", c.kind, c);
export const registerTitleItem = (c: TitleItemContribution) => register("titleItems", c.id, c);
export const registerServiceView = (c: ServiceViewContribution) => register("serviceViews", c.id, c);