Preserve process camera and viewport while opening authoring tools
This commit is contained in:
@@ -103,11 +103,8 @@ impl State {
|
|||||||
changed
|
changed
|
||||||
}
|
}
|
||||||
pub(crate) fn choose(&mut self, tool: CanvasTool) {
|
pub(crate) fn choose(&mut self, tool: CanvasTool) {
|
||||||
|
// Choosing how to edit must retain the user's current view.
|
||||||
self.tool = tool;
|
self.tool = tool;
|
||||||
if tool != CanvasTool::Select && self.fit {
|
|
||||||
self.fit = false;
|
|
||||||
self.zoom = Some(1.);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
pub(crate) fn zoom(&mut self, value: Option<f32>) {
|
pub(crate) fn zoom(&mut self, value: Option<f32>) {
|
||||||
self.fit = value.is_none();
|
self.fit = value.is_none();
|
||||||
|
|||||||
@@ -968,3 +968,225 @@ fn direct_process_append_preserves_existing_order_and_rejects_incompatible_excha
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn direct_process_palette_preserves_fit_and_manual_camera() {
|
||||||
|
for layer in [Layer::Oa, Layer::Sa] {
|
||||||
|
let f = fixture(layer);
|
||||||
|
let mut state = State::default();
|
||||||
|
let document = Id::new_v4();
|
||||||
|
state.enter(document, f.owner);
|
||||||
|
state.viewport(Size::new(500., 300.));
|
||||||
|
state.scrolled(scenario::ScrollArea::Canvas, iced::Vector::new(35., 90.));
|
||||||
|
state.scrolled(scenario::ScrollArea::Page, iced::Vector::new(0., 120.));
|
||||||
|
for camera in [None, Some(0.75), Some(1.5)] {
|
||||||
|
state.zoom(camera);
|
||||||
|
let size = Size::new(690., 250.);
|
||||||
|
let before = state.scale(size, 500.);
|
||||||
|
for tool in [
|
||||||
|
CanvasTool::Add(Tool::Work, ControlKind::Choice),
|
||||||
|
CanvasTool::Add(Tool::Exchange, ControlKind::Choice),
|
||||||
|
CanvasTool::Add(Tool::Reuse, ControlKind::Choice),
|
||||||
|
CanvasTool::Add(Tool::Control, ControlKind::Choice),
|
||||||
|
CanvasTool::Add(Tool::Control, ControlKind::Fork),
|
||||||
|
CanvasTool::Connect,
|
||||||
|
CanvasTool::Select,
|
||||||
|
] {
|
||||||
|
state.choose(tool);
|
||||||
|
assert_eq!(
|
||||||
|
state.scale(size, 500.),
|
||||||
|
before,
|
||||||
|
"{layer:?} {camera:?} {tool:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(state.fit, camera.is_none());
|
||||||
|
assert_eq!(state.scroll_offset(), iced::Vector::new(35., 210.));
|
||||||
|
state.enter(document, f.owner);
|
||||||
|
assert_eq!(state.scale(size, 500.), before);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.enter(Id::new_v4(), f.owner);
|
||||||
|
assert_eq!(state.scale(Size::new(690., 250.), 500.), 1.);
|
||||||
|
assert_eq!(state.scroll_offset(), iced::Vector::ZERO);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn direct_process_pending_form_diff_retains_the_canvas_scroll_state() {
|
||||||
|
use iced::advanced::widget::{Tree, tree};
|
||||||
|
use std::rc::Rc;
|
||||||
|
fn path_to(tree: &Tree, tag: tree::Tag) -> Option<Vec<usize>> {
|
||||||
|
if tree.tag == tag {
|
||||||
|
return Some(Vec::new());
|
||||||
|
}
|
||||||
|
tree.children.iter().enumerate().find_map(|(index, child)| {
|
||||||
|
let mut path = path_to(child, tag)?;
|
||||||
|
path.insert(0, index);
|
||||||
|
Some(path)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn at<'a>(mut tree: &'a mut Tree, path: &[usize]) -> &'a mut Tree {
|
||||||
|
for &index in path {
|
||||||
|
tree = &mut tree.children[index];
|
||||||
|
}
|
||||||
|
tree
|
||||||
|
}
|
||||||
|
for layer in [Layer::Oa, Layer::Sa] {
|
||||||
|
for size in [Size::new(1440., 900.), Size::new(1100., 700.)] {
|
||||||
|
for tool in [Tool::Control, Tool::Branch, Tool::Connect] {
|
||||||
|
let f = fixture(layer);
|
||||||
|
let mut app = App::with_project(f.p.clone());
|
||||||
|
let _ = app.update(Message::Select(f.nodes[1]));
|
||||||
|
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
||||||
|
app.window_size = size;
|
||||||
|
let original = app.document.snapshot();
|
||||||
|
let graph: Element<'_, Message> =
|
||||||
|
canvas::Canvas::new(Graph::new(app.project(), f.owner, f.nodes[1])).into();
|
||||||
|
let canvas_tag = Tree::new(graph.as_widget()).tag;
|
||||||
|
drop(graph);
|
||||||
|
let scroll: Element<'_, Message> = scrollable(text("")).into();
|
||||||
|
let scroll_tag = Tree::new(scroll.as_widget()).tag;
|
||||||
|
let mut tree = Tree::new(view::view(&app, f.owner, Column::new()).as_widget());
|
||||||
|
let mut scroll_path = path_to(&tree, canvas_tag).unwrap();
|
||||||
|
scroll_path.pop();
|
||||||
|
assert_eq!(at(&mut tree, &scroll_path).tag, scroll_tag);
|
||||||
|
// Scrollable::diff treats its local state as opaque. A retained marker
|
||||||
|
// proves the real Iced diff preserves the state that owns the offsets.
|
||||||
|
// No layout or input operation runs on this test-only marker state.
|
||||||
|
let marker = Rc::new(());
|
||||||
|
at(&mut tree, &scroll_path).state = tree::State::new(Rc::clone(&marker));
|
||||||
|
let _ = app.update(Message::Behavior(super::super::Event::Topology(
|
||||||
|
Event::Start(f.owner, tool, Some(f.nodes[1])),
|
||||||
|
)));
|
||||||
|
assert!(app.behavior.has_pending());
|
||||||
|
tree.diff(view::view(&app, f.owner, Column::new()).as_widget());
|
||||||
|
assert_eq!(
|
||||||
|
Rc::strong_count(&marker),
|
||||||
|
2,
|
||||||
|
"form open: {layer:?} {size:?} {tool:?}"
|
||||||
|
);
|
||||||
|
let mut current_path = path_to(&tree, canvas_tag).unwrap();
|
||||||
|
current_path.pop();
|
||||||
|
assert_eq!(current_path, scroll_path);
|
||||||
|
assert!(Rc::ptr_eq(
|
||||||
|
at(&mut tree, &scroll_path).state.downcast_ref::<Rc<()>>(),
|
||||||
|
&marker,
|
||||||
|
));
|
||||||
|
let _ = app.update(Message::Behavior(super::super::Event::Topology(
|
||||||
|
Event::Cancel,
|
||||||
|
)));
|
||||||
|
tree.diff(view::view(&app, f.owner, Column::new()).as_widget());
|
||||||
|
assert_eq!(
|
||||||
|
Rc::strong_count(&marker),
|
||||||
|
2,
|
||||||
|
"form cancel: {layer:?} {size:?} {tool:?}"
|
||||||
|
);
|
||||||
|
assert!(std::sync::Arc::ptr_eq(&app.document.snapshot(), &original));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn direct_process_scaled_control_and_link_gestures_use_visible_positions() {
|
||||||
|
for layer in [Layer::Oa, Layer::Sa] {
|
||||||
|
let f = fixture(layer);
|
||||||
|
for scale in [0.15, 0.6898551, 1.5] {
|
||||||
|
let mut graph = graph(&f, CanvasTool::Add(Tool::Control, ControlKind::Choice));
|
||||||
|
graph.scale = scale;
|
||||||
|
graph.scroll_offset = iced::Vector::new(11., 19.);
|
||||||
|
let bounds = Rectangle::new(
|
||||||
|
Point::new(40., 70.),
|
||||||
|
Size::new(graph.size.width * scale, graph.size.height * scale),
|
||||||
|
);
|
||||||
|
let screen = |world: Point| {
|
||||||
|
Point::new(
|
||||||
|
world.x * scale + bounds.x - 11.,
|
||||||
|
world.y * scale + bounds.y - 19.,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let edge = &graph.edges[0];
|
||||||
|
let point = screen(Point::new(
|
||||||
|
(edge.points[0].x + edge.points[1].x) / 2.,
|
||||||
|
edge.points[0].y,
|
||||||
|
));
|
||||||
|
let mut input = Input::default();
|
||||||
|
send(
|
||||||
|
&graph,
|
||||||
|
&mut input,
|
||||||
|
mouse::Event::CursorMoved { position: point },
|
||||||
|
point,
|
||||||
|
bounds,
|
||||||
|
);
|
||||||
|
send(
|
||||||
|
&graph,
|
||||||
|
&mut input,
|
||||||
|
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||||
|
point,
|
||||||
|
bounds,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
send(&graph, &mut input, mouse::Event::ButtonReleased(mouse::Button::Left), point, bounds),
|
||||||
|
Some(Message::TopologyCanvas(CanvasEvent::Begin {
|
||||||
|
tool: Tool::Control, control: ControlKind::Choice,
|
||||||
|
insertion: Insertion { link: Some(id), .. }, ..
|
||||||
|
})) if id == f.link
|
||||||
|
),
|
||||||
|
"{layer:?} control at {scale}"
|
||||||
|
);
|
||||||
|
graph.interaction.as_mut().unwrap().1 = CanvasTool::Connect;
|
||||||
|
for drag in [false, true] {
|
||||||
|
let mut input = Input::default();
|
||||||
|
let a = screen(position(&graph, f.nodes[0]));
|
||||||
|
let b = screen(position(&graph, f.nodes[2]));
|
||||||
|
send(
|
||||||
|
&graph,
|
||||||
|
&mut input,
|
||||||
|
mouse::Event::CursorMoved { position: a },
|
||||||
|
a,
|
||||||
|
bounds,
|
||||||
|
);
|
||||||
|
send(
|
||||||
|
&graph,
|
||||||
|
&mut input,
|
||||||
|
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||||
|
a,
|
||||||
|
bounds,
|
||||||
|
);
|
||||||
|
if !drag {
|
||||||
|
send(
|
||||||
|
&graph,
|
||||||
|
&mut input,
|
||||||
|
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||||
|
a,
|
||||||
|
bounds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
send(
|
||||||
|
&graph,
|
||||||
|
&mut input,
|
||||||
|
mouse::Event::CursorMoved { position: b },
|
||||||
|
b,
|
||||||
|
bounds,
|
||||||
|
);
|
||||||
|
if !drag {
|
||||||
|
send(
|
||||||
|
&graph,
|
||||||
|
&mut input,
|
||||||
|
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||||
|
b,
|
||||||
|
bounds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
send(&graph, &mut input, mouse::Event::ButtonReleased(mouse::Button::Left), b, bounds),
|
||||||
|
Some(Message::TopologyCanvas(CanvasEvent::Connect { source, target, .. }))
|
||||||
|
if source == f.nodes[0] && target == f.nodes[2]
|
||||||
|
),
|
||||||
|
"{layer:?} link at {scale}, drag={drag}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -72,8 +72,11 @@ pub(super) fn view<'a>(
|
|||||||
uses_explicit_topology(p, owner) || p.elements[&owner].steps().is_none_or(Vec::is_empty);
|
uses_explicit_topology(p, owner) || p.elements[&owner].steps().is_none_or(Vec::is_empty);
|
||||||
let selected = is_contribution(p, owner, app.selected).then_some(app.selected);
|
let selected = is_contribution(p, owner, app.selected).then_some(app.selected);
|
||||||
let choose = |tool| Message::TopologyCanvas(CanvasEvent::Choose(context, tool));
|
let choose = |tool| Message::TopologyCanvas(CanvasEvent::Choose(context, tool));
|
||||||
|
// Keep the canvas at the same sibling position when its palette becomes a
|
||||||
|
// pending-form notice, so Iced retains the scrollable's local offsets.
|
||||||
|
let mut controls = Column::new().spacing(14);
|
||||||
if !pending && explicit {
|
if !pending && explicit {
|
||||||
out = out
|
controls = controls
|
||||||
.push(
|
.push(
|
||||||
row![
|
row![
|
||||||
button("Select / reorder")
|
button("Select / reorder")
|
||||||
@@ -155,10 +158,11 @@ pub(super) fn view<'a>(
|
|||||||
.spacing(6),
|
.spacing(6),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
out=out.push(label(if pending{"Pending contribution · apply or cancel below"}else if !explicit{"Ordered contributions · Append exchange continues the path"}else{match state.tool{
|
controls=controls.push(label(if pending{"Pending contribution · apply or cancel below"}else if !explicit{"Ordered contributions · Append exchange continues the path"}else{match state.tool{
|
||||||
CanvasTool::Select=>"Select local contributions and paths · drag path endpoints to reconnect · ↕ reorders within a column",
|
CanvasTool::Select=>"Select local contributions and paths · drag path endpoints to reconnect · ↕ reorders within a column",
|
||||||
CanvasTool::Connect=>"Sequence link · click or drag from preceding to following contribution · Escape cancels",
|
CanvasTool::Connect=>"Sequence link · click or drag from preceding to following contribution · Escape cancels",
|
||||||
CanvasTool::Add(..)=>"Choose a highlighted path or contribution position · existing work stays shared · Escape cancels"}}));
|
CanvasTool::Add(..)=>"Choose a highlighted path or contribution position · existing work stays shared · Escape cancels"}}));
|
||||||
|
out = out.push(controls);
|
||||||
if explicit {
|
if explicit {
|
||||||
let mut graph = Graph::expanded(p, owner, app.selected, &state.expanded);
|
let mut graph = Graph::expanded(p, owner, app.selected, &state.expanded);
|
||||||
graph.scale = state.scale(graph.size, (app.window_size.width - 600.).max(200.));
|
graph.scale = state.scale(graph.size, (app.window_size.width - 600.).max(200.));
|
||||||
@@ -345,12 +349,19 @@ pub(super) fn view<'a>(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|c| compatible_source(p, d, c.id))
|
.filter(|c| compatible_source(p, d, c.id))
|
||||||
.collect();
|
.collect();
|
||||||
|
if d.tool == Tool::Branch {
|
||||||
|
form = form.push(label("First path work"));
|
||||||
|
}
|
||||||
form = form.push(picker(
|
form = form.push(picker(
|
||||||
choices,
|
choices,
|
||||||
d,
|
d,
|
||||||
0,
|
0,
|
||||||
d.target,
|
d.target,
|
||||||
"Choose existing source…",
|
if d.tool == Tool::Branch {
|
||||||
|
"Choose first path work…"
|
||||||
|
} else {
|
||||||
|
"Choose existing source…"
|
||||||
|
},
|
||||||
Event::Target,
|
Event::Target,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -385,7 +396,7 @@ pub(super) fn view<'a>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if d.tool == Tool::Branch {
|
if d.tool == Tool::Branch {
|
||||||
form = form.push(picker(
|
form = form.push(label("Second path work")).push(picker(
|
||||||
targets(p, owner, Tool::Work),
|
targets(p, owner, Tool::Work),
|
||||||
d,
|
d,
|
||||||
1,
|
1,
|
||||||
@@ -395,11 +406,13 @@ pub(super) fn view<'a>(
|
|||||||
));
|
));
|
||||||
if d.control != ControlKind::Fork {
|
if d.control != ControlKind::Fork {
|
||||||
form = form
|
form = form
|
||||||
|
.push(label("First path guard"))
|
||||||
.push(
|
.push(
|
||||||
text_input("First path guard", &d.guard)
|
text_input("First path guard", &d.guard)
|
||||||
.on_input(|v| event(Event::Guard(v)))
|
.on_input(|v| event(Event::Guard(v)))
|
||||||
.padding(8),
|
.padding(8),
|
||||||
)
|
)
|
||||||
|
.push(label("Second path guard"))
|
||||||
.push(
|
.push(
|
||||||
text_input("Second path guard (e.g. else)", &d.alternate_guard)
|
text_input("Second path guard (e.g. else)", &d.alternate_guard)
|
||||||
.on_input(|v| event(Event::AlternateGuard(v)))
|
.on_input(|v| event(Event::AlternateGuard(v)))
|
||||||
@@ -413,6 +426,7 @@ pub(super) fn view<'a>(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
form = form
|
form = form
|
||||||
|
.push(label("Preceding contribution"))
|
||||||
.push(picker(
|
.push(picker(
|
||||||
contributions(p, owner),
|
contributions(p, owner),
|
||||||
d,
|
d,
|
||||||
@@ -422,6 +436,7 @@ pub(super) fn view<'a>(
|
|||||||
Event::Before,
|
Event::Before,
|
||||||
))
|
))
|
||||||
.push(button("Clear preceding").on_press(event(Event::ClearBefore)))
|
.push(button("Clear preceding").on_press(event(Event::ClearBefore)))
|
||||||
|
.push(label("Following contribution"))
|
||||||
.push(picker(
|
.push(picker(
|
||||||
contributions(p, owner),
|
contributions(p, owner),
|
||||||
d,
|
d,
|
||||||
|
|||||||
@@ -74,8 +74,10 @@ while forms are pending. New occurrence selection is restored after the final
|
|||||||
SetSteps command, and successful edits reveal it in the graph.
|
SetSteps command, and successful edits reveal it in the graph.
|
||||||
|
|
||||||
The 100% default and manual 50% zoom floor keep authoring legible. Explicit Fit
|
The 100% default and manual 50% zoom floor keep authoring legible. Explicit Fit
|
||||||
may produce a small overview; choosing a drawing tool leaves that overview at
|
may produce a small overview. Palette changes preserve the chosen Fit/manual
|
||||||
100%. No positions are persisted. A source-only exchange sequence retains its
|
camera; use the explicit 100% control to enlarge it. This supersedes the original
|
||||||
|
forced-100% policy after native testing showed that it hid intended endpoints.
|
||||||
|
No positions are persisted. A source-only exchange sequence retains its
|
||||||
legacy linear meaning: two or more entries have explicit Convert. The original
|
legacy linear meaning: two or more entries have explicit Convert. The original
|
||||||
one-exchange fallback offered only Work/Control/Reuse; independent review found
|
one-exchange fallback offered only Work/Control/Reuse; independent review found
|
||||||
that this blocked exchange-only continuation. The correction is recorded below.
|
that this blocked exchange-only continuation. The correction is recorded below.
|
||||||
@@ -184,3 +186,55 @@ At 1440x900 and 1100x700 on Windows and Linux, operate the actual application:
|
|||||||
No native/UI input was performed in this task. Cross-reference inner-chain linking
|
No native/UI input was performed in this task. Cross-reference inner-chain linking
|
||||||
remains an explicit unresolved semantic extension; this milestone does not claim
|
remains an explicit unresolved semantic extension; this milestone does not claim
|
||||||
full Capella functional-chain equivalence or complete the first release.
|
full Capella functional-chain equivalence or complete the first release.
|
||||||
|
|
||||||
|
## Root Linux native direct-chain trial — 2026-09-06
|
||||||
|
|
||||||
|
Frozen60d162c Wayland1440x900. Created empty SA Functional chain; Work palette and
|
||||||
|
canvas clicks involved existing Request navigation and Provide navigation. Drew
|
||||||
|
Sequence link by dragging their cards, entered ready, created it. Exchange palette
|
||||||
|
and click on that exact guarded path chose existing Navigation request. Create
|
||||||
|
inserted its occurrence between functions:69objects/7links/3expected model checks.
|
||||||
|
Fit69% reveals all three cards and guard. Undo68/6 and Redo69/7 passed; Save All
|
||||||
|
created included sa/behavior/process_new_functional_chain.arcadia.
|
||||||
|
|
||||||
|
Chain768ad41c-3bf7-4a89-8163-41963157f1b1; function occurrences a77f6697 and aa6526a7;
|
||||||
|
exchange occurrence9562fa0f-c9ff-42d1-aff5-da17ec3c827f. Original link
|
||||||
|
ead846bd-3245-44b7-b6aa-1223865f533b retains ready and now targets inserted exchange;
|
||||||
|
new link6f32045b-a598-47ae-bfe7-3f9b7f52cdf3 connects exchange to Provide navigation.
|
||||||
|
Shared function/exchange records are unchanged. Saved file was inspected; native
|
||||||
|
chain reopen is still pending. Wayland61870 closed0 normally without serious output.
|
||||||
|
OA, branch/fork/loop, reuse, reconnect/rejection, contextual names, Exchange-first
|
||||||
|
continuation, compact/Windows trials and gesture cancellation remain required.
|
||||||
|
|
||||||
|
|
||||||
|
## Preserve camera and viewport during process authoring
|
||||||
|
|
||||||
|
Root native testing exposed two independent UI defects in the combined build.
|
||||||
|
After Fit showed a three-card chain at69%, choosing a control reset scale to100%
|
||||||
|
and hid remote cards. The implementation intentionally enlarged drawing tools for
|
||||||
|
legibility, but the real scaled input path already supports editing at Fit. Tool
|
||||||
|
selection now changes only the tool; explicit camera actions and document/owner
|
||||||
|
navigation retain their existing behavior and gesture epoch invalidation.
|
||||||
|
|
||||||
|
Opening Two paths after revealing a remote OR control also reset the canvas to
|
||||||
|
the beginning without changing source/model. Its two palette rows were conditional
|
||||||
|
outer Column children. Removing them moved the canvas subtree to a different
|
||||||
|
sibling slot; Iced discarded its local scroll state. A permanent controls Column
|
||||||
|
now contains the palette and pending notice, preserving the canvas position in the
|
||||||
|
widget tree through form open/cancel. This follows the accepted scenario approach.
|
||||||
|
|
||||||
|
The compact Two paths form now has persistent First/Second path work and guard
|
||||||
|
labels, plus Preceding/Following contribution labels. Typed values no longer erase
|
||||||
|
those meanings. Existing pinned Create/Cancel controls remain in place.
|
||||||
|
|
||||||
|
Three regression tests exercise OA and SA: Fit and manual-camera preservation
|
||||||
|
across all palette tools; production Control insertion and click/drag sequence
|
||||||
|
links at15%, approximately69% and150% with nonzero canvas/scroll origins; and
|
||||||
|
actual Iced widget-tree diff through Control, Two paths and Connect form open/cancel
|
||||||
|
at1440x900 and1100x700. The latter marks the scrollable's opaque local state during
|
||||||
|
diff only, proving that the state owning offsets survives, while checking the
|
||||||
|
unchanged document snapshot. Both defect tests failed against original production
|
||||||
|
code (0.6898551→1.0 and dropped local state), then passed after correction.
|
||||||
|
|
||||||
|
The focused process suite passed15 tests. Full desktop/platform verification and
|
||||||
|
frozen runtime artifacts are still in progress. This task has used no native input.
|
||||||
|
|||||||
Reference in New Issue
Block a user