Compare commits

...
Author SHA1 Message Date
l0ng-ai 0b4d53f2d8 fix(tools): install GitHub releases when the API is rate-limited
Language servers and debuggers published as GitHub releases were looked
up through api.github.com, which allows 60 unauthenticated requests an
hour per IP address. Behind a shared address the limit is often used up
and installs failed with 403. When the API request fails, the release is
now resolved from its web pages instead: the latest-release redirect gives
the tag, the asset list gives the files and their SHA-256 digests, and the
release notes give linked builds. If that fails too, the error names the
release page to download from and the setting to point at the file.

Fixes #1651
2026-11-06 13:23:13 +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
42 changed files with 1465 additions and 222 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",
+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
+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);
}
});
+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"] }
-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": {
@@ -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");
@@ -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;
+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" });
}
+2 -1
View File
@@ -36,5 +36,6 @@
"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}"
}
+2 -1
View File
@@ -6376,5 +6376,6 @@
"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}"
}
+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);