mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-09 10:49:39 -10:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
961344c0f9 | ||
|
|
f76c297fe9 | ||
|
|
c75a630ede | ||
|
|
266e4eeb03 | ||
|
|
c1aa092d75 | ||
|
|
b370bb0564 | ||
|
|
c8859b4d3f | ||
|
|
ff187a10f6 | ||
|
|
d984695e79 | ||
|
|
b7d886ea34 | ||
|
|
09c0af504e | ||
|
|
dadda6cf59 | ||
|
|
b1d8d61cb1 |
No files matched your search
@@ -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
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -268,6 +268,8 @@ pub struct GeneralSettings {
|
||||
pub power_save_mode: bool,
|
||||
/// File › Rename Project… (project layer): the name shown for the project; empty = its folder name.
|
||||
pub project_name: String,
|
||||
/// Where updates come from: `auto` (the CNB mirror for the Chinese UI, else GitHub) | `github` | `cnb`.
|
||||
pub update_source: String,
|
||||
}
|
||||
|
||||
impl Default for GeneralSettings {
|
||||
@@ -282,6 +284,7 @@ impl Default for GeneralSettings {
|
||||
date_format: DateFormatSettings::default(),
|
||||
power_save_mode: false,
|
||||
project_name: String::new(),
|
||||
update_source: "auto".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -196,10 +196,18 @@ fn managed_dirs(root: &Path) -> Vec<PathBuf> {
|
||||
dirs
|
||||
}
|
||||
|
||||
/// The `PATH` an adapter runs with, unless its launch sets one: the search path language servers
|
||||
/// get, so what it starts in turn (js-debug's wrapper runs `node`, Delve runs `go build`) is
|
||||
/// found like Run finds it, even when the login shell's `PATH` didn't reach the app.
|
||||
fn adapter_path(env: &[(String, String)]) -> Option<std::ffi::OsString> {
|
||||
let own = env.iter().any(|(k, _)| if cfg!(windows) { k.eq_ignore_ascii_case("PATH") } else { k == "PATH" });
|
||||
(!own).then(ide_core::tool_path::search_path)
|
||||
}
|
||||
|
||||
/// Directories searched for adapter binaries: managed tools first, then `PATH` and the usual install spots.
|
||||
fn search_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = tools_root().map(|r| managed_dirs(&r)).unwrap_or_default();
|
||||
dirs.extend(std::env::var_os("PATH").map(|p| std::env::split_paths(&p).collect::<Vec<_>>()).unwrap_or_default());
|
||||
dirs.extend(std::env::split_paths(&ide_core::tool_path::search_path()));
|
||||
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
|
||||
for rel in [".cargo/bin", ".local/bin", "go/bin", ".dotnet/tools", ".bun/bin", ".npm-global/bin", ".volta/bin"]
|
||||
{
|
||||
@@ -464,6 +472,9 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
None => args,
|
||||
};
|
||||
let mut cmd = Command::new(&binary);
|
||||
if let Some(path) = adapter_path(&spec.env) {
|
||||
cmd.env("PATH", path);
|
||||
}
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
cmd.args(&args)
|
||||
.envs(spec.env.iter().map(|(k, v)| (k, v)))
|
||||
@@ -592,6 +603,21 @@ async fn connect_with_retries(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn adapters_run_on_the_tool_search_path() {
|
||||
let search = ide_core::tool_path::search_path();
|
||||
assert_eq!(adapter_path(&[]), Some(search.clone()));
|
||||
assert_eq!(adapter_path(&[("GOFLAGS".into(), "-mod=mod".into())]), Some(search));
|
||||
// A launch that sets its own (a selected Go SDK's bin first) keeps it.
|
||||
assert_eq!(adapter_path(&[("PATH".into(), "/sdk/go/bin:/usr/bin".into())]), None);
|
||||
// The search path reaches the usual install spots even when this process's `PATH` doesn't.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let dirs: Vec<PathBuf> = std::env::split_paths(&ide_core::tool_path::search_path()).collect();
|
||||
assert!(dirs.contains(&PathBuf::from("/usr/local/bin")), "{dirs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn program_output_is_forwarded_without_the_adapter_banner() {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
|
||||
+50
-23
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(¬es), assets, self.platform)
|
||||
}
|
||||
result => result,
|
||||
}
|
||||
}
|
||||
|
||||
fn install_release(
|
||||
&self,
|
||||
spec: &ToolSpec,
|
||||
|
||||
@@ -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
@@ -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(""", "\"").replace("'", "'").replace("<", "<").replace(">", ">").replace("&", "&")
|
||||
}
|
||||
|
||||
fn html_escape(s: &str) -> String {
|
||||
s.replace('&', "&").replace('"', """).replace('<', "<").replace('>', ">")
|
||||
}
|
||||
|
||||
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&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":[
|
||||
|
||||
@@ -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,
|
||||
"\n\n",
|
||||
PIXEL,
|
||||
);
|
||||
for (const name of ["Local QR", "Web QR"]) {
|
||||
await expect
|
||||
.poll(() => reply.getByRole("img", { name }).evaluate((img: HTMLImageElement) => img.naturalWidth))
|
||||
.toBe(1);
|
||||
}
|
||||
});
|
||||
+3
-2
@@ -69,7 +69,8 @@ test("⌘K commits the checked changes with the typed message", async ({ workben
|
||||
await expect(page.getByText(/1 file committed: Return order DTOs from create/)).toBeVisible();
|
||||
const [committed] = await ipcCalls(page, "vcs_commit");
|
||||
expect(committed).toMatchObject({ repo: ROOT, message: "Return order DTOs from create" });
|
||||
const staged = (await ipcCalls(page, "vcs_stage")).flatMap((a) => a.paths as string[]);
|
||||
expect(staged).toEqual([`${ROOT}/build.gradle.kts`]);
|
||||
// The commit takes the checked file itself; the index isn't staged beforehand.
|
||||
expect((committed!.options as { stage: string[] }).stage).toEqual([`${ROOT}/build.gradle.kts`]);
|
||||
expect(await ipcCalls(page, "vcs_stage")).toHaveLength(0);
|
||||
await expect(changes.getByRole("checkbox", { name: "Include build.gradle.kts in commit" })).toBeHidden();
|
||||
});
|
||||
@@ -8,12 +8,11 @@ import { expect, ipcCalls, stubDesktop, test } from "./fixtures";
|
||||
const ROOT = "/dev/acme-api";
|
||||
const NAMES = Array.from({ length: 20 }, (_, i) => `src/f${i}.ts`);
|
||||
|
||||
/** Twenty modified tracked files; `vcs_stage` + `vcs_commit` take the staged ones out of the status. */
|
||||
/** Twenty modified tracked files; `vcs_commit` takes the files it commits out of the status. */
|
||||
async function stubTwentyChanges(page: Page) {
|
||||
await stubDesktop(page);
|
||||
await page.evaluate(
|
||||
async ({ root, names }) => {
|
||||
const staged = new Set<string>();
|
||||
const committed = new Set<string>();
|
||||
const all = names.map((rel) => ({
|
||||
path: `${root}/${rel}`,
|
||||
@@ -33,14 +32,10 @@ async function stubTwentyChanges(page: Page) {
|
||||
operation: null,
|
||||
});
|
||||
h.vcs_status = () => all.filter((f) => !committed.has(f.path));
|
||||
h.vcs_stage = (args) => {
|
||||
for (const path of args.paths as string[]) staged.add(path);
|
||||
return null;
|
||||
};
|
||||
h.vcs_stage = () => null;
|
||||
h.vcs_unstage = () => null;
|
||||
h.vcs_commit = () => {
|
||||
for (const path of staged) committed.add(path);
|
||||
staged.clear();
|
||||
h.vcs_commit = (args) => {
|
||||
for (const path of (args.options as { stage: string[] }).stage) committed.add(path);
|
||||
return "2222222";
|
||||
};
|
||||
const vcsModule = "/src/features/vcs/store.ts";
|
||||
@@ -71,7 +66,8 @@ test("changes left out of a commit stay unchecked (#1539)", async ({ workbench:
|
||||
await panel.getByRole("textbox", { name: "Commit message" }).fill("Commit 5 of 20");
|
||||
await panel.getByRole("button", { name: "Commit", exact: true }).click();
|
||||
await expect(page.getByText(/5 files committed: Commit 5 of 20/)).toBeVisible();
|
||||
expect((await ipcCalls(page, "vcs_stage")).flatMap((a) => a.paths as string[]).sort()).toEqual(
|
||||
const [commit] = await ipcCalls(page, "vcs_commit");
|
||||
expect([...(commit!.options as { stage: string[] }).stage].sort()).toEqual(
|
||||
NAMES.slice(0, 5)
|
||||
.map((rel) => `${ROOT}/${rel}`)
|
||||
.sort(),
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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"] }
|
||||
|
||||
@@ -330,36 +330,74 @@ fn update_endpoint_env() -> Option<String> {
|
||||
/// tauri.conf.json, or the `OPENIDEA_UPDATE_ENDPOINT` environment variable. Empty by
|
||||
/// default, and "Check for Updates…" is hidden then.
|
||||
pub fn updates_configured<R: tauri::Runtime>(app: &AppHandle<R>) -> bool {
|
||||
let in_config = app
|
||||
.config()
|
||||
!config_endpoints(app).is_empty() || update_endpoint_env().is_some()
|
||||
}
|
||||
|
||||
/// Where updates are downloaded from (System Settings › Updates).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum UpdateSource {
|
||||
/// The GitHub releases named in tauri.conf.json.
|
||||
#[default]
|
||||
Github,
|
||||
/// The CNB mirror of the same releases, for networks where GitHub is slow.
|
||||
Cnb,
|
||||
}
|
||||
|
||||
const CNB_ENDPOINT: &str = "https://cnb.cool/l0ng-ai/openidea/-/releases/latest/download/latest.json";
|
||||
|
||||
fn config_endpoints<R: tauri::Runtime>(app: &AppHandle<R>) -> Vec<String> {
|
||||
app.config()
|
||||
.plugins
|
||||
.0
|
||||
.get("updater")
|
||||
.and_then(|u| u.get("endpoints"))
|
||||
.and_then(|e| e.as_array())
|
||||
.is_some_and(|e| !e.is_empty());
|
||||
in_config || update_endpoint_env().is_some()
|
||||
.map(|e| e.iter().filter_map(|u| u.as_str().map(String::from)).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn updater(app: &AppHandle) -> IdeResult<Option<tauri_plugin_updater::Updater>> {
|
||||
/// The endpoints tried in order: `source` first, the other source when it fails or has
|
||||
/// no manifest. Bundles are signed, so either source's manifest and downloads verify.
|
||||
/// No official endpoint (a build without updates) means no mirror either.
|
||||
fn source_endpoints(official: Vec<String>, source: UpdateSource) -> Vec<String> {
|
||||
if official.is_empty() {
|
||||
return official;
|
||||
}
|
||||
let mirror = vec![CNB_ENDPOINT.to_string()];
|
||||
match source {
|
||||
UpdateSource::Github => [official, mirror].concat(),
|
||||
UpdateSource::Cnb => [mirror, official].concat(),
|
||||
}
|
||||
}
|
||||
|
||||
fn updater(app: &AppHandle, source: UpdateSource) -> IdeResult<Option<tauri_plugin_updater::Updater>> {
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
if !updates_configured(app) {
|
||||
return Ok(None);
|
||||
}
|
||||
let env = update_endpoint_env();
|
||||
let endpoints = match update_endpoint_env() {
|
||||
Some(url) => vec![url],
|
||||
None => source_endpoints(config_endpoints(app), source),
|
||||
};
|
||||
let endpoints = endpoints
|
||||
.iter()
|
||||
.map(|u| u.parse().map_err(|e| IdeError::invalid(format!("update endpoint {u}: {e}"))))
|
||||
.collect::<IdeResult<Vec<_>>>()?;
|
||||
let proxy = ide_core::proxy_settings::proxy();
|
||||
let mut builder = app.updater_builder().configure_client(move |b| ide_http::client::with_proxy(b, &proxy));
|
||||
if let Some(url) = env {
|
||||
let url = url.parse().map_err(|e| IdeError::invalid(format!("OPENIDEA_UPDATE_ENDPOINT: {e}")))?;
|
||||
builder = builder.endpoints(vec![url]).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
}
|
||||
builder.build().map(Some).map_err(|e| IdeError::unavailable(e.to_string()))
|
||||
app.updater_builder()
|
||||
.configure_client(move |b| ide_http::client::with_proxy(b, &proxy))
|
||||
.endpoints(endpoints)
|
||||
.map_err(|e| IdeError::invalid(e.to_string()))?
|
||||
.build()
|
||||
.map(Some)
|
||||
.map_err(|e| IdeError::unavailable(e.to_string()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn app_check_update(app: AppHandle) -> IdeResult<UpdateCheck> {
|
||||
let Some(updater) = updater(&app)? else {
|
||||
pub async fn app_check_update(app: AppHandle, source: UpdateSource) -> IdeResult<UpdateCheck> {
|
||||
let Some(updater) = updater(&app, source)? else {
|
||||
return Ok(UpdateCheck { enabled: false, update: None });
|
||||
};
|
||||
let update = updater.check().await.map_err(|e| IdeError::unavailable(e.to_string()))?;
|
||||
@@ -379,12 +417,12 @@ pub async fn app_check_update(app: AppHandle) -> IdeResult<UpdateCheck> {
|
||||
/// not reported: the user didn't ask for this check.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn app_startup_update_check(app: AppHandle) -> Option<UpdateInfo> {
|
||||
pub async fn app_startup_update_check(app: AppHandle, source: UpdateSource) -> Option<UpdateInfo> {
|
||||
static DONE: AtomicBool = AtomicBool::new(false);
|
||||
if cfg!(debug_assertions) || DONE.swap(true, Ordering::SeqCst) {
|
||||
return None;
|
||||
}
|
||||
match app_check_update(app).await {
|
||||
match app_check_update(app, source).await {
|
||||
Ok(check) => check.update,
|
||||
Err(e) => {
|
||||
tracing::warn!("startup update check failed: {e}");
|
||||
@@ -452,6 +490,7 @@ impl Drop for InstallingGuard {
|
||||
pub async fn app_install_update(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
source: UpdateSource,
|
||||
request: RequestId,
|
||||
on_progress: Channel<UpdateProgress>,
|
||||
) -> IdeResult<()> {
|
||||
@@ -459,7 +498,7 @@ pub async fn app_install_update(
|
||||
InstallingGuard::acquire().ok_or_else(|| IdeError::unavailable("an update is already being installed"))?;
|
||||
let token = state.host.begin_request(request);
|
||||
let result = async {
|
||||
let updater = updater(&app)?.ok_or_else(|| IdeError::unavailable("updates are not configured"))?;
|
||||
let updater = updater(&app, source)?.ok_or_else(|| IdeError::unavailable("updates are not configured"))?;
|
||||
let update = tokio::select! {
|
||||
r = updater.check() => r.map_err(|e| IdeError::unavailable(e.to_string()))?,
|
||||
_ = token.cancelled() => return Err(IdeError::cancelled()),
|
||||
@@ -522,6 +561,15 @@ mod tests {
|
||||
assert!(ProgressThrottle::default().chunk(1, Some(0)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chosen_source_is_tried_first_and_the_other_after_it() {
|
||||
let github = "https://github.com/l0ng-ai/openidea/releases/latest/download/latest.json".to_string();
|
||||
assert_eq!(source_endpoints(vec![github.clone()], UpdateSource::Github), [github.clone(), CNB_ENDPOINT.into()]);
|
||||
assert_eq!(source_endpoints(vec![github.clone()], UpdateSource::Cnb), [CNB_ENDPOINT.into(), github]);
|
||||
// A build without an update endpoint stays without one.
|
||||
assert!(source_endpoints(Vec::new(), UpdateSource::Cnb).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_one_install_at_a_time() {
|
||||
let first = InstallingGuard::acquire().expect("first install starts");
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' asset: data: blob: https://avatars.githubusercontent.com; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src ipc: http://ipc.localhost; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; frame-src http://127.0.0.1:* openidea-plugin: http://openidea-plugin.localhost"
|
||||
"csp": "default-src 'self'; img-src 'self' asset: data: blob: http: https:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src ipc: http://ipc.localhost; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; frame-src http://127.0.0.1:* openidea-plugin: http://openidea-plugin.localhost"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
+13
-4
@@ -910,24 +910,24 @@ export const commands = {
|
||||
menuSetDisabled: (ids: string[]) => __TAURI_INVOKE<void>("menu_set_disabled", { ids }),
|
||||
appLogLocation: () => typedError<LogLocation, IdeError>(__TAURI_INVOKE("app_log_location")),
|
||||
appBuildInfo: () => __TAURI_INVOKE<BuildInfo>("app_build_info"),
|
||||
appCheckUpdate: () => typedError<UpdateCheck, IdeError>(__TAURI_INVOKE("app_check_update")),
|
||||
appCheckUpdate: (source: UpdateSource) => typedError<UpdateCheck, IdeError>(__TAURI_INVOKE("app_check_update", { source })),
|
||||
/**
|
||||
* The background check made once per process when the first window starts;
|
||||
* later calls (other windows) and debug builds get `None`. Failures are logged,
|
||||
* not reported: the user didn't ask for this check.
|
||||
*/
|
||||
appStartupUpdateCheck: () => __TAURI_INVOKE<{
|
||||
appStartupUpdateCheck: (source: UpdateSource) => __TAURI_INVOKE<{
|
||||
version: string,
|
||||
currentVersion: string,
|
||||
notes: string | null,
|
||||
date: string | null,
|
||||
} | null>("app_startup_update_check"),
|
||||
} | null>("app_startup_update_check", { source }),
|
||||
/**
|
||||
* Downloads and installs the available update, streaming progress to `on_progress`.
|
||||
* Cancellable through `request` until the download completes; the UI then restarts
|
||||
* with `app_restart`. One install runs at a time.
|
||||
*/
|
||||
appInstallUpdate: (request: RequestId, onProgress: Channel<UpdateProgress>) => typedError<null, IdeError>(__TAURI_INVOKE("app_install_update", { request, onProgress })),
|
||||
appInstallUpdate: (source: UpdateSource, request: RequestId, onProgress: Channel<UpdateProgress>) => typedError<null, IdeError>(__TAURI_INVOKE("app_install_update", { source, request, onProgress })),
|
||||
debugExceptionFilters: () => typedError<SessionExceptionFilters[], IdeError>(__TAURI_INVOKE("debug_exception_filters")),
|
||||
debugSetExceptionBreakpoints: (choices: { [key in string]: boolean }) => typedError<null, IdeError>(__TAURI_INVOKE("debug_set_exception_breakpoints", { choices })),
|
||||
debugSetVariable: (session: DebugSessionId, container: number, name: string, evaluateName: string | null, frameId: number | null, value: string) => typedError<Variable, IdeError>(__TAURI_INVOKE("debug_set_variable", { session, container, name, evaluateName, frameId, value })),
|
||||
@@ -3866,6 +3866,8 @@ export type GeneralSettings = {
|
||||
powerSaveMode?: boolean,
|
||||
/** File › Rename Project… (project layer): the name shown for the project; empty = its folder name. */
|
||||
projectName?: string,
|
||||
/** Where updates come from: `auto` (the CNB mirror for the Chinese UI, else GitHub) | `github` | `cnb`. */
|
||||
updateSource?: string,
|
||||
};
|
||||
|
||||
/** A repository of the signed-in account (owned, collaborator, or organization member). */
|
||||
@@ -7598,6 +7600,13 @@ export type UpdateProgress =
|
||||
/** Download done; the new version is being written over this one. */
|
||||
{ type: "installing" };
|
||||
|
||||
/** Where updates are downloaded from (System Settings › Updates). */
|
||||
export type UpdateSource =
|
||||
/** The GitHub releases named in tauri.conf.json. */
|
||||
"github" |
|
||||
/** The CNB mirror of the same releases, for networks where GitHub is slow. */
|
||||
"cnb";
|
||||
|
||||
export type Variable = {
|
||||
name: string,
|
||||
value: string,
|
||||
|
||||
@@ -30,6 +30,19 @@
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
/*
|
||||
* Kept-alive tabs share the content box; hiding one only skips its rendering, not its layout.
|
||||
* Nothing inherited changes (pointer-events, inert): that would restyle the whole conversation.
|
||||
*/
|
||||
.kept {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.keptHidden {
|
||||
content-visibility: hidden;
|
||||
z-index: -1;
|
||||
}
|
||||
.host {
|
||||
flex: 1;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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={``} 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={``} 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="" cwd="/repo" />);
|
||||
await waitFor(() => expect(screen.getByRole("img")).toHaveAttribute("src", PIXEL));
|
||||
rerender(<Markdown text="\n\nScan to authorize." cwd="/repo" />);
|
||||
await waitFor(() => expect(screen.getByRole("img")).toHaveAttribute("src", PIXEL));
|
||||
expect(readImage).toHaveBeenCalledTimes(1);
|
||||
unmount();
|
||||
render(<Markdown text="" 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="" 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="\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="" 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="" 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");
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { UpdateCheck, UpdateProgress } from "@/bindings";
|
||||
import type { Settings, UpdateCheck, UpdateProgress } from "@/bindings";
|
||||
import type { NotificationService } from "@/workbench/contributions";
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
@@ -20,7 +20,7 @@ function deferred<T>() {
|
||||
let install: ReturnType<typeof deferred<Result<null>>>;
|
||||
let check: ReturnType<typeof deferred<Result<UpdateCheck>>>;
|
||||
let channel: Channel | null;
|
||||
const appInstallUpdate = vi.fn((_request: number, ch: Channel) => {
|
||||
const appInstallUpdate = vi.fn((_source: string, _request: number, ch: Channel) => {
|
||||
channel = ch;
|
||||
return install.promise;
|
||||
});
|
||||
@@ -28,18 +28,35 @@ const appCheckUpdate = vi.fn(() => check.promise);
|
||||
const appRestart = vi.fn(async () => null);
|
||||
const cancelRequest = vi.fn(async () => null);
|
||||
const appBuildInfo = vi.fn(async () => ({ version: "0.1.21" }));
|
||||
const settingsSet = vi.fn(async () => ({ status: "ok", data: { general: {}, appearance: {} } }));
|
||||
|
||||
vi.mock("@/platform/ipc", async (orig) => {
|
||||
const actual = await orig<typeof import("@/platform/ipc")>();
|
||||
return {
|
||||
...actual,
|
||||
isTauri: () => true,
|
||||
commands: { ...actual.commands, appInstallUpdate, appCheckUpdate, appRestart, cancelRequest, appBuildInfo },
|
||||
commands: {
|
||||
...actual.commands,
|
||||
appInstallUpdate,
|
||||
appCheckUpdate,
|
||||
appRestart,
|
||||
cancelRequest,
|
||||
appBuildInfo,
|
||||
settingsSet,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { checkForUpdates, installUpdate, notifyUpdate, progressText, CHECK_TASK_ID, INSTALL_TASK_ID, UPDATE_KEY } =
|
||||
await import("../updates");
|
||||
const {
|
||||
checkForUpdates,
|
||||
installUpdate,
|
||||
notifyUpdate,
|
||||
progressText,
|
||||
updateSource,
|
||||
CHECK_TASK_ID,
|
||||
INSTALL_TASK_ID,
|
||||
UPDATE_KEY,
|
||||
} = await import("../updates");
|
||||
const { provideNotificationService } = await import("@/workbench/contributions");
|
||||
const { useProject } = await import("@/platform/project");
|
||||
const { useTasks } = await import("@/workbench/tasks");
|
||||
@@ -60,7 +77,7 @@ beforeEach(() => {
|
||||
check = deferred();
|
||||
provideNotificationService({ notify: (n) => void notes.push(n), expire: (k) => void expired.push(k) });
|
||||
useTasks.setState({ tasks: [] });
|
||||
useProject.setState({ project: null, lightEdit: false });
|
||||
useProject.setState({ project: null, lightEdit: false, settings: null });
|
||||
});
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
@@ -128,12 +145,33 @@ describe("Install and Restart", () => {
|
||||
install = deferred();
|
||||
const cancelled = installUpdate("0.1.22");
|
||||
task(INSTALL_TASK_ID)!.cancel!();
|
||||
expect(cancelRequest).toHaveBeenCalledWith(appInstallUpdate.mock.calls.at(-1)![0]);
|
||||
expect(cancelRequest).toHaveBeenCalledWith(appInstallUpdate.mock.calls.at(-1)![1]);
|
||||
install.resolve({ status: "error", error: { kind: "cancelled", message: "cancelled" } });
|
||||
await cancelled;
|
||||
expect(notes.at(-1)).toMatchObject({ level: "info", title: "Update cancelled", key: UPDATE_KEY });
|
||||
expect(appRestart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a failed", "unavailable", "github", "CNB", "cnb"],
|
||||
["a failed", "unavailable", "cnb", "GitHub", "github"],
|
||||
["a cancelled (likely slow)", "cancelled", "cnb", "GitHub", "github"],
|
||||
])("offers %s download from %s again from the other source", async (_case, kind, from, label, to) => {
|
||||
useProject.setState({ settings: { general: { updateSource: from }, appearance: {} } as unknown as Settings });
|
||||
const first = installUpdate("0.1.22");
|
||||
install.resolve({ status: "error", error: { kind, message: kind } });
|
||||
await first;
|
||||
const action = notes.at(-1)!.actions!.at(-1)!;
|
||||
expect(action.label).toBe(`Retry with ${label}`);
|
||||
|
||||
install = deferred();
|
||||
action.run();
|
||||
await tick();
|
||||
expect(settingsSet).toHaveBeenCalledWith("global", "general.updateSource", JSON.stringify(to));
|
||||
expect(appInstallUpdate.mock.calls.at(-1)![0]).toBe(to);
|
||||
install.resolve(ok(null));
|
||||
await tick();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Check for Updates", () => {
|
||||
@@ -184,6 +222,36 @@ describe("Check for Updates", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateSource", () => {
|
||||
const withSettings = (updateSource: string | undefined, locale: string | undefined) =>
|
||||
useProject.setState({ settings: { general: { updateSource }, appearance: { locale } } as unknown as Settings });
|
||||
|
||||
it.each([
|
||||
["auto", "zh", "cnb"],
|
||||
["auto", "zh-CN", "cnb"],
|
||||
["auto", "en", "github"],
|
||||
["auto", "ko", "github"],
|
||||
[undefined, "zh", "cnb"],
|
||||
["github", "zh", "github"],
|
||||
["cnb", "en", "cnb"],
|
||||
["bogus", "en", "github"],
|
||||
])("%s with the %s UI uses %s", (setting, locale, expected) => {
|
||||
withSettings(setting, locale);
|
||||
expect(updateSource()).toBe(expected);
|
||||
});
|
||||
|
||||
it("is passed to the check and the install", async () => {
|
||||
withSettings("cnb", "en");
|
||||
void checkForUpdates();
|
||||
expect(appCheckUpdate).toHaveBeenCalledWith("cnb");
|
||||
check.resolve(ok({ enabled: true, update: null }));
|
||||
void installUpdate("0.1.22");
|
||||
expect(appInstallUpdate.mock.calls.at(-1)![0]).toBe("cnb");
|
||||
install.resolve(ok(null));
|
||||
await tick();
|
||||
});
|
||||
});
|
||||
|
||||
describe("progressText", () => {
|
||||
it("formats known and unknown lengths", () => {
|
||||
expect(progressText({ type: "downloading", downloaded: 1024 * 1024, total: null })).toEqual({
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* repeated click joins the running check or install instead of starting another.
|
||||
*/
|
||||
import { Channel } from "@tauri-apps/api/core";
|
||||
import { t } from "@/platform/i18n";
|
||||
import { resolveLocale, t } from "@/platform/i18n";
|
||||
import {
|
||||
call,
|
||||
commands,
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
nextRequestId,
|
||||
type UpdateInfo,
|
||||
type UpdateProgress,
|
||||
type UpdateSource,
|
||||
} from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
@@ -30,6 +31,39 @@ function errorText(e: unknown) {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
/** System Settings › Updates › Update source; `auto` picks the CNB mirror for the Chinese UI. */
|
||||
export function updateSource(): UpdateSource {
|
||||
const { settings } = useProject.getState();
|
||||
const chosen = settings?.general?.updateSource;
|
||||
if (chosen === "github" || chosen === "cnb") return chosen;
|
||||
return resolveLocale(settings?.appearance?.locale) === "zh" ? "cnb" : "github";
|
||||
}
|
||||
|
||||
const SOURCE_NAMES: Record<UpdateSource, string> = { github: "GitHub", cnb: "CNB" };
|
||||
|
||||
/** Makes `source` the update source; the retry it precedes uses it even if saving fails. */
|
||||
async function chooseSource(source: UpdateSource) {
|
||||
try {
|
||||
useProject.setState({
|
||||
settings: await call(commands.settingsSet("global", "general.updateSource", JSON.stringify(source))),
|
||||
});
|
||||
} catch {
|
||||
/* the choice still applies to this retry */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Offered once a download failed or was cancelled (likely too slow): the same install from
|
||||
* the other source. A check never needs it, since it already falls back to the other source.
|
||||
*/
|
||||
function otherSourceAction(version: string, source: UpdateSource) {
|
||||
const other: UpdateSource = source === "cnb" ? "github" : "cnb";
|
||||
return {
|
||||
label: t("app.update.retryWith", { source: SOURCE_NAMES[other] }),
|
||||
run: () => void chooseSource(other).then(() => installUpdate(version, other)),
|
||||
};
|
||||
}
|
||||
|
||||
let checking: Promise<void> | null = null;
|
||||
let installing: Promise<void> | null = null;
|
||||
|
||||
@@ -56,7 +90,7 @@ async function runCheck() {
|
||||
key: UPDATE_KEY,
|
||||
});
|
||||
try {
|
||||
const r = await Promise.race([call(commands.appCheckUpdate()), abandoned]);
|
||||
const r = await Promise.race([call(commands.appCheckUpdate(updateSource())), abandoned]);
|
||||
if (r === "cancelled") return;
|
||||
if (!r.enabled) {
|
||||
notifications().notify({
|
||||
@@ -85,7 +119,7 @@ async function runCheck() {
|
||||
/** Silent check once per app start; only an available update is shown. */
|
||||
export async function checkForUpdatesOnStartup(): Promise<void> {
|
||||
if (!isTauri()) return;
|
||||
const update = await commands.appStartupUpdateCheck().catch(() => null);
|
||||
const update = await commands.appStartupUpdateCheck(updateSource()).catch(() => null);
|
||||
if (update) notifyUpdate(update);
|
||||
}
|
||||
|
||||
@@ -118,13 +152,13 @@ export function progressText(p: UpdateProgress): { message: string; fraction: nu
|
||||
}
|
||||
|
||||
/** Downloads and installs `version`, then restarts. A second call while one runs joins it. */
|
||||
export function installUpdate(version: string): Promise<void> {
|
||||
export function installUpdate(version: string, source = updateSource()): Promise<void> {
|
||||
if (!isTauri()) return Promise.resolve();
|
||||
installing ??= runInstall(version).finally(() => (installing = null));
|
||||
installing ??= runInstall(version, source).finally(() => (installing = null));
|
||||
return installing;
|
||||
}
|
||||
|
||||
async function runInstall(version: string) {
|
||||
async function runInstall(version: string, source: UpdateSource) {
|
||||
const title = t("app.update.downloading", { version });
|
||||
const request = nextRequestId();
|
||||
const cancel = () => void commands.cancelRequest(request).catch(() => {});
|
||||
@@ -160,17 +194,25 @@ async function runInstall(version: string) {
|
||||
// Replaces the notification whose action is now spent.
|
||||
balloon([{ label: t("app.update.cancel"), run: cancel }]);
|
||||
try {
|
||||
await call(commands.appInstallUpdate(request, channel));
|
||||
await call(commands.appInstallUpdate(source, request, channel));
|
||||
} catch (e) {
|
||||
if (isCancelled(e)) {
|
||||
notifications().notify({ level: "info", title: t("app.update.cancelled"), key: UPDATE_KEY });
|
||||
notifications().notify({
|
||||
level: "info",
|
||||
title: t("app.update.cancelled"),
|
||||
key: UPDATE_KEY,
|
||||
actions: [otherSourceAction(version, source)],
|
||||
});
|
||||
} else {
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: t("app.update.installFailed"),
|
||||
body: errorText(e),
|
||||
key: UPDATE_KEY,
|
||||
actions: [{ label: t("app.update.retry"), run: () => void installUpdate(version) }],
|
||||
actions: [
|
||||
{ label: t("app.update.retry"), run: () => void installUpdate(version, source) },
|
||||
otherSourceAction(version, source),
|
||||
],
|
||||
});
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -53,7 +53,7 @@ import { executeAction, getAction } from "@/platform/actions";
|
||||
import { intellijMacos } from "@/platform/keymaps/intellij-macos";
|
||||
import type { DebugCapabilities } from "@/platform/ipc";
|
||||
import { registerDebugActions } from "../actions";
|
||||
import { breakpointsOf, loadBreakpoints } from "../breakpoints/store";
|
||||
import { breakpointsOf, loadBreakpoints, toggleBreakpoint } from "../breakpoints/store";
|
||||
import { loadSpecialBreakpoints, removeAllSpecial, useSpecialBreakpoints } from "../breakpoints/special";
|
||||
import { publishContext, updateExecutionPoint, useExecutionPoint, useDebug, type DebugSession } from "../state";
|
||||
import { focusDebugView, blurDebugView } from "../ui/focusedView";
|
||||
@@ -253,6 +253,21 @@ describe("debugger actions (IDEA parity)", () => {
|
||||
expect(opened.at(-1)).toEqual({ path: "/p/app.py", focus: true });
|
||||
});
|
||||
|
||||
it("puts no new breakpoint on a blank line", async () => {
|
||||
for (const action of ["debug.toggleBreakpoint", "debug.toggleTemporaryBreakpoint"]) {
|
||||
for (const line of [1, 2]) {
|
||||
caret("/p/a.py", "python", "x = 1\n\n \ny = 2", line);
|
||||
await executeAction(action);
|
||||
}
|
||||
expect(breakpointsOf("/p/a.py"), action).toHaveLength(0);
|
||||
}
|
||||
// One placed before the line went blank can still be toggled off.
|
||||
toggleBreakpoint("/p/a.py", 1);
|
||||
caret("/p/a.py", "python", "x = 1\n\n \ny = 2", 1);
|
||||
await executeAction("debug.toggleBreakpoint");
|
||||
expect(breakpointsOf("/p/a.py")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("toggles temporary, method and field breakpoints at the caret", async () => {
|
||||
caret("/p/a.py", "python", "x = 1\ndef run():\n pass", 0);
|
||||
await executeAction("debug.toggleTemporaryBreakpoint");
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { java } from "@codemirror/lang-java";
|
||||
import { lineHasCode } from "../breakpoints/codeLines";
|
||||
|
||||
const lines = (state: EditorState) => Array.from({ length: state.doc.lines }, (_, i) => lineHasCode(state, i));
|
||||
|
||||
describe("lines that take a breakpoint", () => {
|
||||
it("skips blank and comment-only lines", () => {
|
||||
const doc = [
|
||||
"class A {",
|
||||
"",
|
||||
" \t",
|
||||
" // note",
|
||||
" /** doc",
|
||||
" * more",
|
||||
" */",
|
||||
" int f() { return 1; } // trailing",
|
||||
" /* a */ int x = 1;",
|
||||
" /* a */ /* b */",
|
||||
"}",
|
||||
].join("\n");
|
||||
const state = EditorState.create({ doc, extensions: [java()] });
|
||||
expect(lines(state)).toEqual([true, false, false, false, false, false, false, true, true, false, true]);
|
||||
});
|
||||
|
||||
it("only skips blank lines in a file without a parser", () => {
|
||||
expect(lines(EditorState.create({ doc: "x\n\n \n// y" }))).toEqual([true, false, false, true]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -7,6 +7,7 @@ import { activeEditor } from "@/editor/api";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { maybeEditorService, notifications, showStatusInfo, toolWindowService } from "@/workbench/contributions";
|
||||
import { classFileBreakpointReason } from "@/platform/classFiles";
|
||||
import { lineHasCode } from "./breakpoints/codeLines";
|
||||
import { canPlaceBreakpoint } from "./breakpoints/languages";
|
||||
import {
|
||||
removeAllBreakpoints,
|
||||
@@ -628,6 +629,9 @@ export function registerDebugActions(): () => void {
|
||||
return;
|
||||
}
|
||||
const ed = activeEditor();
|
||||
// Like IDEA, no new breakpoint on a blank or comment-only line; an existing one still goes.
|
||||
const taken = exists || specialAt(c.path).some((b) => b.line === c.line);
|
||||
if (!taken && ed && !lineHasCode(ed.view.state, c.line)) return;
|
||||
if (ed) toggleBreakpointSmart(c.path, c.line, language, ed.view.state.doc.toString());
|
||||
else toggleBreakpoint(c.path, c.line);
|
||||
},
|
||||
@@ -640,8 +644,11 @@ export function registerDebugActions(): () => void {
|
||||
description: "A line breakpoint that is removed after it is hit",
|
||||
run: () => {
|
||||
const c = cursor();
|
||||
if (c && breakpointPathAllowed(c.path) && canPlaceBreakpoint(activeEditor()?.ctx.language ?? "", c.path))
|
||||
toggleTemporaryBreakpoint(c.path, c.line);
|
||||
const ed = activeEditor();
|
||||
if (!c || !breakpointPathAllowed(c.path) || !canPlaceBreakpoint(ed?.ctx.language ?? "", c.path)) return;
|
||||
const exists = useBreakpoints.getState().byFile[c.path]?.some((b) => b.line === c.line);
|
||||
if (!exists && ed && !lineHasCode(ed.view.state, c.line)) return;
|
||||
toggleTemporaryBreakpoint(c.path, c.line);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* IDEA puts a new line breakpoint only where there is code: a gutter click or Toggle Line
|
||||
* Breakpoint on a blank or comment-only line does nothing. Comments come from the syntax
|
||||
* tree; in a file without a parser only blank lines are skipped.
|
||||
*/
|
||||
import { ensureSyntaxTree, syntaxTree } from "@codemirror/language";
|
||||
import type { EditorState } from "@codemirror/state";
|
||||
import type { SyntaxNode, Tree } from "@lezer/common";
|
||||
|
||||
const PARSE_BUDGET_MS = 20;
|
||||
|
||||
function commentAt(tree: Tree, pos: number): SyntaxNode | null {
|
||||
for (let n: SyntaxNode | null = tree.resolveInner(pos, 1); n; n = n.parent) if (/comment/i.test(n.name)) return n;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Whether `line` (0-based) has anything besides whitespace and comments. */
|
||||
export function lineHasCode(state: EditorState, line: number): boolean {
|
||||
if (line < 0 || line >= state.doc.lines) return false;
|
||||
const { from, to, text } = state.doc.line(line + 1);
|
||||
if (!/\S/.test(text)) return false;
|
||||
const tree = ensureSyntaxTree(state, to, PARSE_BUDGET_MS) ?? syntaxTree(state);
|
||||
for (let pos = from; pos < to;) {
|
||||
if (/\s/.test(text[pos - from]!)) {
|
||||
pos++;
|
||||
continue;
|
||||
}
|
||||
const comment = commentAt(tree, pos);
|
||||
if (!comment) return true;
|
||||
pos = Math.max(pos + 1, comment.to);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { classFileBreakpointReason } from "@/platform/classFiles";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { showStatusInfo } from "@/workbench/contributions";
|
||||
import { lineHasCode } from "../breakpoints/codeLines";
|
||||
import { canPlaceBreakpoint } from "../breakpoints/languages";
|
||||
import {
|
||||
removeSpecial,
|
||||
@@ -185,14 +186,19 @@ export function breakpointGutter(ctx: EditorDocContext): Extension {
|
||||
private unsubs: Array<() => void>;
|
||||
private pending = false;
|
||||
private destroyed = false;
|
||||
private running = sessionsRunning();
|
||||
constructor(readonly view: EditorView) {
|
||||
this.unsubs = [
|
||||
useBreakpoints.subscribe((s, prev) => {
|
||||
if (s.byFile[path] !== prev.byFile[path] || s.status !== prev.status || s.muted !== prev.muted)
|
||||
this.scheduleRebuild();
|
||||
// States arrive per file: another file's don't change this one's markers.
|
||||
const states = (s.byFile[path] ?? []).some((b) => s.status[b.id] !== prev.status[b.id]);
|
||||
if (s.byFile[path] !== prev.byFile[path] || states || s.muted !== prev.muted) this.scheduleRebuild();
|
||||
}),
|
||||
// The markers only show whether a session runs, while each step updates `sessions` several times.
|
||||
useDebug.subscribe((s, prev) => {
|
||||
if (s.sessions !== prev.sessions) this.scheduleRebuild();
|
||||
if (s.sessions === prev.sessions || sessionsRunning() === this.running) return;
|
||||
this.running = !this.running;
|
||||
this.scheduleRebuild();
|
||||
}),
|
||||
useSpecialBreakpoints.subscribe((s, prev) => {
|
||||
if (s.methods !== prev.methods || s.fields !== prev.fields || s.status !== prev.status)
|
||||
@@ -232,9 +238,10 @@ export function breakpointGutter(ctx: EditorDocContext): Extension {
|
||||
}
|
||||
|
||||
/**
|
||||
* IDEA's gutter clicks: click toggles; on an empty line ⇧-click adds a non-suspending
|
||||
* logging breakpoint and opens its properties, ⌥-click a temporary one. On an existing
|
||||
* breakpoint only a plain (or ⌘/Ctrl) click removes it; middle-click enables/disables.
|
||||
* IDEA's gutter clicks: click toggles; on a line without a breakpoint ⇧-click adds a
|
||||
* non-suspending logging breakpoint and opens its properties, ⌥-click a temporary one.
|
||||
* Blank and comment-only lines take none. On an existing breakpoint only a plain
|
||||
* (or ⌘/Ctrl) click removes it; middle-click enables/disables.
|
||||
*/
|
||||
onMouseDown = (e: MouseEvent) => {
|
||||
if ((e.button !== 0 && e.button !== 1) || !inBreakpointArea(e)) return;
|
||||
@@ -271,6 +278,7 @@ export function breakpointGutter(ctx: EditorDocContext): Extension {
|
||||
else removeBreakpoint(path, bp!.id);
|
||||
return;
|
||||
}
|
||||
if (!lineHasCode(this.view.state, line)) return;
|
||||
if (e.altKey) {
|
||||
addBreakpoint(path, line, { temporary: true });
|
||||
return;
|
||||
|
||||
@@ -50,8 +50,11 @@ export interface DateFormatValid {
|
||||
pretty: boolean;
|
||||
}
|
||||
|
||||
export type UpdateSourceSetting = "auto" | "github" | "cnb";
|
||||
|
||||
export interface GeneralExtras {
|
||||
openProjectIn: OpenProjectIn;
|
||||
updateSource: UpdateSourceSetting;
|
||||
defaultProjectDirectory: string;
|
||||
saveOnIdle: boolean;
|
||||
idleSeconds: number;
|
||||
@@ -100,6 +103,7 @@ export const DEFAULT_APPEARANCE_EXTRAS: AppearanceExtras = {
|
||||
|
||||
export const DEFAULT_GENERAL_EXTRAS: GeneralExtras = {
|
||||
openProjectIn: "ask",
|
||||
updateSource: "auto",
|
||||
defaultProjectDirectory: "",
|
||||
saveOnIdle: false,
|
||||
idleSeconds: 15,
|
||||
@@ -160,6 +164,7 @@ export function validateGeneralExtras(raw: unknown): GeneralExtras {
|
||||
const df = obj(g.dateFormat);
|
||||
return {
|
||||
openProjectIn: oneOf(g.openProjectIn, ["ask", "new", "same", "attach"] as const, d.openProjectIn),
|
||||
updateSource: oneOf(g.updateSource, ["auto", "github", "cnb"] as const, d.updateSource),
|
||||
defaultProjectDirectory: typeof g.defaultProjectDirectory === "string" ? g.defaultProjectDirectory : "",
|
||||
saveOnIdle: bool(g.saveOnIdle, d.saveOnIdle),
|
||||
idleSeconds: int(g.idleSeconds, d.idleSeconds, IDLE_LIMITS),
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
type NotificationDisplay,
|
||||
type NotificationGroupValid,
|
||||
type OpenProjectIn,
|
||||
type UpdateSourceSetting,
|
||||
} from "../model/behaviorSchema";
|
||||
import { ACCENT_PRESETS, LIMITS, type ThemeId } from "../model/schema";
|
||||
import type { PageSpec } from "./spec";
|
||||
@@ -381,6 +382,12 @@ const OPEN_IN: Array<{ value: OpenProjectIn; label: string }> = [
|
||||
{ value: "ask", label: "Ask" },
|
||||
];
|
||||
|
||||
const UPDATE_SOURCES: SelectOption<UpdateSourceSetting>[] = [
|
||||
{ value: "auto", label: "Automatic" },
|
||||
{ value: "github", label: "GitHub" },
|
||||
{ value: "cnb", label: "CNB (China mirror)" },
|
||||
];
|
||||
|
||||
function OpenProjectInRow() {
|
||||
const [value, set] = useSetting<OpenProjectIn>("general.openProjectIn");
|
||||
const name = useId();
|
||||
@@ -724,6 +731,18 @@ export const BEHAVIOR_PAGES: PageSpec[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Updates",
|
||||
options: [
|
||||
{
|
||||
kind: "select",
|
||||
key: "general.updateSource",
|
||||
label: "Update source",
|
||||
hint: "Where updates are downloaded from. If it can't be reached, the other source is tried.",
|
||||
options: UPDATE_SOURCES,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Exit",
|
||||
options: [{ kind: "toggle", key: "general.confirmExit", label: "Confirm before exiting the IDE" }],
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { FontPicker } from "../components/FontPicker";
|
||||
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
import { quotePath } from "@/platform/commandLine";
|
||||
import { commands, isTauri } from "@/platform/ipc";
|
||||
import {
|
||||
DEFAULT_EXTERNAL_TOOL,
|
||||
@@ -110,7 +111,7 @@ function ShellPathRow() {
|
||||
const { open: pick } = await import("@tauri-apps/plugin-dialog");
|
||||
const picked = await pick({ multiple: false, title: "Shell Path" });
|
||||
const path = Array.isArray(picked) ? picked[0] : picked;
|
||||
if (path) set(path);
|
||||
if (path) set(quotePath(path));
|
||||
};
|
||||
return (
|
||||
<Row
|
||||
@@ -142,7 +143,7 @@ function ShellPathRow() {
|
||||
anchor={menuRef}
|
||||
placement="bottom-end"
|
||||
aria-label="Installed shells"
|
||||
items={shells.map((p) => ({ id: p, label: p, checked: p === value, run: () => set(p) }))}
|
||||
items={shells.map((p) => ({ id: p, label: p, checked: quotePath(p) === value, run: () => set(quotePath(p)) }))}
|
||||
/>
|
||||
<IconButton
|
||||
icon="folder-open"
|
||||
|
||||
@@ -16,9 +16,9 @@ import { installTerminalPaste, pasteTerminalClipboard } from "./paste";
|
||||
import { ImeCommitGate } from "./imeCommit";
|
||||
import { ownTerminalInput } from "./imeInput";
|
||||
import { findTerminalLinks, resolvePath, type TerminalLink } from "./links";
|
||||
import { splitCommandLine } from "@/platform/commandLine";
|
||||
import { parseShellCommand } from "@/platform/commandLine";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { useRemote } from "@/platform/remote";
|
||||
import { hostOs, useRemote } from "@/platform/remote";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import { workflowSettings, type TerminalSettingsValid } from "@/platform/workflowSettings";
|
||||
import { editorPrefs } from "@/platform/editorSettings";
|
||||
@@ -530,7 +530,7 @@ export class TerminalSession {
|
||||
};
|
||||
try {
|
||||
const t = workflowSettings().terminal;
|
||||
const [shellPath, ...shellArgs] = splitCommandLine(t.shellPath);
|
||||
const [shellPath, ...shellArgs] = parseShellCommand(t.shellPath, hostOs() === "windows");
|
||||
const shell = custom ? custom.program : shellPath;
|
||||
const args = custom ? custom.args : shellArgs;
|
||||
const info = await call(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export const CLIENT_SETTINGS_KEYS = [
|
||||
"general.reopenLastProject",
|
||||
"general.confirmExit",
|
||||
"general.openProjectIn",
|
||||
"general.updateSource",
|
||||
"general.dateFormat",
|
||||
"editor.fontFamily",
|
||||
"editor.fontSize",
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseShellCommand, quotePath } from "./commandLine";
|
||||
|
||||
describe("parseShellCommand", () => {
|
||||
it("keeps Windows paths as typed", () => {
|
||||
expect(parseShellCommand(String.raw`C:\Windows\system32\cmd.exe`, true)).toEqual([
|
||||
String.raw`C:\Windows\system32\cmd.exe`,
|
||||
]);
|
||||
expect(parseShellCommand(String.raw`C:\Windows\system32\cmd.exe /k C:\tools\init.bat`, true)).toEqual([
|
||||
String.raw`C:\Windows\system32\cmd.exe`,
|
||||
"/k",
|
||||
String.raw`C:\tools\init.bat`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("takes an unquoted Windows path with spaces up to its executable", () => {
|
||||
expect(parseShellCommand(String.raw`C:\Program Files\PowerShell\7\pwsh.exe`, true)).toEqual([
|
||||
String.raw`C:\Program Files\PowerShell\7\pwsh.exe`,
|
||||
]);
|
||||
expect(parseShellCommand(String.raw`C:\Program Files\Git\bin\bash.EXE --login -i`, true)).toEqual([
|
||||
String.raw`C:\Program Files\Git\bin\bash.EXE`,
|
||||
"--login",
|
||||
"-i",
|
||||
]);
|
||||
});
|
||||
|
||||
it("honours quotes", () => {
|
||||
expect(parseShellCommand(String.raw`"C:\Program Files\Git\bin\bash.exe" --login`, true)).toEqual([
|
||||
String.raw`C:\Program Files\Git\bin\bash.exe`,
|
||||
"--login",
|
||||
]);
|
||||
expect(parseShellCommand(`/bin/zsh --login`, false)).toEqual(["/bin/zsh", "--login"]);
|
||||
expect(parseShellCommand(`"/Applications/My Shell/bin/fish" -l`, false)).toEqual([
|
||||
"/Applications/My Shell/bin/fish",
|
||||
"-l",
|
||||
]);
|
||||
expect(parseShellCommand(String.raw`/bin/sh -c a\ b`, false)).toEqual(["/bin/sh", "-c", "a b"]);
|
||||
});
|
||||
|
||||
it("is empty for an empty setting", () => {
|
||||
expect(parseShellCommand(" ", true)).toEqual([]);
|
||||
expect(parseShellCommand("", false)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("quotePath", () => {
|
||||
it("quotes only paths with whitespace", () => {
|
||||
expect(quotePath(String.raw`C:\Windows\system32\cmd.exe`)).toBe(String.raw`C:\Windows\system32\cmd.exe`);
|
||||
expect(quotePath(String.raw`C:\Program Files\PowerShell\7\pwsh.exe`)).toBe(
|
||||
String.raw`"C:\Program Files\PowerShell\7\pwsh.exe"`,
|
||||
);
|
||||
expect(parseShellCommand(quotePath("/Applications/My Shell/fish"), false)).toEqual(["/Applications/My Shell/fish"]);
|
||||
});
|
||||
});
|
||||
@@ -67,6 +67,28 @@ export function parseArgs(input: string, windowsPaths = false): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The program and arguments of a shell command line (Settings › Terminal › Shell path). On
|
||||
* Windows a backslash is a path separator, and an unquoted path up to its `.exe` / `.cmd` /
|
||||
* `.bat` / `.com` is the program even with spaces in it (`C:\Program Files\…\pwsh.exe -NoLogo`).
|
||||
*/
|
||||
export function parseShellCommand(line: string, windows: boolean): string[] {
|
||||
const text = line.trim();
|
||||
if (windows && !/["']/.test(text)) {
|
||||
const end = /\.(?:exe|cmd|bat|com)(?=\s|$)/i.exec(text);
|
||||
if (end) {
|
||||
const stop = end.index + end[0].length;
|
||||
return [text.slice(0, stop), ...parseArgs(text.slice(stop), true)];
|
||||
}
|
||||
}
|
||||
return parseArgs(text, windows);
|
||||
}
|
||||
|
||||
/** `path` as one word of a command line: quoted when it has whitespace. */
|
||||
export function quotePath(path: string): string {
|
||||
return /\s/.test(path) ? `"${path}"` : path;
|
||||
}
|
||||
|
||||
/** Inverse of `parseArgs` (quotes only when needed). */
|
||||
export function formatArgs(args: readonly string[]): string {
|
||||
return args.map((a) => (a === "" ? "''" : /[\s'"\\$`]/.test(a) ? `'${a.replace(/'/g, `'\\''`)}'` : a)).join(" ");
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
"app.update.progressUnknown": "{done} MB",
|
||||
"app.update.restart": "Restart",
|
||||
"app.update.retry": "Retry",
|
||||
"app.update.retryWith": "Retry with {source}",
|
||||
"app.window.desktopOnly": "Multiple windows require the desktop app",
|
||||
"app.window.openFailed": "Cannot open {name}",
|
||||
"app.window.pickFileTitle": "Open File",
|
||||
|
||||
@@ -36,5 +36,12 @@
|
||||
"For web requests: the XDEBUG_SESSION cookie or query parameter; point xdebug.client_host at this machine when PHP runs in a container": "For web requests: the XDEBUG_SESSION cookie or query parameter; point xdebug.client_host at this machine when PHP runs in a container",
|
||||
"e.g. /var/www/html": "e.g. /var/www/html",
|
||||
"Listening for Xdebug on port {port}": "Listening for Xdebug on port {port}",
|
||||
"Port {port} is already in use: another IDE or debug session may be listening on it. Stop it or choose another port.": "Port {port} is already in use: another IDE or debug session may be listening on it. Stop it or choose another port."
|
||||
"Port {port} is already in use: another IDE or debug session may be listening on it. Stop it or choose another port.": "Port {port} is already in use: another IDE or debug session may be listening on it. Stop it or choose another port.",
|
||||
"Unable to load image: {path}": "Unable to load image: {path}",
|
||||
"Updates": "Updates",
|
||||
"Update source": "Update source",
|
||||
"Where updates are downloaded from. If it can't be reached, the other source is tried.": "Where updates are downloaded from. If it can't be reached, the other source is tried.",
|
||||
"Automatic": "Automatic",
|
||||
"CNB (China mirror)": "CNB (China mirror)",
|
||||
"Remote connection cancelled": "Remote connection cancelled"
|
||||
}
|
||||
@@ -6376,5 +6376,11 @@
|
||||
"Force-with-lease push {source} to {target} was rejected. Force-with-lease push prevents overriding remote changes that haven't been fetched to the local repository. Fetch recent changes to verify that they can be discarded safely and repeat the push operation.": "将 {source} 以 force-with-lease 方式推送到 {target} 被拒绝。force-with-lease 推送可防止覆盖尚未获取到本地仓库的远程更改。请先获取最新更改,确认可以安全丢弃后再重新推送。",
|
||||
"Force push anyway": "仍然强制推送",
|
||||
"Stage and Retry": "暂存并重试",
|
||||
"Show Files": "显示文件"
|
||||
"Show Files": "显示文件",
|
||||
"Unable to load image: {path}": "无法加载图片:{path}",
|
||||
"Updates": "更新",
|
||||
"Update source": "更新源",
|
||||
"Where updates are downloaded from. If it can't be reached, the other source is tried.": "从哪里下载更新。无法连接时会改用另一个源。",
|
||||
"CNB (China mirror)": "CNB(国内镜像)",
|
||||
"Remote connection cancelled": "远程连接已取消"
|
||||
}
|
||||
@@ -94,6 +94,7 @@ const UNTRANSLATED = new Set<string>([
|
||||
"oxfmt",
|
||||
"Ruff",
|
||||
"IntelliJ IDEA",
|
||||
"GitHub",
|
||||
"PHPStan",
|
||||
// Docker run target placeholder values
|
||||
"compose.yaml",
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
"app.update.progressUnknown": "{done} MB",
|
||||
"app.update.restart": "重启",
|
||||
"app.update.retry": "重试",
|
||||
"app.update.retryWith": "改用 {source} 重试",
|
||||
"app.window.desktopOnly": "多窗口需要桌面应用",
|
||||
"app.window.openFailed": "无法打开 {name}",
|
||||
"app.window.pickFileTitle": "打开文件",
|
||||
|
||||
+16
-4
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user