From 2f5286a387e5fb6325e21fecccf9b65a3f2e6f2c Mon Sep 17 00:00:00 2001 From: Mojtaba Jafarzadeh Date: Mon, 26 Oct 2026 09:29:27 +0330 Subject: [PATCH 1/7] collab-net: server keeps publish uploads alive with the reply timeout A publish uploads a whole file; a large project on a slow link takes the server a while to receive and answer. But the server's one-shot request connections (manage, hosting, library, plugins) used the short request_idle as their read timeout, so a slow upload could be cut off mid-processing. The manage/hosting connections that carry uploads now wait the longer request_reply_timeout (as the client already did), keeping the short idle for the other one-shot requests and sessions. Co-Authored-By: Claude --- crates/collab-net/src/hub.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index c22029193..1471c1798 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -956,11 +956,18 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimit // Sessions and the operator may use the last connections; one-shot requests // (hosting, libraries, browsing plugins) leave them free. let session = matches!(first, ClientMsg::Hello(_) | ClientMsg::Manage(_)); + // A publish uploads a whole file; a large one on a slow link takes the server a + // while to receive and answer. The manage/hosting connections that carry uploads + // wait the longer request-reply timeout, not the short request idle. + let idle = match first { + ClientMsg::Manage(_) | ClientMsg::OpenHosting(_) => limits.request_reply_timeout, + ClientMsg::Hello(_) => limits.idle_timeout, + _ => limits.request_idle, + }; let max = match &first { ClientMsg::OpenPlugins(o) if o.key.is_empty() => limits.max_request, _ => limits.max_client_message, }; - let idle = if session { limits.idle_timeout } else { limits.request_idle }; let reserved = !session && open.0.load(Ordering::Relaxed) > limits.max_connections.saturating_sub(limits.reserve_for_sessions); // (In flight is only taken when the connection goes on: the hub gives it back.) if reserved || !shared.in_flight.take(bytes, limits.max_in_flight) { From c826c74e6ea0f1dcc2282e5f3771809a6fbd744b Mon Sep 17 00:00:00 2001 From: Mojtaba Jafarzadeh Date: Mon, 26 Oct 2026 09:29:30 +0330 Subject: [PATCH 2/7] ui: bind the share server key to its server The Share popover's server key was stored without its server, so changing the server in Preferences would send the previous server's key to the new one. Remember the key with the server it was typed for and only send it when the addresses match. A regression test covers the mismatch. Co-Authored-By: Claude --- crates/ui-egui/src/live.rs | 28 +++++++++++++++++--------- crates/ui-egui/src/live_share_tests.rs | 18 ++++++++++++++++- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs index bcc0bc77f..c4ea3ed75 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -98,10 +98,11 @@ pub struct LiveUi { pub advanced: bool, pub this_computer: bool, pub port: String, - /// The server key for online sharing (a server that needs one; empty: none). Passed to - /// `collab.share` so a keyed server can be used from the Share popover without the Publish - /// dialog. - pub share_key: String, + /// The server key for online sharing, and the server address it was entered for (a server + /// that needs one; `None`: none). Passed to `collab.share` so a keyed server can be used + /// from the Share popover without the Publish dialog. The key goes out only to the server it + /// belongs to: switching the server in Preferences must not leak the old one to it. + pub share_key: Option<(String, String)>, /// What is typed in the join field of the Share popover and the home screen. pub code: String, /// The join started from the home screen: what it is doing shows under the field, and its @@ -750,8 +751,14 @@ fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { if let (true, Ok(port)) = (local, app.ui.live.port.trim().parse::()) { params["port"] = json!(port); } - if !local && !app.ui.live.share_key.trim().is_empty() { - params["key"] = json!(app.ui.live.share_key.trim()); + // The key goes out only to the server it belongs to (the server in Preferences): changing + // the server must not send the old server's key to a new one. + if !local + && let Some((at, key)) = app.ui.live.share_key.as_ref() + && *at == server + && !key.is_empty() + { + params["key"] = json!(key); } app.run("collab.share", params); } @@ -770,12 +777,13 @@ fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { } else { widgets::label(ui, tr("Another server, or one that needs its key. The usual server is set in Preferences."), t.text_secondary); // The server key (a server that needs one; the address in Preferences is without it). + // It is remembered for the server it was typed for, so it never leaks to another one. ui.horizontal(|ui| { widgets::label(ui, tr("Server key"), t.text); - if let Some(key) = - widgets::secret_field_named(ui, egui::Id::unique("live_share_key"), &app.ui.live.share_key, 200.0, tr("Server key")) - { - app.ui.live.share_key = key.trim().chars().filter(|c| !c.is_whitespace() && !c.is_control()).take(128).collect(); + let shown = app.ui.live.share_key.as_ref().map(|(_, key)| key.as_str()).unwrap_or_default(); + if let Some(key) = widgets::secret_field_named(ui, egui::Id::unique("live_share_key"), shown, 200.0, tr("Server key")) { + let key: String = key.trim().chars().filter(|c| !c.is_whitespace() && !c.is_control()).take(128).collect(); + app.ui.live.share_key = if key.is_empty() { None } else { Some((server.trim().to_string(), key)) }; } }); if widgets::button(ui, tr("Publish to another server…"), false, true).clicked() { diff --git a/crates/ui-egui/src/live_share_tests.rs b/crates/ui-egui/src/live_share_tests.rs index 6c5bdb84d..edef3accb 100644 --- a/crates/ui-egui/src/live_share_tests.rs +++ b/crates/ui-egui/src/live_share_tests.rs @@ -237,7 +237,7 @@ fn the_server_key_shows_as_dots_while_shown_and_typed() { let (mut h, _) = harness(); let shapes = open(&mut h); click(&mut h, &shapes, "Advanced"); - h.app.ui.live.share_key = "s3cret-key".into(); + h.app.ui.live.share_key = Some(("wss://share.example/".to_string(), "s3cret-key".to_string())); let shapes = h.frames(2); let dots = "•".repeat(10); assert!(has_text(&shapes, &dots) && !has_text(&shapes, "s3cret-key")); @@ -505,6 +505,22 @@ fn the_server_preference_resets_and_turns_online_sharing_off() { assert!(s.published.is_empty() && s.hosted.len() == 1, "shared on the local network"); } +#[test] +fn a_key_typed_for_one_server_is_not_sent_to_another() { + // The Server key field remembers the server it was typed for: it goes out only to that + // server. Changing the server in Preferences must not send the old server's key to the new + // one. (The key is set as the field editor would, bound to the server it was typed for.) + let (mut h, script) = harness(); + h.app.ui.live.share_key = Some(("wss://share.example/".to_string(), "KeyKeyKeyKeyKeyKeyKey0".to_string())); + h.frames(2); + // Change the server in Preferences to another one, then share: the old key must not go to it. + h.app.session.execute("pref.publishServer", &json!({"url": "wss://other.example/"})).unwrap(); + h.frames(2); + let shapes = open(&mut h); + let shapes = click(&mut h, &shapes, "Start sharing"); + assert_eq!(script.lock().unwrap().published.last().unwrap().key, None, "the old server's key is not sent to the new server"); +} + #[test] fn an_open_popover_asks_for_no_frames() { let (mut h, script) = harness(); From 65d270dd42453a8990f23a66303f1818f60337d8 Mon Sep 17 00:00:00 2001 From: Mojtaba Jafarzadeh Date: Mon, 26 Oct 2026 10:55:38 +0330 Subject: [PATCH 3/7] ui: context menu offers "Go to main component" for instances Right-clicking an instance always showed "Create component" (and never "Go to main component"), so an imported instance looked disconnected from its main even though the link was intact. When the selection is an instance, replace "Create component" with "Go to main component" in the context menu, matching Figma. A regression test covers the menu contents for an instance. Co-Authored-By: Claude --- crates/ui-egui/src/context_menu_tests.rs | 17 +++++++++++++++++ crates/ui-egui/src/menus.rs | 8 ++++++++ 2 files changed, 25 insertions(+) diff --git a/crates/ui-egui/src/context_menu_tests.rs b/crates/ui-egui/src/context_menu_tests.rs index 61df3fdb1..a447347d7 100644 --- a/crates/ui-egui/src/context_menu_tests.rs +++ b/crates/ui-egui/src/context_menu_tests.rs @@ -55,6 +55,23 @@ fn the_context_menu_groups_the_copy_commands_under_copy_as() { assert!(u.app.ui.context_menu.is_none(), "the menu closes"); } +#[test] +fn the_context_menu_offers_go_to_main_for_an_instance() { + // Right-clicking an instance reaches its main component: the menu offers "Go to main + // component" instead of "Create component" (which is for plain layers). + let mut s = Session::new(); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 60, "name": "Button"})).unwrap()); + let comp = id(&s.execute("component.create", &json!({"ids": [f]})).unwrap()); + let inst = id(&s.execute("instance.create", &json!({"component": comp, "x": 0, "y": 200})).unwrap()); + let mut u = Ui::new(s); + u.frame(vec![]); + u.app.ui.context_menu = Some((Pos2::new(700.0, 300.0), vec![inst])); + u.frame(vec![]); + let shapes = flat(&u.frame_output(vec![]).shapes); + assert!(has_text(&shapes, "Go to main component"), "an instance's menu reaches its main"); + assert!(!has_text(&shapes, "Create component"), "not offered for an instance"); +} + #[test] fn copying_a_link_with_nothing_selected_copies_nothing() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d3647bb05..d65dd2460 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1019,6 +1019,14 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { { entries.insert(at + 1, (label, "component.combineVariants")); } + // An instance's actions reach its main component: replace "Create component" with + // "Go to main component" (as in Figma's context menu). + if app.session.doc().ok().is_some_and(|doc| { + ids.iter().any(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, figcraft_doc::NodeKind::Instance { .. }))) + }) && let Some(at) = entries.iter().position(|e| e.1 == "component.create") + { + entries[at] = (tr("Go to main component"), "component.goToMain"); + } // A right-clicked ruler guide (selected, with no layers) can be removed. if !has_sel && app.session.ds().is_ok_and(|d| d.selected_guide().is_some()) { entries.splice(0..0, [(tr("Remove guide"), "guide.remove"), ("-", "")]); From c73ff432cb33a9c99acf750391a08fa7ff8e8cfa Mon Sep 17 00:00:00 2001 From: Mojtaba Jafarzadeh Date: Mon, 26 Oct 2026 11:18:17 +0330 Subject: [PATCH 4/7] ui: rustfmt the context-menu instance check (CI gate fix) Co-Authored-By: Claude --- crates/ui-egui/src/menus.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d65dd2460..d8f5bc1b1 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1021,9 +1021,12 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { } // An instance's actions reach its main component: replace "Create component" with // "Go to main component" (as in Figma's context menu). - if app.session.doc().ok().is_some_and(|doc| { - ids.iter().any(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, figcraft_doc::NodeKind::Instance { .. }))) - }) && let Some(at) = entries.iter().position(|e| e.1 == "component.create") + if app + .session + .doc() + .ok() + .is_some_and(|doc| ids.iter().any(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, figcraft_doc::NodeKind::Instance { .. })))) + && let Some(at) = entries.iter().position(|e| e.1 == "component.create") { entries[at] = (tr("Go to main component"), "component.goToMain"); } From c9f834f13c001d774d91bcef88489d25e15a95f6 Mon Sep 17 00:00:00 2001 From: Mojtaba Jafarzadeh Date: Mon, 26 Oct 2026 11:35:35 +0330 Subject: [PATCH 5/7] ui: drop an unused variable in the key-binding test (CI gate fix) Co-Authored-By: Claude --- crates/ui-egui/src/live_share_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/live_share_tests.rs b/crates/ui-egui/src/live_share_tests.rs index edef3accb..a2ac63c2e 100644 --- a/crates/ui-egui/src/live_share_tests.rs +++ b/crates/ui-egui/src/live_share_tests.rs @@ -517,7 +517,7 @@ fn a_key_typed_for_one_server_is_not_sent_to_another() { h.app.session.execute("pref.publishServer", &json!({"url": "wss://other.example/"})).unwrap(); h.frames(2); let shapes = open(&mut h); - let shapes = click(&mut h, &shapes, "Start sharing"); + click(&mut h, &shapes, "Start sharing"); assert_eq!(script.lock().unwrap().published.last().unwrap().key, None, "the old server's key is not sent to the new server"); } From 35108cf8dcd8a4b138507be9f17b34a4d1633492 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 26 Oct 2026 23:05:39 +0700 Subject: [PATCH 6/7] #1369: the server key field shows a key only for the server it was typed for --- crates/ui-egui/src/live.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs index c4ea3ed75..e16d2cfaf 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -780,7 +780,7 @@ fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { // It is remembered for the server it was typed for, so it never leaks to another one. ui.horizontal(|ui| { widgets::label(ui, tr("Server key"), t.text); - let shown = app.ui.live.share_key.as_ref().map(|(_, key)| key.as_str()).unwrap_or_default(); + let shown = app.ui.live.share_key.as_ref().and_then(|(at, key)| (*at == server).then_some(key.as_str())).unwrap_or_default(); if let Some(key) = widgets::secret_field_named(ui, egui::Id::unique("live_share_key"), shown, 200.0, tr("Server key")) { let key: String = key.trim().chars().filter(|c| !c.is_whitespace() && !c.is_control()).take(128).collect(); app.ui.live.share_key = if key.is_empty() { None } else { Some((server.trim().to_string(), key)) }; From 8186c4da3f6657063b56b239fe51c44c53a64d84 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 26 Oct 2026 23:05:40 +0700 Subject: [PATCH 7/7] #1369: Go to main component replaces Create component only when every selected layer is an instance --- crates/ui-egui/src/menus.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d8f5bc1b1..af6de8c27 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1025,7 +1025,7 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { .session .doc() .ok() - .is_some_and(|doc| ids.iter().any(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, figcraft_doc::NodeKind::Instance { .. })))) + .is_some_and(|doc| ids.iter().all(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, figcraft_doc::NodeKind::Instance { .. })))) && let Some(at) = entries.iter().position(|e| e.1 == "component.create") { entries[at] = (tr("Go to main component"), "component.goToMain");