mirror of
https://github.com/langgenius/dify.git
synced 2026-05-09 12:59:18 +08:00
Merge remote-tracking branch 'upstream/main' into feat/upgrade-graphon
This commit is contained in:
commit
67e85b34e3
@ -151,6 +151,12 @@ def deserialize_response(raw_data: bytes) -> Response:
|
||||
|
||||
response = Response(response=body, status=status_code)
|
||||
|
||||
# Replace Flask's default headers (e.g. Content-Type, Content-Length) with the
|
||||
# parsed ones so we faithfully reproduce the original response. Use Headers.add
|
||||
# rather than dict-style assignment so that repeated headers such as Set-Cookie
|
||||
# (and any other multi-valued header per RFC 9110) are preserved instead of
|
||||
# being overwritten.
|
||||
response.headers.clear()
|
||||
for line in lines[1:]:
|
||||
if not line:
|
||||
continue
|
||||
@ -158,6 +164,6 @@ def deserialize_response(raw_data: bytes) -> Response:
|
||||
if ":" not in line_str:
|
||||
continue
|
||||
name, value = line_str.split(":", 1)
|
||||
response.headers[name] = value.strip()
|
||||
response.headers.add(name, value.strip())
|
||||
|
||||
return response
|
||||
|
||||
@ -365,7 +365,8 @@ class DifyNodeFactory(NodeFactory):
|
||||
(including pydantic ValidationError, which subclasses ValueError),
|
||||
if node type is unknown, or if no implementation exists for the resolved version
|
||||
"""
|
||||
typed_node_config = NodeConfigDictAdapter.validate_python(adapt_node_config_for_graph(node_config))
|
||||
adapted_node_config = adapt_node_config_for_graph(node_config)
|
||||
typed_node_config = NodeConfigDictAdapter.validate_python(adapted_node_config)
|
||||
node_id = typed_node_config["id"]
|
||||
node_data = typed_node_config["data"]
|
||||
node_class = self._resolve_node_class(node_type=node_data.type, node_version=str(node_data.version))
|
||||
@ -373,6 +374,11 @@ class DifyNodeFactory(NodeFactory):
|
||||
# Re-validate using the resolved node class so workflow-local node schemas
|
||||
# stay explicit and constructors receive the concrete typed payload.
|
||||
resolved_node_data = self._validate_resolved_node_data(node_class, node_data)
|
||||
config_for_node_init: BaseNodeData | dict[str, Any]
|
||||
if isinstance(resolved_node_data, BaseNodeData):
|
||||
config_for_node_init = resolved_node_data.model_dump(mode="python", by_alias=True)
|
||||
else:
|
||||
config_for_node_init = resolved_node_data
|
||||
node_type = node_data.type
|
||||
node_init_kwargs_factories: Mapping[NodeType, Callable[[], dict[str, object]]] = {
|
||||
BuiltinNodeTypes.CODE: lambda: {
|
||||
@ -442,7 +448,7 @@ class DifyNodeFactory(NodeFactory):
|
||||
node_init_kwargs = node_init_kwargs_factories.get(node_type, lambda: {})()
|
||||
return node_class(
|
||||
node_id=node_id,
|
||||
data=resolved_node_data,
|
||||
data=config_for_node_init,
|
||||
graph_init_params=self.graph_init_params,
|
||||
graph_runtime_state=self.graph_runtime_state,
|
||||
**node_init_kwargs,
|
||||
@ -474,10 +480,7 @@ class DifyNodeFactory(NodeFactory):
|
||||
include_retriever_attachment_loader: bool,
|
||||
include_jinja2_template_renderer: bool,
|
||||
) -> dict[str, object]:
|
||||
validated_node_data = cast(
|
||||
LLMCompatibleNodeData,
|
||||
self._validate_resolved_node_data(node_class=node_class, node_data=node_data),
|
||||
)
|
||||
validated_node_data = cast(LLMCompatibleNodeData, node_data)
|
||||
model_instance = self._build_model_instance_for_llm_node(validated_node_data)
|
||||
node_init_kwargs: dict[str, object] = {
|
||||
"credentials_provider": self._llm_credentials_provider,
|
||||
|
||||
@ -323,6 +323,50 @@ class TestDeserializeResponse:
|
||||
with pytest.raises(ValueError, match="Invalid status line"):
|
||||
deserialize_response(raw_data)
|
||||
|
||||
def test_deserialize_response_preserves_duplicate_set_cookie_headers(self):
|
||||
# Regression test for https://github.com/langgenius/dify/issues/35722
|
||||
# Multiple Set-Cookie headers must be preserved per RFC 9110, not collapsed
|
||||
# into a single value by dict-style assignment.
|
||||
raw_data = (
|
||||
b"HTTP/1.1 200 OK\r\n"
|
||||
b"Content-Type: text/plain\r\n"
|
||||
b"Set-Cookie: session=abc; Path=/; HttpOnly\r\n"
|
||||
b"Set-Cookie: tracking=xyz; Path=/; Secure\r\n"
|
||||
b"\r\n"
|
||||
b"ok"
|
||||
)
|
||||
|
||||
response = deserialize_response(raw_data)
|
||||
|
||||
cookies = response.headers.getlist("Set-Cookie")
|
||||
assert cookies == [
|
||||
"session=abc; Path=/; HttpOnly",
|
||||
"tracking=xyz; Path=/; Secure",
|
||||
]
|
||||
# Single-valued headers should still be readable normally.
|
||||
assert response.headers.get("Content-Type") == "text/plain"
|
||||
|
||||
def test_deserialize_response_preserves_duplicate_generic_headers(self):
|
||||
# Any header name (not just Set-Cookie) may legitimately repeat; verify the
|
||||
# parser preserves all values rather than overwriting earlier ones.
|
||||
raw_data = b"HTTP/1.1 200 OK\r\nX-Custom: first\r\nX-Custom: second\r\n\r\n"
|
||||
|
||||
response = deserialize_response(raw_data)
|
||||
|
||||
assert response.headers.getlist("X-Custom") == ["first", "second"]
|
||||
|
||||
def test_deserialize_response_does_not_inject_default_content_type(self):
|
||||
# Flask's Response constructor adds a default Content-Type header. When the
|
||||
# raw response has no Content-Type, the parsed response should not silently
|
||||
# gain one from the framework default.
|
||||
raw_data = b"HTTP/1.1 204 No Content\r\nX-Trace-Id: abc\r\n\r\n"
|
||||
|
||||
response = deserialize_response(raw_data)
|
||||
|
||||
header_names = [name for name, _ in response.headers.items()]
|
||||
assert "Content-Type" not in header_names
|
||||
assert response.headers.get("X-Trace-Id") == "abc"
|
||||
|
||||
def test_roundtrip_response(self):
|
||||
# Test that serialize -> deserialize produces equivalent response
|
||||
original_response = Response(
|
||||
|
||||
@ -5,6 +5,7 @@ from graphon.graph_events import (
|
||||
NodeRunStreamChunkEvent,
|
||||
)
|
||||
|
||||
from .test_mock_config import MockConfigBuilder
|
||||
from .test_table_runner import TableTestRunner
|
||||
|
||||
|
||||
@ -44,3 +45,51 @@ def test_tool_in_chatflow():
|
||||
assert stream_chunk_events[0].chunk == "hello, dify!", (
|
||||
f"Expected chunk to be 'hello, dify!', but got {stream_chunk_events[0].chunk}"
|
||||
)
|
||||
|
||||
|
||||
def test_answer_can_render_llm_structured_output_in_chatflow():
|
||||
runner = TableTestRunner()
|
||||
|
||||
fixture_data = runner.workflow_runner.load_fixture("basic_chatflow")
|
||||
nodes = fixture_data["workflow"]["graph"]["nodes"]
|
||||
answer_node = next(node for node in nodes if node["id"] == "answer")
|
||||
answer_node["data"]["answer"] = "{{#llm.structured_output#}}"
|
||||
|
||||
mock_config = (
|
||||
MockConfigBuilder()
|
||||
.with_node_output(
|
||||
"llm",
|
||||
{
|
||||
"text": "plain text",
|
||||
"structured_output": {"type": "greeting"},
|
||||
"usage": {
|
||||
"prompt_tokens": 10,
|
||||
"completion_tokens": 5,
|
||||
"total_tokens": 15,
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
},
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
graph, graph_runtime_state = runner.workflow_runner.create_graph_from_fixture(
|
||||
fixture_data=fixture_data,
|
||||
query="hello",
|
||||
use_mock_factory=True,
|
||||
mock_config=mock_config,
|
||||
)
|
||||
|
||||
engine = GraphEngine(
|
||||
workflow_id="test_workflow",
|
||||
graph=graph,
|
||||
graph_runtime_state=graph_runtime_state,
|
||||
command_channel=InMemoryChannel(),
|
||||
config=GraphEngineConfig(),
|
||||
)
|
||||
|
||||
events = list(engine.run())
|
||||
success_events = [e for e in events if isinstance(e, GraphRunSucceededEvent)]
|
||||
|
||||
assert success_events, "Workflow should complete successfully"
|
||||
assert success_events[-1].outputs["answer"] == '{\n "type": "greeting"\n}'
|
||||
|
||||
@ -86,3 +86,80 @@ def test_execute_answer():
|
||||
|
||||
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
|
||||
assert result.outputs["answer"] == "Today's weather is sunny\nYou are a helpful AI.\n{{img}}\nFin."
|
||||
|
||||
|
||||
def test_execute_answer_renders_structured_output_object_as_json() -> None:
|
||||
init_params = build_test_graph_init_params(
|
||||
workflow_id="1",
|
||||
graph_config={"nodes": [], "edges": []},
|
||||
tenant_id="1",
|
||||
app_id="1",
|
||||
user_id="1",
|
||||
user_from=UserFrom.ACCOUNT,
|
||||
invoke_from=InvokeFrom.DEBUGGER,
|
||||
call_depth=0,
|
||||
)
|
||||
|
||||
variable_pool = VariablePool(
|
||||
system_variables=build_system_variables(user_id="aaa", files=[]),
|
||||
user_inputs={},
|
||||
environment_variables=[],
|
||||
conversation_variables=[],
|
||||
)
|
||||
variable_pool.add(["1777539038857", "structured_output"], {"type": "greeting"})
|
||||
|
||||
graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
|
||||
|
||||
node = AnswerNode(
|
||||
node_id=str(uuid.uuid4()),
|
||||
graph_init_params=init_params,
|
||||
graph_runtime_state=graph_runtime_state,
|
||||
config=AnswerNodeData(
|
||||
title="123",
|
||||
type="answer",
|
||||
answer="{{#1777539038857.structured_output#}}",
|
||||
),
|
||||
)
|
||||
|
||||
result = node._run()
|
||||
|
||||
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
|
||||
assert result.outputs["answer"] == '{\n "type": "greeting"\n}'
|
||||
|
||||
|
||||
def test_execute_answer_falls_back_to_plain_selector_text_when_structured_output_missing() -> None:
|
||||
init_params = build_test_graph_init_params(
|
||||
workflow_id="1",
|
||||
graph_config={"nodes": [], "edges": []},
|
||||
tenant_id="1",
|
||||
app_id="1",
|
||||
user_id="1",
|
||||
user_from=UserFrom.ACCOUNT,
|
||||
invoke_from=InvokeFrom.DEBUGGER,
|
||||
call_depth=0,
|
||||
)
|
||||
|
||||
variable_pool = VariablePool(
|
||||
system_variables=build_system_variables(user_id="aaa", files=[]),
|
||||
user_inputs={},
|
||||
environment_variables=[],
|
||||
conversation_variables=[],
|
||||
)
|
||||
|
||||
graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
|
||||
|
||||
node = AnswerNode(
|
||||
node_id=str(uuid.uuid4()),
|
||||
graph_init_params=init_params,
|
||||
graph_runtime_state=graph_runtime_state,
|
||||
config=AnswerNodeData(
|
||||
title="123",
|
||||
type="answer",
|
||||
answer="{{#1777539038857.structured_output#}}",
|
||||
),
|
||||
)
|
||||
|
||||
result = node._run()
|
||||
|
||||
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
|
||||
assert result.outputs["answer"] == "1777539038857.structured_output"
|
||||
|
||||
@ -10,14 +10,20 @@ from core.workflow.nodes.knowledge_index import KNOWLEDGE_INDEX_NODE_TYPE
|
||||
from graphon.entities.base_node_data import BaseNodeData
|
||||
from graphon.enums import BuiltinNodeTypes, NodeType
|
||||
from graphon.nodes.code.entities import CodeLanguage
|
||||
from graphon.nodes.llm.entities import LLMNodeData
|
||||
from graphon.variables.segments import StringSegment
|
||||
|
||||
|
||||
def _assert_typed_node_config(config, *, node_id: str, node_type: NodeType, version: str = "1") -> None:
|
||||
_ = node_id
|
||||
assert isinstance(config, BaseNodeData)
|
||||
assert config.type == node_type
|
||||
assert config.version == version
|
||||
if isinstance(config, BaseNodeData):
|
||||
assert config.type == node_type
|
||||
assert config.version == version
|
||||
return
|
||||
|
||||
assert isinstance(config, dict)
|
||||
assert config["type"] == node_type
|
||||
assert config["version"] == version
|
||||
|
||||
|
||||
def _node_constructor(*, return_value):
|
||||
@ -546,6 +552,84 @@ class TestDifyNodeFactoryCreateNode:
|
||||
assert kwargs["unstructured_api_config"] is sentinel.unstructured_api_config
|
||||
assert kwargs["http_client"] is sentinel.http_client
|
||||
|
||||
def test_build_llm_compatible_node_init_kwargs_preserves_structured_output_switch(self, factory):
|
||||
node_data = LLMNodeData.model_validate(
|
||||
{
|
||||
"type": BuiltinNodeTypes.LLM,
|
||||
"title": "LLM",
|
||||
"model": {"provider": "provider", "name": "model", "mode": "chat", "completion_params": {}},
|
||||
"prompt_template": [{"role": "system", "text": "x"}],
|
||||
"context": {"enabled": False, "variable_selector": []},
|
||||
"vision": {"enabled": False},
|
||||
"structured_output_enabled": True,
|
||||
"structured_output": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"type": {"type": "string"}},
|
||||
"required": ["type"],
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
wrapped_model_instance = sentinel.wrapped_model_instance
|
||||
memory = sentinel.memory
|
||||
factory._build_model_instance_for_llm_node = MagicMock(return_value=sentinel.model_instance)
|
||||
factory._build_memory_for_llm_node = MagicMock(return_value=memory)
|
||||
with patch.object(node_factory, "DifyPreparedLLM", return_value=wrapped_model_instance) as prepared_llm:
|
||||
kwargs = factory._build_llm_compatible_node_init_kwargs(
|
||||
node_class=sentinel.node_class,
|
||||
node_data=node_data,
|
||||
wrap_model_instance=True,
|
||||
include_http_client=True,
|
||||
include_llm_file_saver=True,
|
||||
include_prompt_message_serializer=True,
|
||||
include_retriever_attachment_loader=True,
|
||||
include_jinja2_template_renderer=True,
|
||||
)
|
||||
|
||||
assert node_data.structured_output_switch_on is True
|
||||
assert node_data.structured_output_enabled is True
|
||||
factory._build_model_instance_for_llm_node.assert_called_once_with(node_data)
|
||||
factory._build_memory_for_llm_node.assert_called_once_with(
|
||||
node_data=node_data,
|
||||
model_instance=sentinel.model_instance,
|
||||
)
|
||||
prepared_llm.assert_called_once_with(sentinel.model_instance)
|
||||
assert kwargs["model_instance"] is wrapped_model_instance
|
||||
|
||||
def test_create_node_passes_alias_preserving_llm_config_to_constructor(self, monkeypatch, factory):
|
||||
created_node = object()
|
||||
constructor = _node_constructor(return_value=created_node)
|
||||
monkeypatch.setattr(factory, "_resolve_node_class", MagicMock(return_value=constructor))
|
||||
monkeypatch.setattr(factory, "_build_llm_compatible_node_init_kwargs", MagicMock(return_value={}))
|
||||
|
||||
node_config = {
|
||||
"id": "llm-node-id",
|
||||
"data": {
|
||||
"type": BuiltinNodeTypes.LLM,
|
||||
"title": "LLM",
|
||||
"model": {"provider": "provider", "name": "model", "mode": "chat", "completion_params": {}},
|
||||
"prompt_template": [{"role": "system", "text": "x"}],
|
||||
"context": {"enabled": False, "variable_selector": []},
|
||||
"vision": {"enabled": False},
|
||||
"structured_output_enabled": True,
|
||||
"structured_output": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"type": {"type": "string"}},
|
||||
"required": ["type"],
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
factory.create_node(node_config)
|
||||
|
||||
config = constructor.call_args.kwargs["config"]
|
||||
assert isinstance(config, dict)
|
||||
assert config["structured_output_enabled"] is True
|
||||
assert "structured_output_switch_on" not in config
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("node_type", "constructor_name", "expected_extra_kwargs"),
|
||||
[
|
||||
|
||||
@ -1744,11 +1744,6 @@
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/prompt-editor/plugins/component-picker-block/menu.tsx": {
|
||||
"erasable-syntax-only/parameter-properties": {
|
||||
"count": 1
|
||||
@ -3769,11 +3764,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/header/version-history-button.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/hooks-store/index.ts": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 2
|
||||
@ -3796,7 +3786,7 @@
|
||||
},
|
||||
"web/app/components/workflow/hooks/index.ts": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 27
|
||||
"count": 26
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/hooks/use-checklist.ts": {
|
||||
@ -4994,11 +4984,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/operator/tip-popup.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/operator/zoom-in-out.tsx": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
@ -5338,11 +5323,6 @@
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/workflow-history-store.tsx": {
|
||||
"react-refresh/only-export-components": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/workflow-preview/components/nodes/base.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
||||
221
pnpm-lock.yaml
generated
221
pnpm-lock.yaml
generated
@ -48,6 +48,9 @@ catalogs:
|
||||
'@heroicons/react':
|
||||
specifier: 2.2.0
|
||||
version: 2.2.0
|
||||
'@hey-api/openapi-ts':
|
||||
specifier: 0.97.0
|
||||
version: 0.97.0
|
||||
'@hono/node-server':
|
||||
specifier: 2.0.0
|
||||
version: 2.0.0
|
||||
@ -168,6 +171,9 @@ catalogs:
|
||||
'@tanstack/react-form-devtools':
|
||||
specifier: 0.2.22
|
||||
version: 0.2.22
|
||||
'@tanstack/react-hotkeys':
|
||||
specifier: 0.10.0
|
||||
version: 0.10.0
|
||||
'@tanstack/react-query':
|
||||
specifier: 5.100.6
|
||||
version: 5.100.6
|
||||
@ -881,6 +887,9 @@ importers:
|
||||
'@tanstack/react-form':
|
||||
specifier: 'catalog:'
|
||||
version: 1.29.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
'@tanstack/react-hotkeys':
|
||||
specifier: 'catalog:'
|
||||
version: 0.10.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
'@tanstack/react-query':
|
||||
specifier: 'catalog:'
|
||||
version: 5.100.6(react@19.2.5)
|
||||
@ -1125,6 +1134,9 @@ importers:
|
||||
'@eslint-react/eslint-plugin':
|
||||
specifier: 'catalog:'
|
||||
version: 3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3)
|
||||
'@hey-api/openapi-ts':
|
||||
specifier: 'catalog:'
|
||||
version: 0.97.0(magicast@0.5.2)(typescript@6.0.3)
|
||||
'@hono/node-server':
|
||||
specifier: 'catalog:'
|
||||
version: 2.0.0(hono@4.12.15)
|
||||
@ -2072,6 +2084,31 @@ packages:
|
||||
peerDependencies:
|
||||
react: '>= 16 || ^19.0.0-rc'
|
||||
|
||||
'@hey-api/codegen-core@0.8.1':
|
||||
resolution: {integrity: sha512-Iciv2vUCJTW9lWM/ROvyZLblmcbYJHPuXfzb1SzeDVVn4xEXu2ilLU1pq3fn+09FZ/Y0P7VyvRE47UDU6om8xA==}
|
||||
engines: {node: '>=22.13.0'}
|
||||
|
||||
'@hey-api/json-schema-ref-parser@1.4.1':
|
||||
resolution: {integrity: sha512-DoPJGxVApDlktP1yYLjmOrF0YBEqb32ieCbx1S1i09n8TyCgdoh4yQaQ3kp0sMTauH+bwNKPsFh7S8qiWCoKZA==}
|
||||
engines: {node: '>=22.13.0'}
|
||||
|
||||
'@hey-api/openapi-ts@0.97.0':
|
||||
resolution: {integrity: sha512-WZkKgrDlZpxKlDv2HkBCzaAYeuM+EtZKFmKGBv9/JblAKpX3JQTROi7PzlCZE3eisetRPSakbcRgn+LGyB7EiQ==}
|
||||
engines: {node: '>=22.13.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
typescript: '>=5.5.3 || >=6.0.0 || 6.0.1-rc'
|
||||
|
||||
'@hey-api/shared@0.4.2':
|
||||
resolution: {integrity: sha512-4fconS10E0Xr4/acV8G+BkApxaIStxrT0GhB9BDTQWvrFTy5/nV933SyFk8qImcbpKvgv9hpn3N+7bV8oFrbjA==}
|
||||
engines: {node: '>=22.13.0'}
|
||||
|
||||
'@hey-api/spec-types@0.2.0':
|
||||
resolution: {integrity: sha512-ibQ8Is7evMavzr8GNyJCcTg975d8DpaMUyLmOrQ85UBdy1l6t1KuRAwgChAbesJsIlNV6gjmlXruWyegDX18Fg==}
|
||||
|
||||
'@hey-api/types@0.1.4':
|
||||
resolution: {integrity: sha512-thWfawrDIP7wSI9ioT13I5soaaqB5vAPIiZmgD8PbeEVKNrkonc0N/Sjj97ezl7oQgusZmaNphGdMKipPO6IBg==}
|
||||
|
||||
'@hono/node-server@2.0.0':
|
||||
resolution: {integrity: sha512-n3GfHwwCvHCkGmOwKfxUPOlbfzuO64Sbc5XC4NGPIXxkuOnJrdgExdRKmHfF924r914WRJPT397GdqLvdYTeyQ==}
|
||||
engines: {node: '>=20'}
|
||||
@ -2294,6 +2331,9 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@jsdevtools/ono@7.1.3':
|
||||
resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==}
|
||||
|
||||
'@lexical/clipboard@0.44.0':
|
||||
resolution: {integrity: sha512-nfmNIs7uENqlDI7cm2E4I1Yp8mDJGMhEQIrIV2rNWnL1oeHVXQ7yuYdyoPdcY1zuj/9nvkYBQYUEh0QiGwpETA==}
|
||||
|
||||
@ -2369,6 +2409,10 @@ packages:
|
||||
peerDependencies:
|
||||
yjs: '>=13.5.22'
|
||||
|
||||
'@lukeed/ms@2.0.2':
|
||||
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
'@mdx-js/loader@3.1.1':
|
||||
resolution: {integrity: sha512-0TTacJyZ9mDmY+VefuthVshaNIyCGZHJG2fMnGaDttCt8HmjUF7SizlHJpaCDoGnN635nK1wpzfpx/Xx5S4WnQ==}
|
||||
peerDependencies:
|
||||
@ -3801,6 +3845,10 @@ packages:
|
||||
peerDependencies:
|
||||
solid-js: 1.9.11
|
||||
|
||||
'@tanstack/hotkeys@0.8.0':
|
||||
resolution: {integrity: sha512-vqH7X9nb0MTJ/O08++dB5bP9jgj4+BIPOUu/U+6myG86lDsirZSVSobpq5UQpE7nBuk62i8eIYeOhd+OMl/UrA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@tanstack/pacer-lite@0.1.1':
|
||||
resolution: {integrity: sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==}
|
||||
engines: {node: '>=18'}
|
||||
@ -3834,6 +3882,13 @@ packages:
|
||||
'@tanstack/react-start':
|
||||
optional: true
|
||||
|
||||
'@tanstack/react-hotkeys@0.10.0':
|
||||
resolution: {integrity: sha512-GwOSndI5j3qBVYTmgP1mYyRTnlxb2MS17cwGlsavSxMQPSnmDf+m3LzMIpRMs+3zzQMjg3cYhHsFYizYlFI2tw==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: '>=16.8'
|
||||
react-dom: '>=16.8'
|
||||
|
||||
'@tanstack/react-query-devtools@5.100.6':
|
||||
resolution: {integrity: sha512-sz3ksMKA2t1rx0+Odzb0x1A3pXH/SVf7fzlzd3sKXzwXz8980f5sbOwfQD6+UfTG8G4Y2KaIg9e3sBn+uC4VTg==}
|
||||
peerDependencies:
|
||||
@ -3845,6 +3900,12 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^18 || ^19
|
||||
|
||||
'@tanstack/react-store@0.11.0':
|
||||
resolution: {integrity: sha512-tX4YXh3PDkmpvGQWkWqKpzs/MSqbtuwY9dWdWhtV9Q50PmO+jOkUKIWIX4G85dwt7lxdHLXsiaEKPdKmC8F41w==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tanstack/react-store@0.9.3':
|
||||
resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==}
|
||||
peerDependencies:
|
||||
@ -3857,6 +3918,9 @@ packages:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tanstack/store@0.11.0':
|
||||
resolution: {integrity: sha512-WlzzCt3xi0G6pCAJu1U+2jiECwabETDpQDi3hfkFZvJii9AuZqEKbOiVarX1/bWhTNjU486yQtJCCasi/0q+Cw==}
|
||||
|
||||
'@tanstack/store@0.9.3':
|
||||
resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==}
|
||||
|
||||
@ -4653,6 +4717,10 @@ packages:
|
||||
ajv@6.14.0:
|
||||
resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==}
|
||||
|
||||
ansi-colors@4.1.3:
|
||||
resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
ansi-regex@4.1.1:
|
||||
resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==}
|
||||
engines: {node: '>=6'}
|
||||
@ -4792,6 +4860,14 @@ packages:
|
||||
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
c12@3.3.4:
|
||||
resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==}
|
||||
peerDependencies:
|
||||
magicast: '*'
|
||||
peerDependenciesMeta:
|
||||
magicast:
|
||||
optional: true
|
||||
|
||||
cac@7.0.0:
|
||||
resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
@ -4858,6 +4934,10 @@ packages:
|
||||
chevrotain@11.1.2:
|
||||
resolution: {integrity: sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==}
|
||||
|
||||
chokidar@5.0.0:
|
||||
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
chownr@1.1.4:
|
||||
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
|
||||
|
||||
@ -4931,6 +5011,10 @@ packages:
|
||||
color-name@1.1.4:
|
||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||
|
||||
color-support@1.1.3:
|
||||
resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==}
|
||||
hasBin: true
|
||||
|
||||
comma-separated-tokens@2.0.3:
|
||||
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
|
||||
|
||||
@ -5250,6 +5334,9 @@ packages:
|
||||
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
defu@6.1.7:
|
||||
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
|
||||
|
||||
delaunator@5.1.0:
|
||||
resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==}
|
||||
|
||||
@ -5257,6 +5344,9 @@ packages:
|
||||
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
destr@2.0.5:
|
||||
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
|
||||
|
||||
detect-libc@2.1.2:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
@ -5309,6 +5399,10 @@ packages:
|
||||
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
dotenv@17.4.2:
|
||||
resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
echarts-for-react@3.0.6:
|
||||
resolution: {integrity: sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==}
|
||||
peerDependencies:
|
||||
@ -5893,6 +5987,10 @@ packages:
|
||||
get-tsconfig@4.14.0:
|
||||
resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==}
|
||||
|
||||
giget@3.2.0:
|
||||
resolution: {integrity: sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==}
|
||||
hasBin: true
|
||||
|
||||
github-from-package@0.0.0:
|
||||
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
|
||||
|
||||
@ -7137,6 +7235,9 @@ packages:
|
||||
resolution: {integrity: sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA==}
|
||||
engines: {node: '>=14.18.0'}
|
||||
|
||||
rc9@3.0.1:
|
||||
resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==}
|
||||
|
||||
rc@1.2.8:
|
||||
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
|
||||
hasBin: true
|
||||
@ -7310,6 +7411,10 @@ packages:
|
||||
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
readdirp@5.0.0:
|
||||
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
recast@0.23.11:
|
||||
resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==}
|
||||
engines: {node: '>= 4'}
|
||||
@ -9332,6 +9437,56 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.2.5
|
||||
|
||||
'@hey-api/codegen-core@0.8.1(magicast@0.5.2)':
|
||||
dependencies:
|
||||
'@hey-api/types': 0.1.4
|
||||
ansi-colors: 4.1.3
|
||||
c12: 3.3.4(magicast@0.5.2)
|
||||
color-support: 1.1.3
|
||||
transitivePeerDependencies:
|
||||
- magicast
|
||||
|
||||
'@hey-api/json-schema-ref-parser@1.4.1':
|
||||
dependencies:
|
||||
'@jsdevtools/ono': 7.1.3
|
||||
'@types/json-schema': 7.0.15
|
||||
yaml: 2.8.3
|
||||
|
||||
'@hey-api/openapi-ts@0.97.0(magicast@0.5.2)(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@hey-api/codegen-core': 0.8.1(magicast@0.5.2)
|
||||
'@hey-api/json-schema-ref-parser': 1.4.1
|
||||
'@hey-api/shared': 0.4.2(magicast@0.5.2)
|
||||
'@hey-api/spec-types': 0.2.0
|
||||
'@hey-api/types': 0.1.4
|
||||
'@lukeed/ms': 2.0.2
|
||||
ansi-colors: 4.1.3
|
||||
color-support: 1.1.3
|
||||
commander: 14.0.3
|
||||
get-tsconfig: 4.14.0
|
||||
typescript: 6.0.3
|
||||
transitivePeerDependencies:
|
||||
- magicast
|
||||
|
||||
'@hey-api/shared@0.4.2(magicast@0.5.2)':
|
||||
dependencies:
|
||||
'@hey-api/codegen-core': 0.8.1(magicast@0.5.2)
|
||||
'@hey-api/json-schema-ref-parser': 1.4.1
|
||||
'@hey-api/spec-types': 0.2.0
|
||||
'@hey-api/types': 0.1.4
|
||||
ansi-colors: 4.1.3
|
||||
cross-spawn: 7.0.6
|
||||
open: 11.0.0
|
||||
semver: 7.7.4
|
||||
transitivePeerDependencies:
|
||||
- magicast
|
||||
|
||||
'@hey-api/spec-types@0.2.0':
|
||||
dependencies:
|
||||
'@hey-api/types': 0.1.4
|
||||
|
||||
'@hey-api/types@0.1.4': {}
|
||||
|
||||
'@hono/node-server@2.0.0(hono@4.12.15)':
|
||||
dependencies:
|
||||
hono: 4.12.15
|
||||
@ -9517,6 +9672,8 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@jsdevtools/ono@7.1.3': {}
|
||||
|
||||
'@lexical/clipboard@0.44.0':
|
||||
dependencies:
|
||||
'@lexical/extension': 0.44.0
|
||||
@ -9669,6 +9826,8 @@ snapshots:
|
||||
'@lexical/selection': 0.44.0
|
||||
lexical: 0.44.0
|
||||
|
||||
'@lukeed/ms@2.0.2': {}
|
||||
|
||||
'@mdx-js/loader@3.1.1':
|
||||
dependencies:
|
||||
'@mdx-js/mdx': 3.1.1
|
||||
@ -10890,6 +11049,10 @@ snapshots:
|
||||
- react
|
||||
- vue
|
||||
|
||||
'@tanstack/hotkeys@0.8.0':
|
||||
dependencies:
|
||||
'@tanstack/store': 0.11.0
|
||||
|
||||
'@tanstack/pacer-lite@0.1.1': {}
|
||||
|
||||
'@tanstack/query-core@5.100.6': {}
|
||||
@ -10928,6 +11091,13 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- react-dom
|
||||
|
||||
'@tanstack/react-hotkeys@0.10.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
|
||||
dependencies:
|
||||
'@tanstack/hotkeys': 0.8.0
|
||||
'@tanstack/react-store': 0.11.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
react: 19.2.5
|
||||
react-dom: 19.2.5(react@19.2.5)
|
||||
|
||||
'@tanstack/react-query-devtools@5.100.6(@tanstack/react-query@5.100.6(react@19.2.5))(react@19.2.5)':
|
||||
dependencies:
|
||||
'@tanstack/query-devtools': 5.100.6
|
||||
@ -10939,6 +11109,13 @@ snapshots:
|
||||
'@tanstack/query-core': 5.100.6
|
||||
react: 19.2.5
|
||||
|
||||
'@tanstack/react-store@0.11.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
|
||||
dependencies:
|
||||
'@tanstack/store': 0.11.0
|
||||
react: 19.2.5
|
||||
react-dom: 19.2.5(react@19.2.5)
|
||||
use-sync-external-store: 1.6.0(react@19.2.5)
|
||||
|
||||
'@tanstack/react-store@0.9.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
|
||||
dependencies:
|
||||
'@tanstack/store': 0.9.3
|
||||
@ -10952,6 +11129,8 @@ snapshots:
|
||||
react: 19.2.5
|
||||
react-dom: 19.2.5(react@19.2.5)
|
||||
|
||||
'@tanstack/store@0.11.0': {}
|
||||
|
||||
'@tanstack/store@0.9.3': {}
|
||||
|
||||
'@tanstack/virtual-core@3.14.0': {}
|
||||
@ -11863,6 +12042,8 @@ snapshots:
|
||||
json-schema-traverse: 0.4.1
|
||||
uri-js: 4.4.1
|
||||
|
||||
ansi-colors@4.1.3: {}
|
||||
|
||||
ansi-regex@4.1.1: {}
|
||||
|
||||
ansi-regex@5.0.1: {}
|
||||
@ -11977,6 +12158,23 @@ snapshots:
|
||||
|
||||
bytes@3.1.2: {}
|
||||
|
||||
c12@3.3.4(magicast@0.5.2):
|
||||
dependencies:
|
||||
chokidar: 5.0.0
|
||||
confbox: 0.2.4
|
||||
defu: 6.1.7
|
||||
dotenv: 17.4.2
|
||||
exsolve: 1.0.8
|
||||
giget: 3.2.0
|
||||
jiti: 2.6.1
|
||||
ohash: 2.0.11
|
||||
pathe: 2.0.3
|
||||
perfect-debounce: 2.1.0
|
||||
pkg-types: 2.3.0
|
||||
rc9: 3.0.1
|
||||
optionalDependencies:
|
||||
magicast: 0.5.2
|
||||
|
||||
cac@7.0.0: {}
|
||||
|
||||
camelize@1.0.1: {}
|
||||
@ -12064,6 +12262,10 @@ snapshots:
|
||||
'@chevrotain/utils': 11.1.2
|
||||
lodash-es: 4.18.0
|
||||
|
||||
chokidar@5.0.0:
|
||||
dependencies:
|
||||
readdirp: 5.0.0
|
||||
|
||||
chownr@1.1.4:
|
||||
optional: true
|
||||
|
||||
@ -12135,6 +12337,8 @@ snapshots:
|
||||
|
||||
color-name@1.1.4: {}
|
||||
|
||||
color-support@1.1.3: {}
|
||||
|
||||
comma-separated-tokens@2.0.3: {}
|
||||
|
||||
commander@14.0.0: {}
|
||||
@ -12457,12 +12661,16 @@ snapshots:
|
||||
|
||||
define-lazy-prop@3.0.0: {}
|
||||
|
||||
defu@6.1.7: {}
|
||||
|
||||
delaunator@5.1.0:
|
||||
dependencies:
|
||||
robust-predicates: 3.0.3
|
||||
|
||||
dequal@2.0.3: {}
|
||||
|
||||
destr@2.0.5: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
detect-node-es@1.1.0: {}
|
||||
@ -12511,6 +12719,8 @@ snapshots:
|
||||
|
||||
dotenv@16.6.1: {}
|
||||
|
||||
dotenv@17.4.2: {}
|
||||
|
||||
echarts-for-react@3.0.6(echarts@6.0.0)(react@19.2.5):
|
||||
dependencies:
|
||||
echarts: 6.0.0
|
||||
@ -13274,6 +13484,8 @@ snapshots:
|
||||
dependencies:
|
||||
resolve-pkg-maps: 1.0.0
|
||||
|
||||
giget@3.2.0: {}
|
||||
|
||||
github-from-package@0.0.0:
|
||||
optional: true
|
||||
|
||||
@ -14928,6 +15140,11 @@ snapshots:
|
||||
|
||||
radash@12.1.1: {}
|
||||
|
||||
rc9@3.0.1:
|
||||
dependencies:
|
||||
defu: 6.1.7
|
||||
destr: 2.0.5
|
||||
|
||||
rc@1.2.8:
|
||||
dependencies:
|
||||
deep-extend: 0.6.0
|
||||
@ -15126,6 +15343,8 @@ snapshots:
|
||||
util-deprecate: 1.0.2
|
||||
optional: true
|
||||
|
||||
readdirp@5.0.0: {}
|
||||
|
||||
recast@0.23.11:
|
||||
dependencies:
|
||||
ast-types: 0.16.1
|
||||
@ -16330,6 +16549,7 @@ snapshots:
|
||||
time:
|
||||
'@amplitude/analytics-browser@2.42.0': '2026-04-28T17:01:08.442Z'
|
||||
'@amplitude/plugin-session-replay-browser@1.28.1': '2026-04-28T17:01:37.145Z'
|
||||
'@hey-api/openapi-ts@0.97.0': '2026-04-28T03:33:22.380Z'
|
||||
'@hono/node-server@2.0.0': '2026-04-21T00:25:40.852Z'
|
||||
'@lexical/link@0.44.0': '2026-04-27T14:47:45.477Z'
|
||||
'@lexical/list@0.44.0': '2026-04-27T14:47:48.463Z'
|
||||
@ -16338,6 +16558,7 @@ time:
|
||||
'@lexical/text@0.44.0': '2026-04-27T14:48:23.958Z'
|
||||
'@lexical/utils@0.44.0': '2026-04-27T14:48:26.689Z'
|
||||
'@tanstack/eslint-plugin-query@5.100.6': '2026-04-28T16:39:45.129Z'
|
||||
'@tanstack/react-hotkeys@0.10.0': '2026-04-25T12:28:06.989Z'
|
||||
'@tanstack/react-query-devtools@5.100.6': '2026-04-28T16:39:51.334Z'
|
||||
'@tanstack/react-query@5.100.6': '2026-04-28T16:39:52.105Z'
|
||||
'@tsslint/cli@3.1.0': '2026-04-29T04:57:38.423Z'
|
||||
|
||||
@ -68,6 +68,7 @@ catalog:
|
||||
'@formatjs/intl-localematcher': 0.8.4
|
||||
'@headlessui/react': 2.2.10
|
||||
'@heroicons/react': 2.2.0
|
||||
'@hey-api/openapi-ts': 0.97.0
|
||||
'@hono/node-server': 2.0.0
|
||||
'@iconify-json/heroicons': 1.2.3
|
||||
'@iconify-json/ri': 1.2.10
|
||||
@ -109,6 +110,7 @@ catalog:
|
||||
'@tanstack/react-devtools': 0.10.2
|
||||
'@tanstack/react-form': 1.29.1
|
||||
'@tanstack/react-form-devtools': 0.2.22
|
||||
'@tanstack/react-hotkeys': 0.10.0
|
||||
'@tanstack/react-query': 5.100.6
|
||||
'@tanstack/react-query-devtools': 5.100.6
|
||||
'@tanstack/react-virtual': 3.13.24
|
||||
|
||||
@ -4,4 +4,8 @@ export default defineConfig({
|
||||
staged: {
|
||||
'*': 'eslint --fix --pass-on-unpruned-suppressions',
|
||||
},
|
||||
fmt: {
|
||||
singleQuote: true,
|
||||
semi: false,
|
||||
},
|
||||
})
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { CSSProperties, FC } from 'react'
|
||||
import type { ModelAndParameter } from '../types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@ -92,11 +93,16 @@ const DebugItem: FC<DebugItemProps> = ({
|
||||
modelAndParameter={modelAndParameter}
|
||||
/>
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger render={<div />}>
|
||||
<ActionButton className={open ? 'bg-state-base-hover' : ''}>
|
||||
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
|
||||
</ActionButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<ActionButton
|
||||
className={cn(open && 'bg-state-base-hover', 'focus-visible:ring-2 focus-visible:ring-state-accent-solid')}
|
||||
aria-label={t('operation.more', { ns: 'common' })}
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
|
||||
</ActionButton>
|
||||
)}
|
||||
/>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
|
||||
@ -92,12 +92,12 @@ const WorkflowProcessItem = ({
|
||||
)
|
||||
}
|
||||
<div
|
||||
className={cn('system-xs-medium text-text-secondary', !collapse && 'grow')}
|
||||
className="min-w-0 grow truncate system-xs-medium text-text-secondary"
|
||||
data-testid="workflow-process-title"
|
||||
>
|
||||
{!collapse ? t('common.workflowProcess', { ns: 'workflow' }) : latestNode?.title}
|
||||
</div>
|
||||
<div className={cn('ml-1 i-ri-arrow-right-s-line h-4 w-4 text-text-tertiary', !collapse && 'rotate-90')} />
|
||||
<div className={cn('ml-1 i-ri-arrow-right-s-line h-4 w-4 shrink-0 text-text-tertiary', !collapse && 'rotate-90')} />
|
||||
</div>
|
||||
{
|
||||
!collapse && (
|
||||
|
||||
@ -29,6 +29,7 @@ import {
|
||||
} from 'lexical'
|
||||
import * as React from 'react'
|
||||
import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types'
|
||||
import { VAR_REFERENCE_CHILD_POPUP_CLASS_NAME } from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
|
||||
import { VarType } from '@/app/components/workflow/types'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
|
||||
@ -928,5 +929,46 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => {
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('does not hide the menu when focus moves into a variable child popup', async () => {
|
||||
const captures: Captures = { editor: null, eventEmitter: null }
|
||||
|
||||
render((
|
||||
<MinimalEditor
|
||||
triggerString="/"
|
||||
workflowVariableBlock={makeWorkflowVariableBlock({}, [
|
||||
makeWorkflowVarNode('node-1', 'Node 1', [
|
||||
makeWorkflowNodeVar('payload', VarType.object, [makeWorkflowNodeVar('child', VarType.string)]),
|
||||
]),
|
||||
])}
|
||||
captures={captures}
|
||||
/>
|
||||
))
|
||||
|
||||
const editor = await waitForEditor(captures)
|
||||
await setEditorText(editor, '/', true)
|
||||
expect(await screen.findByText('payload')).toBeInTheDocument()
|
||||
|
||||
vi.useFakeTimers()
|
||||
|
||||
const popupTarget = document.createElement('button')
|
||||
const popup = document.createElement('div')
|
||||
popup.classList.add(VAR_REFERENCE_CHILD_POPUP_CLASS_NAME)
|
||||
popup.appendChild(popupTarget)
|
||||
document.body.appendChild(popup)
|
||||
|
||||
act(() => {
|
||||
editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur-sm', { relatedTarget: popupTarget }))
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
|
||||
expect(screen.queryByText('payload')).toBeInTheDocument()
|
||||
|
||||
popup.remove()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -14,6 +14,7 @@ import type {
|
||||
WorkflowVariableBlockType,
|
||||
} from '../../types'
|
||||
import type { PickerBlockMenuOption } from './menu'
|
||||
import type { EventEmitterValue } from '@/context/event-emitter'
|
||||
import {
|
||||
flip,
|
||||
offset,
|
||||
@ -39,7 +40,7 @@ import {
|
||||
} from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types'
|
||||
import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
|
||||
import VarReferenceVars, { VAR_REFERENCE_CHILD_POPUP_CLASS_NAME } from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { useBasicTypeaheadTriggerMatch } from '../../hooks'
|
||||
import { $splitNodeContainingQuery } from '../../utils'
|
||||
@ -119,7 +120,9 @@ const ComponentPicker = ({
|
||||
(event) => {
|
||||
clearBlurTimer()
|
||||
const target = event?.relatedTarget as HTMLElement
|
||||
if (!target?.classList?.contains('var-search-input'))
|
||||
const isVariableMenuTarget = target?.classList?.contains('var-search-input')
|
||||
|| target?.closest?.(`.${VAR_REFERENCE_CHILD_POPUP_CLASS_NAME}`)
|
||||
if (!isVariableMenuTarget)
|
||||
blurTimerRef.current = setTimeout(() => setBlurHidden(true), 200)
|
||||
return false
|
||||
},
|
||||
@ -143,8 +146,8 @@ const ComponentPicker = ({
|
||||
}
|
||||
}, [editor, clearBlurTimer])
|
||||
|
||||
eventEmitter?.useSubscription((v: any) => {
|
||||
if (v.type === INSERT_VARIABLE_VALUE_BLOCK_COMMAND)
|
||||
eventEmitter?.useSubscription((v: EventEmitterValue) => {
|
||||
if (typeof v !== 'string' && v.type === INSERT_VARIABLE_VALUE_BLOCK_COMMAND && typeof v.payload === 'string')
|
||||
editor.dispatchCommand(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, `{{${v.payload}}}`)
|
||||
})
|
||||
|
||||
@ -303,7 +306,7 @@ const ComponentPicker = ({
|
||||
}
|
||||
</>
|
||||
)
|
||||
}, [blurHidden, allFlattenOptions.length, workflowVariableBlock?.show, floatingStyles, isPositioned, refs, workflowVariableOptions, isSupportFileVar, handleClose, currentBlock?.generatorType, handleSelectWorkflowVariable, queryString, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField])
|
||||
}, [blurHidden, allFlattenOptions.length, workflowVariableBlock?.show, floatingStyles, isPositioned, refs, workflowVariableOptions, isSupportFileVar, handleClose, currentBlock?.generatorType, handleSelectWorkflowVariable, queryString, triggerString, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField])
|
||||
|
||||
return (
|
||||
<LexicalTypeaheadMenuPlugin
|
||||
|
||||
@ -363,9 +363,7 @@ describe('Breadcrumbs', () => {
|
||||
|
||||
render(<Breadcrumbs {...props} />)
|
||||
|
||||
// Assert - Dropdown trigger (more button) should be present
|
||||
// Assert - Dropdown trigger (more button) should be present
|
||||
expect(screen.getByRole('button', { name: '' }))!.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.more' }))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show dropdown when breadcrumbs do not exceed displayBreadcrumbNum', () => {
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Menu from './menu'
|
||||
|
||||
type DropdownProps = {
|
||||
@ -19,6 +20,7 @@ const Dropdown = ({
|
||||
breadcrumbs,
|
||||
onBreadcrumbClick,
|
||||
}: DropdownProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const handleBreadCrumbClick = useCallback((index: number) => {
|
||||
@ -31,17 +33,21 @@ const Dropdown = ({
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<DropdownMenuTrigger render={<div />}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex size-6 items-center justify-center rounded-md',
|
||||
open ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill size-4 text-text-tertiary" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('operation.more', { ns: 'common' })}
|
||||
className={cn(
|
||||
'flex size-6 items-center justify-center rounded-md',
|
||||
'focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
open ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill size-4 text-text-tertiary" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { subscribeWorkflowCommand, WorkflowCommand } from '@/app/components/workflow/shortcuts/commands'
|
||||
import { registerCommands, unregisterCommands } from '../command-bus'
|
||||
import { ZEN_TOGGLE_EVENT, zenCommand } from '../zen'
|
||||
import { zenCommand } from '../zen'
|
||||
|
||||
vi.mock('../command-bus')
|
||||
|
||||
@ -24,10 +25,6 @@ describe('zenCommand', () => {
|
||||
expect(zenCommand.execute).toBeDefined()
|
||||
})
|
||||
|
||||
it('exports ZEN_TOGGLE_EVENT constant', () => {
|
||||
expect(ZEN_TOGGLE_EVENT).toBe('zen-toggle-maximize')
|
||||
})
|
||||
|
||||
describe('isAvailable', () => {
|
||||
it('delegates to isInWorkflowPage', async () => {
|
||||
const { isInWorkflowPage } = vi.mocked(
|
||||
@ -43,15 +40,14 @@ describe('zenCommand', () => {
|
||||
})
|
||||
|
||||
describe('execute', () => {
|
||||
it('dispatches custom zen-toggle event', () => {
|
||||
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
|
||||
it('emits the workflow canvas maximize command', () => {
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = subscribeWorkflowCommand(WorkflowCommand.ToggleCanvasMaximize, listener)
|
||||
|
||||
zenCommand.execute?.()
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: ZEN_TOGGLE_EVENT }),
|
||||
)
|
||||
dispatchSpy.mockRestore()
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
unsubscribe()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -3,17 +3,17 @@ import { RiFullscreenLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { getI18n } from 'react-i18next'
|
||||
import { isInWorkflowPage } from '@/app/components/workflow/constants'
|
||||
import {
|
||||
emitWorkflowCommand,
|
||||
WorkflowCommand,
|
||||
} from '@/app/components/workflow/shortcuts/commands'
|
||||
import { registerCommands, unregisterCommands } from './command-bus'
|
||||
|
||||
// Zen command dependency types - no external dependencies needed
|
||||
type ZenDeps = Record<string, never>
|
||||
|
||||
// Custom event name for zen toggle
|
||||
export const ZEN_TOGGLE_EVENT = 'zen-toggle-maximize'
|
||||
|
||||
// Shared function to dispatch zen toggle event
|
||||
const toggleZenMode = () => {
|
||||
window.dispatchEvent(new CustomEvent(ZEN_TOGGLE_EVENT))
|
||||
emitWorkflowCommand(WorkflowCommand.ToggleCanvasMaximize)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type {
|
||||
DataSourceCredential,
|
||||
} from './types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@ -45,11 +46,17 @@ const Operator = ({
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger render={<div />}>
|
||||
<ActionButton size="l" className={open ? 'bg-state-base-hover' : ''}>
|
||||
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
|
||||
</ActionButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<ActionButton
|
||||
size="l"
|
||||
className={cn(open && 'bg-state-base-hover', 'focus-visible:ring-2 focus-visible:ring-state-accent-solid')}
|
||||
aria-label={t('operation.more', { ns: 'common' })}
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
|
||||
</ActionButton>
|
||||
)}
|
||||
/>
|
||||
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="min-w-[200px]">
|
||||
<DropdownMenuItem className="h-auto gap-2 py-2" onClick={() => handleAction('setDefault')}>
|
||||
<span aria-hidden className="i-ri-home-9-line h-4 w-4 text-text-tertiary" />
|
||||
|
||||
@ -218,7 +218,6 @@ describe('EdgeContextmenu', () => {
|
||||
})
|
||||
|
||||
const deleteAction = await screen.findByRole('menuitem', { name: /common:operation\.delete/i })
|
||||
expect(screen.getByText(/^del$/i))!.toBeInTheDocument()
|
||||
|
||||
await user.click(deleteAction)
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { Edge, Node } from '../types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import { WorkflowContextProvider } from '../context'
|
||||
import { useDatasetsDetailStore } from '../datasets-detail-store/store'
|
||||
import WorkflowWithDefaultContext from '../index'
|
||||
import { BlockEnum } from '../types'
|
||||
@ -35,14 +36,13 @@ const edges: Edge[] = [
|
||||
]
|
||||
|
||||
const ContextConsumer = () => {
|
||||
const { store, shortcutsEnabled } = useWorkflowHistoryStore()
|
||||
const { store } = useWorkflowHistoryStore()
|
||||
const datasetCount = useDatasetsDetailStore(state => Object.keys(state.datasetsDetail).length)
|
||||
const reactFlowStore = useStoreApi()
|
||||
|
||||
return (
|
||||
<div>
|
||||
{`history:${store.getState().nodes.length}`}
|
||||
{` shortcuts:${String(shortcutsEnabled)}`}
|
||||
{` datasets:${datasetCount}`}
|
||||
{` reactflow:${String(!!reactFlowStore)}`}
|
||||
</div>
|
||||
@ -52,16 +52,18 @@ const ContextConsumer = () => {
|
||||
describe('WorkflowWithDefaultContext', () => {
|
||||
it('wires the ReactFlow, workflow history, and datasets detail providers around its children', () => {
|
||||
render(
|
||||
<WorkflowWithDefaultContext
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
>
|
||||
<ContextConsumer />
|
||||
</WorkflowWithDefaultContext>,
|
||||
<WorkflowContextProvider>
|
||||
<WorkflowWithDefaultContext
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
>
|
||||
<ContextConsumer />
|
||||
</WorkflowWithDefaultContext>
|
||||
</WorkflowContextProvider>,
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByText('history:1 shortcuts:true datasets:0 reactflow:true'),
|
||||
screen.getByText('history:1 datasets:0 reactflow:true'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -145,8 +145,8 @@ describe('PanelContextmenu', () => {
|
||||
const { container } = render(<PanelContextmenu />)
|
||||
|
||||
expect(screen.getByTestId('add-block')).toHaveTextContent('common.addBlock')
|
||||
expect(screen.getByTestId('shortcut-alt-r')).toHaveTextContent('alt+r')
|
||||
expect(screen.getByTestId('shortcut-ctrl-v')).toHaveTextContent('ctrl+v')
|
||||
expect(screen.getByRole('button', { name: /common\.run/i })).toHaveTextContent(/Alt\s*R/)
|
||||
expect(screen.getByRole('button', { name: /common\.pasteHere/i })).toHaveTextContent(/Ctrl\s*V/)
|
||||
expect(container.firstChild).toHaveStyle({
|
||||
left: '24px',
|
||||
top: '48px',
|
||||
|
||||
@ -251,17 +251,23 @@ vi.mock('../hooks/use-workflow-comment', () => ({
|
||||
vi.mock('../base/confirm', () => ({
|
||||
default: ({
|
||||
isShow,
|
||||
title,
|
||||
desc,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
isShow: boolean
|
||||
title?: string
|
||||
desc?: string
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}) => isShow
|
||||
? (
|
||||
<div data-testid="confirm-dialog">
|
||||
<button type="button" onClick={onConfirm}>confirm</button>
|
||||
<button type="button" onClick={onCancel}>cancel</button>
|
||||
<div role="alertdialog" data-testid="confirm-dialog">
|
||||
{title && <div>{title}</div>}
|
||||
{desc && <div>{desc}</div>}
|
||||
<button type="button" onClick={onConfirm}>common.operation.confirm</button>
|
||||
<button type="button" onClick={onCancel}>common.operation.cancel</button>
|
||||
</div>
|
||||
)
|
||||
: null,
|
||||
@ -338,6 +344,11 @@ vi.mock('../syncing-data-modal', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../shortcuts/use-workflow-hotkeys', () => ({
|
||||
useWorkflowHotkeys: workflowHookMocks.useShortcuts,
|
||||
useWorkflowShortcut: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useEdgesInteractions: () => ({
|
||||
handleEdgeEnter: workflowHookMocks.handleEdgeEnter,
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import type { WorkflowHistoryState } from '../store/workflow/history-slice'
|
||||
import type { Edge, Node } from '../types'
|
||||
import type { WorkflowHistoryState } from '../workflow-history-store'
|
||||
import { render, renderHook, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { WorkflowContext } from '../context'
|
||||
import { createWorkflowStore } from '../store/workflow'
|
||||
import { BlockEnum } from '../types'
|
||||
import { useWorkflowHistoryStore, WorkflowHistoryProvider } from '../workflow-history-store'
|
||||
import { useWorkflowHistoryStore } from '../workflow-history-store'
|
||||
|
||||
const nodes: Node[] = [
|
||||
{
|
||||
@ -36,44 +37,28 @@ const edges: Edge[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const HistoryConsumer = () => {
|
||||
const { store, shortcutsEnabled, setShortcutsEnabled } = useWorkflowHistoryStore()
|
||||
const createWrapper = () => {
|
||||
const workflowStore = createWorkflowStore({})
|
||||
workflowStore.temporal.getState().pause()
|
||||
workflowStore.getState().setWorkflowHistory({
|
||||
nodes,
|
||||
edges,
|
||||
workflowHistoryEvent: undefined,
|
||||
workflowHistoryEventMeta: undefined,
|
||||
})
|
||||
workflowStore.temporal.getState().clear()
|
||||
workflowStore.temporal.getState().resume()
|
||||
|
||||
return (
|
||||
<button onClick={() => setShortcutsEnabled(!shortcutsEnabled)}>
|
||||
{`nodes:${store.getState().nodes.length} shortcuts:${String(shortcutsEnabled)}`}
|
||||
</button>
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<WorkflowContext.Provider value={workflowStore}>
|
||||
{children}
|
||||
</WorkflowContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('WorkflowHistoryProvider', () => {
|
||||
it('provides workflow history state and shortcut toggles', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<WorkflowHistoryProvider
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
>
|
||||
<HistoryConsumer />
|
||||
</WorkflowHistoryProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'nodes:1 shortcuts:true' }))!.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'nodes:1 shortcuts:true' }))
|
||||
expect(screen.getByRole('button', { name: 'nodes:1 shortcuts:false' }))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('workflow history store', () => {
|
||||
it('sanitizes selected flags when history state is replaced through the exposed store api', () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<WorkflowHistoryProvider
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
>
|
||||
{children}
|
||||
</WorkflowHistoryProvider>
|
||||
)
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const { result } = renderHook(() => useWorkflowHistoryStore(), { wrapper })
|
||||
const nextState: WorkflowHistoryState = {
|
||||
@ -91,7 +76,7 @@ describe('WorkflowHistoryProvider', () => {
|
||||
|
||||
it('throws when consumed outside the provider', () => {
|
||||
expect(() => renderHook(() => useWorkflowHistoryStore())).toThrow(
|
||||
'useWorkflowHistoryStoreApi must be used within a WorkflowHistoryProvider',
|
||||
'Missing WorkflowContext.Provider in the tree',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -63,22 +63,18 @@
|
||||
import type { RenderHookOptions, RenderHookResult, RenderOptions, RenderResult } from '@testing-library/react'
|
||||
import type { Shape as HooksStoreShape } from '../hooks-store/store'
|
||||
import type { Shape } from '../store/workflow'
|
||||
import type { WorkflowHistoryState } from '../store/workflow/history-slice'
|
||||
import type { Edge, Node, WorkflowRunningData } from '../types'
|
||||
import type { WorkflowHistoryStoreApi } from '../workflow-history-store'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, renderHook } from '@testing-library/react'
|
||||
import isDeepEqual from 'fast-deep-equal'
|
||||
import * as React from 'react'
|
||||
import ReactFlow, { ReactFlowProvider } from 'reactflow'
|
||||
import { temporal } from 'zundo'
|
||||
import { create } from 'zustand'
|
||||
import { seedSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import { WorkflowContext } from '../context'
|
||||
import { HooksStoreContext } from '../hooks-store/provider'
|
||||
import { createHooksStore } from '../hooks-store/store'
|
||||
import { createWorkflowStore } from '../store/workflow'
|
||||
import { WorkflowRunningStatus } from '../types'
|
||||
import { WorkflowHistoryStoreContext } from '../workflow-history-store'
|
||||
|
||||
// Re-exports are in a separate non-JSX file to avoid react-refresh warnings.
|
||||
// Import directly from the individual modules:
|
||||
@ -156,9 +152,13 @@ function createWorkflowWrapper(
|
||||
historyConfig?: HistoryStoreConfig,
|
||||
externalQueryClient?: QueryClient,
|
||||
) {
|
||||
const historyCtxValue = historyConfig
|
||||
? createTestHistoryStoreContext(historyConfig)
|
||||
: undefined
|
||||
if (historyConfig) {
|
||||
stores.store.temporal.getState().pause()
|
||||
stores.store.getState().setWorkflowHistory(createTestWorkflowHistoryState(historyConfig))
|
||||
stores.store.temporal.getState().clear()
|
||||
stores.store.temporal.getState().resume()
|
||||
}
|
||||
|
||||
const queryClient = externalQueryClient ?? new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
@ -172,14 +172,6 @@ function createWorkflowWrapper(
|
||||
return ({ children }: { children: React.ReactNode }) => {
|
||||
let inner: React.ReactNode = children
|
||||
|
||||
if (historyCtxValue) {
|
||||
inner = React.createElement(
|
||||
WorkflowHistoryStoreContext.Provider,
|
||||
{ value: historyCtxValue },
|
||||
inner,
|
||||
)
|
||||
}
|
||||
|
||||
if (stores.hooksStore) {
|
||||
inner = React.createElement(
|
||||
HooksStoreContext.Provider,
|
||||
@ -214,7 +206,7 @@ type WorkflowHookTestResult<R, P> = RenderHookResult<R, P> & StoreInstances
|
||||
* Contexts provided based on options:
|
||||
* - **Always**: `WorkflowContext` (real zustand store)
|
||||
* - **hooksStoreProps**: `HooksStoreContext` (real zustand store)
|
||||
* - **historyStore**: `WorkflowHistoryStoreContext` (real zundo temporal store)
|
||||
* - **historyStore**: workflow history zundo store on `WorkflowContext`
|
||||
*/
|
||||
export function renderWorkflowHook<R, P = undefined>(
|
||||
hook: (props: P) => R,
|
||||
@ -243,7 +235,7 @@ type WorkflowComponentTestResult = RenderResult & StoreInstances
|
||||
* Provides the same context layers as `renderWorkflowHook`:
|
||||
* - **Always**: `WorkflowContext` (real zustand store)
|
||||
* - **hooksStoreProps**: `HooksStoreContext` (real zustand store)
|
||||
* - **historyStore**: `WorkflowHistoryStoreContext` (real zundo temporal store)
|
||||
* - **historyStore**: workflow history zundo store on `WorkflowContext`
|
||||
*/
|
||||
export function renderWorkflowComponent(
|
||||
ui: React.ReactElement,
|
||||
@ -393,36 +385,13 @@ export function renderNodeComponent<T extends Record<string, unknown>>(
|
||||
// WorkflowHistoryStore test helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createTestHistoryStoreContext(config: HistoryStoreConfig) {
|
||||
function createTestWorkflowHistoryState(config: HistoryStoreConfig): WorkflowHistoryState {
|
||||
const nodes = config.nodes ?? []
|
||||
const edges = config.edges ?? []
|
||||
|
||||
type HistState = {
|
||||
workflowHistoryEvent: string | undefined
|
||||
workflowHistoryEventMeta: unknown
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
getNodes: () => Node[]
|
||||
setNodes: (n: Node[]) => void
|
||||
setEdges: (e: Edge[]) => void
|
||||
}
|
||||
|
||||
const store = create(temporal<HistState>(
|
||||
(set, get) => ({
|
||||
workflowHistoryEvent: undefined,
|
||||
workflowHistoryEventMeta: undefined,
|
||||
nodes,
|
||||
edges,
|
||||
getNodes: () => get().nodes,
|
||||
setNodes: (n: Node[]) => set({ nodes: n }),
|
||||
setEdges: (e: Edge[]) => set({ edges: e }),
|
||||
}),
|
||||
{ equality: (a, b) => isDeepEqual(a, b) },
|
||||
)) as unknown as WorkflowHistoryStoreApi
|
||||
|
||||
return {
|
||||
store,
|
||||
shortcutsEnabled: true,
|
||||
setShortcutsEnabled: () => {},
|
||||
nodes,
|
||||
edges,
|
||||
workflowHistoryEvent: undefined,
|
||||
workflowHistoryEventMeta: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@ -74,11 +74,16 @@ const OperationDropdown: FC<Props> = ({
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<DropdownMenuTrigger render={<div />}>
|
||||
<ActionButton className={cn(open && 'bg-state-base-hover')}>
|
||||
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-components-button-secondary-accent-text" />
|
||||
</ActionButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<ActionButton
|
||||
className={cn(open && 'bg-state-base-hover', 'focus-visible:ring-2 focus-visible:ring-state-accent-solid')}
|
||||
aria-label={t('operation.more', { ns: 'common' })}
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-components-button-secondary-accent-text" />
|
||||
</ActionButton>
|
||||
)}
|
||||
/>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import type { StateCreator } from 'zustand'
|
||||
import type { SliceFromInjection } from './store'
|
||||
import type { SliceFromInjection } from './store/workflow'
|
||||
import {
|
||||
createContext,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import {
|
||||
createWorkflowStore,
|
||||
} from './store'
|
||||
} from './store/workflow'
|
||||
|
||||
type WorkflowStore = ReturnType<typeof createWorkflowStore>
|
||||
export const WorkflowContext = createContext<WorkflowStore | null>(null)
|
||||
|
||||
@ -10,7 +10,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEdges } from 'reactflow'
|
||||
import { useEdgesInteractions, usePanelInteractions } from './hooks'
|
||||
import ShortcutsName from './shortcuts-name'
|
||||
import { ShortcutKbd } from './shortcuts/shortcut-kbd'
|
||||
import { useStore } from './store'
|
||||
|
||||
const EdgeContextmenu = () => {
|
||||
@ -53,7 +53,7 @@ const EdgeContextmenu = () => {
|
||||
onClick={() => handleEdgeDeleteById(edgeMenu.edgeId)}
|
||||
>
|
||||
<span>{t('common:operation.delete')}</span>
|
||||
<ShortcutsName keys={['del']} />
|
||||
<ShortcutKbd shortcut="workflow.delete" />
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
@ -37,11 +37,15 @@ vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
vi.mock('@/app/components/workflow/store/workflow', () => ({
|
||||
useStore: (selector: (state: { workflowRunningData?: unknown, isListening: boolean }) => unknown) =>
|
||||
selector({ workflowRunningData: mockWorkflowRunningData, isListening: mockIsListening }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/shortcuts/use-workflow-hotkeys', () => ({
|
||||
useWorkflowShortcut: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/use-dynamic-test-run-options', () => ({
|
||||
useDynamicTestRunOptions: () => mockDynamicOptions,
|
||||
}))
|
||||
|
||||
@ -27,12 +27,17 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => {
|
||||
render,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
render?: React.ReactElement
|
||||
render?: React.ReactElement<{ children?: React.ReactNode }>
|
||||
}) => {
|
||||
const { open, setOpen } = useDropdownMenuContext()
|
||||
|
||||
if (render)
|
||||
return React.cloneElement(render, { onClick: () => setOpen(!open) } as Record<string, unknown>, children)
|
||||
if (render) {
|
||||
return React.cloneElement(
|
||||
render,
|
||||
{ onClick: () => setOpen(!open) } as Record<string, unknown>,
|
||||
children ?? render.props.children,
|
||||
)
|
||||
}
|
||||
|
||||
return <button type="button" onClick={() => setOpen(!open)}>{children}</button>
|
||||
},
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import VersionHistoryButton from '../version-history-button'
|
||||
|
||||
let mockTheme: 'light' | 'dark' = 'light'
|
||||
const workflowShortcutHandlers = vi.hoisted(() => new Map<string, () => void | Promise<void>>())
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({
|
||||
@ -9,17 +10,22 @@ vi.mock('@/hooks/use-theme', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../utils', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../utils')>()
|
||||
return {
|
||||
...actual,
|
||||
getKeyboardKeyCodeBySystem: () => 'ctrl',
|
||||
}
|
||||
})
|
||||
vi.mock('../../shortcuts/use-workflow-hotkeys', () => ({
|
||||
useWorkflowShortcut: (id: string, callback: () => void | Promise<void>) => {
|
||||
workflowShortcutHandlers.set(id, callback)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
describe('VersionHistoryButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
workflowShortcutHandlers.clear()
|
||||
mockTheme = 'light'
|
||||
})
|
||||
|
||||
@ -32,22 +38,14 @@ describe('VersionHistoryButton', () => {
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should trigger onClick when the version history shortcut is pressed', () => {
|
||||
it('should trigger onClick when the version history shortcut is pressed', async () => {
|
||||
const onClick = vi.fn()
|
||||
render(<VersionHistoryButton onClick={onClick} />)
|
||||
|
||||
const keyboardEvent = new KeyboardEvent('keydown', {
|
||||
key: 'H',
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
await act(async () => {
|
||||
await workflowShortcutHandlers.get('workflow.version-history')?.()
|
||||
})
|
||||
Object.defineProperty(keyboardEvent, 'keyCode', { value: 72 })
|
||||
Object.defineProperty(keyboardEvent, 'which', { value: 72 })
|
||||
window.dispatchEvent(keyboardEvent)
|
||||
|
||||
expect(keyboardEvent.defaultPrevented).toBe(true)
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
import type { TestRunMenuRef, TriggerOption } from './test-run-menu'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiLoader2Line, RiPlayLargeLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import { StopCircle } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
|
||||
import { useWorkflowRun, useWorkflowRunValidation, useWorkflowStartRun } from '@/app/components/workflow/hooks'
|
||||
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
|
||||
import { useWorkflowShortcut } from '@/app/components/workflow/shortcuts/use-workflow-hotkeys'
|
||||
import { useStore } from '@/app/components/workflow/store/workflow'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
@ -42,17 +42,12 @@ const RunMode = ({
|
||||
const dynamicOptions = useDynamicTestRunOptions()
|
||||
const testRunMenuRef = useRef<TestRunMenuRef>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// @ts-expect-error - Dynamic property for backward compatibility with keyboard shortcuts
|
||||
window._toggleTestRunDropdown = () => {
|
||||
testRunMenuRef.current?.toggle()
|
||||
}
|
||||
return () => {
|
||||
// @ts-expect-error - Dynamic property cleanup
|
||||
delete window._toggleTestRunDropdown
|
||||
}
|
||||
const handleToggleTestRunMenu = useCallback(() => {
|
||||
testRunMenuRef.current?.toggle()
|
||||
}, [])
|
||||
|
||||
useWorkflowShortcut('workflow.open-test-run-menu', handleToggleTestRunMenu)
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
handleStopRun(workflowRunningData?.task_id || '')
|
||||
}, [handleStopRun, workflowRunningData?.task_id])
|
||||
@ -117,7 +112,7 @@ const RunMode = ({
|
||||
)}
|
||||
disabled={true}
|
||||
>
|
||||
<RiLoader2Line className="mr-1 size-4 animate-spin" />
|
||||
<span className="mr-1 i-ri-loader-2-line size-4 animate-spin" />
|
||||
{isListening ? t('common.listening', { ns: 'workflow' }) : t('common.running', { ns: 'workflow' })}
|
||||
</button>
|
||||
)
|
||||
@ -127,16 +122,17 @@ const RunMode = ({
|
||||
options={dynamicOptions}
|
||||
onSelect={handleTriggerSelect}
|
||||
>
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex h-7 cursor-pointer items-center gap-x-1 rounded-md px-1.5 system-xs-medium text-text-accent hover:bg-state-accent-hover',
|
||||
)}
|
||||
style={{ userSelect: 'none' }}
|
||||
>
|
||||
<RiPlayLargeLine className="mr-1 size-4" />
|
||||
<span aria-hidden className="mr-1 i-ri-play-large-line size-4" />
|
||||
{text ?? t('common.run', { ns: 'workflow' })}
|
||||
<ShortcutsName keys={['alt', 'R']} textColor="secondary" />
|
||||
</div>
|
||||
<ShortcutKbd shortcut="workflow.open-test-run-menu" textColor="secondary" />
|
||||
</button>
|
||||
</TestRunMenu>
|
||||
)
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ import {
|
||||
isValidElement,
|
||||
useEffect,
|
||||
} from 'react'
|
||||
import ShortcutsName from '../shortcuts-name'
|
||||
import { ShortcutKbd } from '../shortcuts/shortcut-kbd'
|
||||
|
||||
export type ShortcutMapping = {
|
||||
option: TriggerOption
|
||||
@ -39,7 +39,7 @@ export const OptionRow = ({
|
||||
<span className="ml-2 truncate">{option.name}</span>
|
||||
</div>
|
||||
{shortcutKey && (
|
||||
<ShortcutsName keys={[shortcutKey]} className="ml-2" textColor="secondary" />
|
||||
<ShortcutKbd hotkey={shortcutKey} className="ml-2" textColor="secondary" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
@ -111,8 +111,8 @@ export const SingleOptionTrigger = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<span onClick={handleRunClick}>
|
||||
<button type="button" onClick={handleRunClick}>
|
||||
{children}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { ShortcutMapping } from './test-run-menu-helpers'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { forwardRef, useCallback, useImperativeHandle, useMemo, useState } from 'react'
|
||||
import { forwardRef, isValidElement, useCallback, useImperativeHandle, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { OptionRow, SingleOptionTrigger, useShortcutMenu } from './test-run-menu-helpers'
|
||||
|
||||
@ -145,9 +145,18 @@ const TestRunMenu = forwardRef<TestRunMenuRef, TestRunMenuProps>(({
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<DropdownMenuTrigger render={<div style={{ userSelect: 'none' }} />}>
|
||||
{children}
|
||||
</DropdownMenuTrigger>
|
||||
{isValidElement(children)
|
||||
? (
|
||||
<DropdownMenuTrigger
|
||||
render={children}
|
||||
style={{ userSelect: 'none' }}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<DropdownMenuTrigger style={{ userSelect: 'none' }}>
|
||||
{children}
|
||||
</DropdownMenuTrigger>
|
||||
)}
|
||||
<DropdownMenuContent
|
||||
placement="bottom-start"
|
||||
sideOffset={8}
|
||||
|
||||
@ -28,7 +28,7 @@ const UndoRedo: FC<UndoRedoProps> = ({ handleUndo, handleRedo }) => {
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-0.5 rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-lg backdrop-blur-[5px]">
|
||||
<TipPopup title={t('common.undo', { ns: 'workflow' })!} shortcuts={['ctrl', 'z']}>
|
||||
<TipPopup title={t('common.undo', { ns: 'workflow' })!} shortcut="workflow.undo">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.undo', { ns: 'workflow' })!}
|
||||
@ -43,7 +43,7 @@ const UndoRedo: FC<UndoRedoProps> = ({ handleUndo, handleRedo }) => {
|
||||
<span className="i-ri-arrow-go-back-line h-4 w-4" />
|
||||
</button>
|
||||
</TipPopup>
|
||||
<TipPopup title={t('common.redo', { ns: 'workflow' })!} shortcuts={['ctrl', 'y']}>
|
||||
<TipPopup title={t('common.redo', { ns: 'workflow' })!} shortcut="workflow.redo">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.redo', { ns: 'workflow' })!}
|
||||
|
||||
@ -1,22 +1,22 @@
|
||||
import type { FC } from 'react'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiHistoryLine } from '@remixicon/react'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@langgenius/dify-ui/tooltip'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import Tooltip from '../../base/tooltip'
|
||||
import ShortcutsName from '../shortcuts-name'
|
||||
import { getKeyboardKeyCodeBySystem } from '../utils'
|
||||
import { ShortcutKbd } from '../shortcuts/shortcut-kbd'
|
||||
import { useWorkflowShortcut } from '../shortcuts/use-workflow-hotkeys'
|
||||
|
||||
type VersionHistoryButtonProps = {
|
||||
onClick: () => Promise<unknown> | unknown
|
||||
}
|
||||
|
||||
const VERSION_HISTORY_SHORTCUT = ['ctrl', '⇧', 'H']
|
||||
|
||||
const PopupContent = React.memo(() => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
@ -24,7 +24,7 @@ const PopupContent = React.memo(() => {
|
||||
<div className="px-0.5 system-xs-medium text-text-secondary">
|
||||
{t('common.versionHistory', { ns: 'workflow' })}
|
||||
</div>
|
||||
<ShortcutsName keys={VERSION_HISTORY_SHORTCUT} bgColor="gray" textColor="secondary" />
|
||||
<ShortcutKbd shortcut="workflow.version-history" bgColor="gray" textColor="secondary" />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@ -39,27 +39,30 @@ const VersionHistoryButton: FC<VersionHistoryButtonProps> = ({
|
||||
await onClick?.()
|
||||
}, [onClick])
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.h`, (e) => {
|
||||
e.preventDefault()
|
||||
useWorkflowShortcut('workflow.version-history', () => {
|
||||
handleViewVersionHistory()
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
})
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
popupContent={<PopupContent />}
|
||||
noDecoration
|
||||
popupClassName="rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg
|
||||
shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px] p-1.5"
|
||||
>
|
||||
<Button
|
||||
className={cn(
|
||||
'rounded-lg border border-transparent p-2',
|
||||
theme === 'dark' && 'border-black/5 bg-white/10 backdrop-blur-xs',
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<Button
|
||||
className={cn(
|
||||
'rounded-lg border border-transparent p-2',
|
||||
theme === 'dark' && 'border-black/5 bg-white/10 backdrop-blur-xs',
|
||||
)}
|
||||
onClick={handleViewVersionHistory}
|
||||
>
|
||||
<span className="i-ri-history-line h-4 w-4 text-components-button-secondary-text" />
|
||||
</Button>
|
||||
)}
|
||||
onClick={handleViewVersionHistory}
|
||||
/>
|
||||
<TooltipContent
|
||||
className="rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg p-1.5 shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]"
|
||||
>
|
||||
<RiHistoryLine className="h-4 w-4 text-components-button-secondary-text" />
|
||||
</Button>
|
||||
<PopupContent />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import type { WorkflowHistoryState } from '../workflow-history-store'
|
||||
import type { WorkflowHistoryState } from '../store/workflow/history-slice'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverClose,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
@ -141,6 +142,7 @@ const ViewWorkflowHistory = () => {
|
||||
return (
|
||||
(
|
||||
<Popover
|
||||
modal="trap-focus"
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (nodesReadOnly)
|
||||
@ -148,49 +150,56 @@ const ViewWorkflowHistory = () => {
|
||||
setOpen(nextOpen)
|
||||
}}
|
||||
>
|
||||
<TipPopup
|
||||
title={t('changeHistory.title', { ns: 'workflow' })}
|
||||
>
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<div
|
||||
className={
|
||||
cn('flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', open && 'bg-state-accent-active text-text-accent', nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled')
|
||||
}
|
||||
onClick={() => {
|
||||
if (nodesReadOnly)
|
||||
return
|
||||
setCurrentLogItem()
|
||||
setShowMessageLogModal(false)
|
||||
}}
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('changeHistory.title', { ns: 'workflow' })}
|
||||
disabled={nodesReadOnly}
|
||||
className={
|
||||
cn('box-border inline-flex h-8 max-h-8 min-h-8 w-8 max-w-8 min-w-8 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-md p-0 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', open && 'bg-state-accent-active text-text-accent', nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled')
|
||||
}
|
||||
onClick={() => {
|
||||
if (nodesReadOnly)
|
||||
return
|
||||
setCurrentLogItem()
|
||||
setShowMessageLogModal(false)
|
||||
}}
|
||||
>
|
||||
<TipPopup
|
||||
title={t('changeHistory.title', { ns: 'workflow' })}
|
||||
>
|
||||
<RiHistoryLine className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</TipPopup>
|
||||
<span className="flex h-full w-full shrink-0 items-center justify-center">
|
||||
<span className="i-ri-history-line h-4 w-4 shrink-0" />
|
||||
</span>
|
||||
</TipPopup>
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
alignOffset={131}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div
|
||||
className="ml-2 flex max-w-[360px] min-w-[240px] flex-col overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl backdrop-blur-[5px]"
|
||||
className="flex max-w-[360px] min-w-[240px] flex-col overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl backdrop-blur-[5px]"
|
||||
>
|
||||
<div className="sticky top-0 flex items-center justify-between px-4 pt-3">
|
||||
<div className="system-mg-regular grow text-text-secondary">{t('changeHistory.title', { ns: 'workflow' })}</div>
|
||||
<div
|
||||
className="flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center"
|
||||
<PopoverClose
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('operation.close', { ns: 'common' })}
|
||||
className="flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center"
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4 text-text-secondary" />
|
||||
</button>
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentLogItem()
|
||||
setShowMessageLogModal(false)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4 text-text-secondary" />
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="overflow-y-auto p-2"
|
||||
|
||||
@ -1,13 +1,17 @@
|
||||
import { act } from '@testing-library/react'
|
||||
import { ZEN_TOGGLE_EVENT } from '@/app/components/goto-anything/actions/commands/zen'
|
||||
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
|
||||
import { useShortcuts } from '../use-shortcuts'
|
||||
import { emitWorkflowCommand, WorkflowCommand } from '../../shortcuts/commands'
|
||||
import { useWorkflowHotkeys } from '../../shortcuts/use-workflow-hotkeys'
|
||||
|
||||
type KeyPressRegistration = {
|
||||
keyFilter: unknown
|
||||
handler: (event: KeyboardEvent) => void
|
||||
options?: {
|
||||
events?: string[]
|
||||
enabled?: boolean
|
||||
ignoreInputs?: boolean
|
||||
preventDefault?: boolean
|
||||
stopPropagation?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,6 +22,12 @@ type ReactFlowNodeMock = {
|
||||
}
|
||||
}
|
||||
|
||||
type HotkeyDefinitionMock = {
|
||||
hotkey: unknown
|
||||
callback: (event: KeyboardEvent) => void
|
||||
options?: KeyPressRegistration['options'] & { eventType?: 'keydown' | 'keyup' }
|
||||
}
|
||||
|
||||
const keyPressRegistrations = vi.hoisted<KeyPressRegistration[]>(() => [])
|
||||
const mockZoomTo = vi.hoisted(() => vi.fn())
|
||||
const mockGetZoom = vi.hoisted(() => vi.fn(() => 1))
|
||||
@ -35,14 +45,34 @@ const mockUndimAllNodes = vi.hoisted(() => vi.fn())
|
||||
const mockHandleSyncWorkflowDraft = vi.hoisted(() => vi.fn())
|
||||
const mockHandleModeHand = vi.hoisted(() => vi.fn())
|
||||
const mockHandleModePointer = vi.hoisted(() => vi.fn())
|
||||
const mockHandleModeComment = vi.hoisted(() => vi.fn())
|
||||
const mockHandleLayout = vi.hoisted(() => vi.fn())
|
||||
const mockHandleToggleMaximizeCanvas = vi.hoisted(() => vi.fn())
|
||||
const mockUseKeyHold = vi.hoisted(() => vi.fn(() => false))
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useKeyPress: (keyFilter: unknown, handler: (event: KeyboardEvent) => void, options?: { events?: string[] }) => {
|
||||
keyPressRegistrations.push({ keyFilter, handler, options })
|
||||
},
|
||||
}))
|
||||
vi.mock('@tanstack/react-hotkeys', () => {
|
||||
const useHotkeys = (
|
||||
definitions: HotkeyDefinitionMock[],
|
||||
commonOptions?: KeyPressRegistration['options'],
|
||||
) => {
|
||||
definitions.forEach((definition) => {
|
||||
keyPressRegistrations.push({
|
||||
keyFilter: definition.hotkey,
|
||||
handler: definition.callback,
|
||||
options: {
|
||||
...commonOptions,
|
||||
...definition.options,
|
||||
events: definition.options?.eventType ? [definition.options.eventType] : undefined,
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
useHotkeys,
|
||||
useKeyHold: mockUseKeyHold,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useReactFlow: () => ({
|
||||
@ -53,7 +83,7 @@ vi.mock('reactflow', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('..', () => ({
|
||||
vi.mock('../use-nodes-interactions', () => ({
|
||||
useNodesInteractions: () => ({
|
||||
handleNodesCopy: mockHandleNodesCopy,
|
||||
handleNodesPaste: mockHandleNodesPaste,
|
||||
@ -64,32 +94,44 @@ vi.mock('..', () => ({
|
||||
dimOtherNodes: mockDimOtherNodes,
|
||||
undimAllNodes: mockUndimAllNodes,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-edges-interactions', () => ({
|
||||
useEdgesInteractions: () => ({
|
||||
handleEdgeDelete: mockHandleEdgeDelete,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-nodes-sync-draft', () => ({
|
||||
useNodesSyncDraft: () => ({
|
||||
handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-workflow-canvas-maximize', () => ({
|
||||
useWorkflowCanvasMaximize: () => ({
|
||||
handleToggleMaximizeCanvas: mockHandleToggleMaximizeCanvas,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-workflow-panel-interactions', () => ({
|
||||
useWorkflowMoveMode: () => ({
|
||||
handleModeHand: mockHandleModeHand,
|
||||
handleModePointer: mockHandleModePointer,
|
||||
handleModeComment: mockHandleModeComment,
|
||||
isCommentModeAvailable: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-workflow-organize', () => ({
|
||||
useWorkflowOrganize: () => ({
|
||||
handleLayout: mockHandleLayout,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../workflow-history-store', () => ({
|
||||
useWorkflowHistoryStore: () => ({
|
||||
shortcutsEnabled: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
const createKeyboardEvent = (target: HTMLElement = document.body) => ({
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
target,
|
||||
}) as unknown as KeyboardEvent
|
||||
|
||||
@ -107,49 +149,73 @@ const findRegistration = (matcher: (registration: KeyPressRegistration) => boole
|
||||
return registration as KeyPressRegistration
|
||||
}
|
||||
|
||||
const isEditableTarget = (target: EventTarget | null) => {
|
||||
return target instanceof HTMLInputElement
|
||||
|| target instanceof HTMLTextAreaElement
|
||||
|| target instanceof HTMLSelectElement
|
||||
|| (target instanceof HTMLElement && target.isContentEditable)
|
||||
}
|
||||
|
||||
const triggerShortcut = (
|
||||
registration: KeyPressRegistration,
|
||||
event: KeyboardEvent = createKeyboardEvent(),
|
||||
) => {
|
||||
if (registration.options?.enabled === false)
|
||||
return
|
||||
|
||||
if (registration.options?.ignoreInputs !== false && isEditableTarget(event.target))
|
||||
return
|
||||
|
||||
if (registration.options?.preventDefault !== false)
|
||||
event.preventDefault()
|
||||
|
||||
if (registration.options?.stopPropagation !== false)
|
||||
event.stopPropagation()
|
||||
|
||||
registration.handler(event)
|
||||
}
|
||||
|
||||
describe('useShortcuts', () => {
|
||||
beforeEach(() => {
|
||||
keyPressRegistrations.length = 0
|
||||
vi.clearAllMocks()
|
||||
mockUseKeyHold.mockReturnValue(false)
|
||||
mockGetNodes.mockReturnValue([])
|
||||
})
|
||||
|
||||
it('deletes selected nodes and edges only outside editable inputs', () => {
|
||||
renderWorkflowHook(() => useShortcuts())
|
||||
renderWorkflowHook(() => useWorkflowHotkeys())
|
||||
|
||||
const deleteShortcut = findRegistration(registration =>
|
||||
Array.isArray(registration.keyFilter)
|
||||
&& registration.keyFilter.includes('delete'),
|
||||
)
|
||||
const deleteShortcut = findRegistration(registration => registration.keyFilter === 'Delete')
|
||||
|
||||
const bodyEvent = createKeyboardEvent()
|
||||
deleteShortcut.handler(bodyEvent)
|
||||
triggerShortcut(deleteShortcut, bodyEvent)
|
||||
|
||||
expect(bodyEvent.preventDefault).toHaveBeenCalled()
|
||||
expect(mockHandleNodesDelete).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleEdgeDelete).toHaveBeenCalledTimes(1)
|
||||
|
||||
const inputEvent = createKeyboardEvent(document.createElement('input'))
|
||||
deleteShortcut.handler(inputEvent)
|
||||
triggerShortcut(deleteShortcut, inputEvent)
|
||||
|
||||
expect(mockHandleNodesDelete).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleEdgeDelete).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('runs layout and zoom shortcuts through the workflow actions', () => {
|
||||
renderWorkflowHook(() => useShortcuts())
|
||||
renderWorkflowHook(() => useWorkflowHotkeys())
|
||||
|
||||
const layoutShortcut = findRegistration(registration => registration.keyFilter === 'ctrl.o' || registration.keyFilter === 'meta.o')
|
||||
const fitViewShortcut = findRegistration(registration => registration.keyFilter === 'ctrl.1' || registration.keyFilter === 'meta.1')
|
||||
const halfZoomShortcut = findRegistration(registration => registration.keyFilter === 'shift.5')
|
||||
const zoomOutShortcut = findRegistration(registration => registration.keyFilter === 'ctrl.dash' || registration.keyFilter === 'meta.dash')
|
||||
const zoomInShortcut = findRegistration(registration => registration.keyFilter === 'ctrl.equalsign' || registration.keyFilter === 'meta.equalsign')
|
||||
const layoutShortcut = findRegistration(registration => registration.keyFilter === 'Mod+O')
|
||||
const fitViewShortcut = findRegistration(registration => registration.keyFilter === 'Mod+1')
|
||||
const halfZoomShortcut = findRegistration(registration => registration.keyFilter === 'Shift+5')
|
||||
const zoomOutShortcut = findRegistration(registration => registration.keyFilter === 'Mod+-')
|
||||
const zoomInShortcut = findRegistration(registration => registration.keyFilter === 'Mod+=')
|
||||
|
||||
layoutShortcut.handler(createKeyboardEvent())
|
||||
fitViewShortcut.handler(createKeyboardEvent())
|
||||
halfZoomShortcut.handler(createKeyboardEvent())
|
||||
zoomOutShortcut.handler(createKeyboardEvent())
|
||||
zoomInShortcut.handler(createKeyboardEvent())
|
||||
triggerShortcut(layoutShortcut)
|
||||
triggerShortcut(fitViewShortcut)
|
||||
triggerShortcut(halfZoomShortcut)
|
||||
triggerShortcut(zoomOutShortcut)
|
||||
triggerShortcut(zoomInShortcut)
|
||||
|
||||
expect(mockHandleLayout).toHaveBeenCalledTimes(1)
|
||||
expect(mockFitView).toHaveBeenCalledTimes(1)
|
||||
@ -176,11 +242,11 @@ describe('useShortcuts', () => {
|
||||
},
|
||||
])
|
||||
|
||||
renderWorkflowHook(() => useShortcuts())
|
||||
renderWorkflowHook(() => useWorkflowHotkeys())
|
||||
|
||||
const copyShortcut = findRegistration(registration => registration.keyFilter === 'ctrl.c' || registration.keyFilter === 'meta.c')
|
||||
const copyShortcut = findRegistration(registration => registration.keyFilter === 'Mod+C')
|
||||
const event = createKeyboardEvent()
|
||||
copyShortcut.handler(event)
|
||||
triggerShortcut(copyShortcut, event)
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalled()
|
||||
expect(mockHandleNodesCopy).toHaveBeenCalledTimes(1)
|
||||
@ -188,28 +254,44 @@ describe('useShortcuts', () => {
|
||||
getSelectionSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('dims on shift down, undims on shift up, and responds to zen toggle events', () => {
|
||||
const { unmount } = renderWorkflowHook(() => useShortcuts())
|
||||
it('dims while shift is held, undims when released, and responds to zen toggle events', () => {
|
||||
const { rerender, unmount } = renderWorkflowHook(() => useWorkflowHotkeys())
|
||||
|
||||
const shiftDownShortcut = findRegistration(registration => registration.keyFilter === 'shift' && registration.options?.events?.[0] === 'keydown')
|
||||
const shiftUpShortcut = findRegistration(registration => typeof registration.keyFilter === 'function' && registration.options?.events?.[0] === 'keyup')
|
||||
mockUseKeyHold.mockReturnValue(true)
|
||||
rerender()
|
||||
|
||||
shiftDownShortcut.handler(createKeyboardEvent())
|
||||
shiftUpShortcut.handler({ ...createKeyboardEvent(), key: 'Shift' } as KeyboardEvent)
|
||||
mockUseKeyHold.mockReturnValue(false)
|
||||
rerender()
|
||||
|
||||
expect(mockDimOtherNodes).toHaveBeenCalledTimes(1)
|
||||
expect(mockUndimAllNodes).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event(ZEN_TOGGLE_EVENT))
|
||||
emitWorkflowCommand(WorkflowCommand.ToggleCanvasMaximize)
|
||||
})
|
||||
expect(mockHandleToggleMaximizeCanvas).toHaveBeenCalledTimes(1)
|
||||
|
||||
unmount()
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event(ZEN_TOGGLE_EVENT))
|
||||
emitWorkflowCommand(WorkflowCommand.ToggleCanvasMaximize)
|
||||
})
|
||||
expect(mockHandleToggleMaximizeCanvas).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not dim when shift is held inside editable inputs', () => {
|
||||
const input = document.createElement('input')
|
||||
document.body.appendChild(input)
|
||||
input.focus()
|
||||
|
||||
const { rerender } = renderWorkflowHook(() => useWorkflowHotkeys())
|
||||
|
||||
mockUseKeyHold.mockReturnValue(true)
|
||||
rerender()
|
||||
|
||||
expect(mockDimOtherNodes).not.toHaveBeenCalled()
|
||||
expect(mockUndimAllNodes).not.toHaveBeenCalled()
|
||||
|
||||
input.remove()
|
||||
})
|
||||
})
|
||||
|
||||
@ -125,8 +125,14 @@ describe('useWorkflowHistory', () => {
|
||||
result.current.onRedo(onRedo)
|
||||
})
|
||||
|
||||
const undoSpy = vi.spyOn(result.current.store.temporal.getState(), 'undo')
|
||||
const redoSpy = vi.spyOn(result.current.store.temporal.getState(), 'redo')
|
||||
const temporalState = result.current.store.temporal.getState()
|
||||
const undoSpy = vi.fn()
|
||||
const redoSpy = vi.fn()
|
||||
vi.spyOn(result.current.store.temporal, 'getState').mockReturnValue({
|
||||
...temporalState,
|
||||
undo: undoSpy,
|
||||
redo: redoSpy,
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.undo()
|
||||
|
||||
@ -13,7 +13,6 @@ export * from './use-panel-interactions'
|
||||
export * from './use-selection-interactions'
|
||||
export * from './use-serial-async-callback'
|
||||
export * from './use-set-workflow-vars-with-value'
|
||||
export * from './use-shortcuts'
|
||||
export * from './use-tool-icon'
|
||||
export * from './use-workflow'
|
||||
export * from './use-workflow-comment'
|
||||
|
||||
@ -1,293 +0,0 @@
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import { ZEN_TOGGLE_EVENT } from '@/app/components/goto-anything/actions/commands/zen'
|
||||
import {
|
||||
useEdgesInteractions,
|
||||
useNodesInteractions,
|
||||
useNodesSyncDraft,
|
||||
useWorkflowCanvasMaximize,
|
||||
useWorkflowMoveMode,
|
||||
useWorkflowOrganize,
|
||||
} from '.'
|
||||
import { collaborationManager } from '../collaboration/core/collaboration-manager'
|
||||
import { useWorkflowStore } from '../store'
|
||||
import {
|
||||
getKeyboardKeyCodeBySystem,
|
||||
isEventTargetInputArea,
|
||||
} from '../utils'
|
||||
import { useWorkflowHistoryStore } from '../workflow-history-store'
|
||||
|
||||
export const useShortcuts = (): void => {
|
||||
const {
|
||||
handleNodesCopy,
|
||||
handleNodesPaste,
|
||||
handleNodesDuplicate,
|
||||
handleNodesDelete,
|
||||
handleHistoryBack,
|
||||
handleHistoryForward,
|
||||
dimOtherNodes,
|
||||
undimAllNodes,
|
||||
} = useNodesInteractions()
|
||||
const { shortcutsEnabled: workflowHistoryShortcutsEnabled } = useWorkflowHistoryStore()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const { handleEdgeDelete } = useEdgesInteractions()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const {
|
||||
handleModeHand,
|
||||
handleModePointer,
|
||||
handleModeComment,
|
||||
isCommentModeAvailable,
|
||||
} = useWorkflowMoveMode()
|
||||
const { handleLayout } = useWorkflowOrganize()
|
||||
const { handleToggleMaximizeCanvas } = useWorkflowCanvasMaximize()
|
||||
|
||||
const {
|
||||
zoomTo,
|
||||
getZoom,
|
||||
fitView,
|
||||
getNodes,
|
||||
} = useReactFlow()
|
||||
|
||||
// Zoom out to a minimum of 0.25 for shortcut
|
||||
const constrainedZoomOut = () => {
|
||||
const currentZoom = getZoom()
|
||||
const newZoom = Math.max(currentZoom - 0.1, 0.25)
|
||||
zoomTo(newZoom)
|
||||
}
|
||||
|
||||
// Zoom in to a maximum of 2 for shortcut
|
||||
const constrainedZoomIn = () => {
|
||||
const currentZoom = getZoom()
|
||||
const newZoom = Math.min(currentZoom + 0.1, 2)
|
||||
zoomTo(newZoom)
|
||||
}
|
||||
|
||||
const shouldHandleShortcut = useCallback((e: KeyboardEvent) => {
|
||||
return !isEventTargetInputArea(e.target as HTMLElement)
|
||||
}, [])
|
||||
|
||||
const shouldHandleCopy = useCallback(() => {
|
||||
// Box selection can leave incidental DOM text selection behind while the
|
||||
// workflow selection itself lives on node.data._isBundled.
|
||||
if (getNodes().some(node => node.data._isBundled))
|
||||
return true
|
||||
|
||||
const selection = document.getSelection()
|
||||
return !selection || selection.isCollapsed || !selection.rangeCount
|
||||
}, [getNodes])
|
||||
|
||||
useKeyPress(['delete', 'backspace'], (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
handleNodesDelete()
|
||||
handleEdgeDelete()
|
||||
}
|
||||
})
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.c`, (e) => {
|
||||
const { showDebugAndPreviewPanel } = workflowStore.getState()
|
||||
if (shouldHandleShortcut(e) && shouldHandleCopy() && !showDebugAndPreviewPanel) {
|
||||
e.preventDefault()
|
||||
handleNodesCopy()
|
||||
}
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.v`, (e) => {
|
||||
const { showDebugAndPreviewPanel } = workflowStore.getState()
|
||||
if (shouldHandleShortcut(e) && !showDebugAndPreviewPanel) {
|
||||
e.preventDefault()
|
||||
handleNodesPaste()
|
||||
}
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.d`, (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
handleNodesDuplicate()
|
||||
}
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('alt')}.r`, (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
// @ts-expect-error - Dynamic property added by run-and-history component
|
||||
if (window._toggleTestRunDropdown) {
|
||||
// @ts-expect-error - Dynamic property added by run-and-history component
|
||||
window._toggleTestRunDropdown()
|
||||
}
|
||||
}
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.z`, (e) => {
|
||||
const { showDebugAndPreviewPanel } = workflowStore.getState()
|
||||
if (shouldHandleShortcut(e) && !showDebugAndPreviewPanel) {
|
||||
e.preventDefault()
|
||||
if (workflowHistoryShortcutsEnabled)
|
||||
handleHistoryBack()
|
||||
}
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
useKeyPress(
|
||||
[`${getKeyboardKeyCodeBySystem('ctrl')}.y`, `${getKeyboardKeyCodeBySystem('ctrl')}.shift.z`],
|
||||
(e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
if (workflowHistoryShortcutsEnabled)
|
||||
handleHistoryForward()
|
||||
}
|
||||
},
|
||||
{ exactMatch: true, useCapture: true },
|
||||
)
|
||||
|
||||
useKeyPress('h', (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
handleModeHand()
|
||||
}
|
||||
}, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
useKeyPress('v', (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
handleModePointer()
|
||||
}
|
||||
}, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
useKeyPress('c', (e) => {
|
||||
if (shouldHandleShortcut(e) && isCommentModeAvailable) {
|
||||
e.preventDefault()
|
||||
handleModeComment()
|
||||
}
|
||||
}, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.o`, (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
handleLayout()
|
||||
}
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
useKeyPress('f', (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
handleToggleMaximizeCanvas()
|
||||
}
|
||||
}, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.1`, (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
fitView()
|
||||
handleSyncWorkflowDraft()
|
||||
}
|
||||
}, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
useKeyPress('shift.1', (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
zoomTo(1)
|
||||
handleSyncWorkflowDraft()
|
||||
}
|
||||
}, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
useKeyPress('shift.5', (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
zoomTo(0.5)
|
||||
handleSyncWorkflowDraft()
|
||||
}
|
||||
}, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.dash`, (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
constrainedZoomOut()
|
||||
handleSyncWorkflowDraft()
|
||||
}
|
||||
}, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.equalsign`, (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
constrainedZoomIn()
|
||||
handleSyncWorkflowDraft()
|
||||
}
|
||||
}, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.l`, (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
collaborationManager.downloadGraphImportLog()
|
||||
}
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
// Shift ↓
|
||||
useKeyPress(
|
||||
'shift',
|
||||
(e) => {
|
||||
if (shouldHandleShortcut(e))
|
||||
dimOtherNodes()
|
||||
},
|
||||
{
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
events: ['keydown'],
|
||||
},
|
||||
)
|
||||
|
||||
// Shift ↑
|
||||
useKeyPress(
|
||||
(e) => {
|
||||
return e.key === 'Shift'
|
||||
},
|
||||
(e) => {
|
||||
if (shouldHandleShortcut(e))
|
||||
undimAllNodes()
|
||||
},
|
||||
{
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
events: ['keyup'],
|
||||
},
|
||||
)
|
||||
|
||||
// Listen for zen toggle event from /zen command
|
||||
useEffect(() => {
|
||||
const handleZenToggle = () => {
|
||||
handleToggleMaximizeCanvas()
|
||||
}
|
||||
|
||||
window.addEventListener(ZEN_TOGGLE_EVENT, handleZenToggle)
|
||||
return () => {
|
||||
window.removeEventListener(ZEN_TOGGLE_EVENT, handleZenToggle)
|
||||
}
|
||||
}, [handleToggleMaximizeCanvas])
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import type { WorkflowHistoryEventMeta } from '../workflow-history-store'
|
||||
import type { WorkflowHistoryEventMeta } from '../store/workflow/history-slice'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import {
|
||||
useCallback,
|
||||
|
||||
@ -88,7 +88,6 @@ import {
|
||||
usePanelInteractions,
|
||||
useSelectionInteractions,
|
||||
useSetWorkflowVarsWithValue,
|
||||
useShortcuts,
|
||||
useWorkflow,
|
||||
useWorkflowReadOnly,
|
||||
useWorkflowRefreshDraft,
|
||||
@ -111,19 +110,19 @@ import Operator from './operator'
|
||||
import Control from './operator/control'
|
||||
import PanelContextmenu from './panel-contextmenu'
|
||||
import SelectionContextmenu from './selection-contextmenu'
|
||||
import { useWorkflowHotkeys } from './shortcuts/use-workflow-hotkeys'
|
||||
import CustomSimpleNode from './simple-node'
|
||||
import { CUSTOM_SIMPLE_NODE } from './simple-node/constants'
|
||||
import {
|
||||
useStore,
|
||||
useWorkflowStore,
|
||||
} from './store'
|
||||
} from './store/workflow'
|
||||
import SyncingDataModal from './syncing-data-modal'
|
||||
import {
|
||||
ControlMode,
|
||||
WorkflowRunningStatus,
|
||||
} from './types'
|
||||
import { setupScrollToNodeListener } from './utils/node-navigation'
|
||||
import { WorkflowHistoryProvider } from './workflow-history-store'
|
||||
import 'reactflow/dist/style.css'
|
||||
import './style.css'
|
||||
|
||||
@ -530,7 +529,7 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
||||
},
|
||||
})
|
||||
|
||||
useShortcuts()
|
||||
useWorkflowHotkeys()
|
||||
// Initialize workflow node search functionality
|
||||
useWorkflowSearch()
|
||||
|
||||
@ -794,6 +793,30 @@ type WorkflowWithDefaultContextProps
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const WorkflowHistoryStoreInitializer = ({
|
||||
nodes,
|
||||
edges,
|
||||
children,
|
||||
}: WorkflowWithDefaultContextProps) => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const initializedRef = useRef(false)
|
||||
|
||||
if (!initializedRef.current) {
|
||||
workflowStore.temporal.getState().pause()
|
||||
workflowStore.getState().setWorkflowHistory({
|
||||
nodes,
|
||||
edges,
|
||||
workflowHistoryEvent: undefined,
|
||||
workflowHistoryEventMeta: undefined,
|
||||
})
|
||||
workflowStore.temporal.getState().clear()
|
||||
workflowStore.temporal.getState().resume()
|
||||
initializedRef.current = true
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
const WorkflowWithDefaultContext = ({
|
||||
nodes,
|
||||
edges,
|
||||
@ -801,14 +824,14 @@ const WorkflowWithDefaultContext = ({
|
||||
}: WorkflowWithDefaultContextProps) => {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<WorkflowHistoryProvider
|
||||
<WorkflowHistoryStoreInitializer
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
>
|
||||
<DatasetsDetailProvider nodes={nodes}>
|
||||
{children}
|
||||
</DatasetsDetailProvider>
|
||||
</WorkflowHistoryProvider>
|
||||
</WorkflowHistoryStoreInitializer>
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@ -35,10 +35,15 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => {
|
||||
<div>{children}</div>
|
||||
</DropdownMenuContext>
|
||||
),
|
||||
DropdownMenuTrigger: ({ children, render }: { children: ReactNode, render?: ReactNode }) => {
|
||||
DropdownMenuTrigger: ({ children, render }: { children: ReactNode, render?: React.ReactElement<{ children?: ReactNode }> }) => {
|
||||
const { open, setOpen } = useDropdownMenuContext()
|
||||
if (render)
|
||||
return <div onClick={() => setOpen(!open)}>{children}</div>
|
||||
if (render) {
|
||||
return React.cloneElement(
|
||||
render,
|
||||
{ onClick: () => setOpen(!open) } as Record<string, unknown>,
|
||||
children ?? render.props.children,
|
||||
)
|
||||
}
|
||||
|
||||
return <button type="button" onClick={() => setOpen(!open)}>{children}</button>
|
||||
},
|
||||
@ -50,8 +55,8 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => {
|
||||
})
|
||||
|
||||
vi.mock('@langgenius/dify-ui/button', () => ({
|
||||
Button: ({ children, className }: { children: ReactNode, className?: string }) => (
|
||||
<button type="button" className={className}>
|
||||
Button: ({ children, className, onClick }: { children: ReactNode, className?: string, onClick?: React.MouseEventHandler<HTMLButtonElement> }) => (
|
||||
<button type="button" className={className} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
|
||||
@ -89,11 +89,13 @@ const Operator = ({
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<DropdownMenuTrigger render={<div />}>
|
||||
<Button className="h-6 w-6 p-0">
|
||||
<span aria-hidden className="i-ri-more-fill h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<Button className="h-6 w-6 p-0" aria-label={t('common.moreActions', { ns: 'workflow' })}>
|
||||
<span aria-hidden className="i-ri-more-fill h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
|
||||
@ -13,7 +13,7 @@ import {
|
||||
useNodesReadOnly,
|
||||
useNodesSyncDraft,
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
|
||||
import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import {
|
||||
canRunBySingle,
|
||||
@ -67,9 +67,10 @@ const PanelOperatorPopup = ({
|
||||
<div className="p-1">
|
||||
{
|
||||
canRunBySingle(data.type, isChildNode) && (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
className={`
|
||||
flex h-8 cursor-pointer items-center rounded-lg px-3 text-sm text-text-secondary
|
||||
flex h-8 w-full cursor-pointer items-center rounded-lg px-3 text-sm text-text-secondary
|
||||
hover:bg-state-base-hover
|
||||
`}
|
||||
onClick={() => {
|
||||
@ -80,7 +81,7 @@ const PanelOperatorPopup = ({
|
||||
}}
|
||||
>
|
||||
{t('panel.runThisStep', { ns: 'workflow' })}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
{
|
||||
@ -104,26 +105,28 @@ const PanelOperatorPopup = ({
|
||||
!nodeMetaData.isSingleton && (
|
||||
<>
|
||||
<div className="p-1">
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
onClosePopup()
|
||||
handleNodesCopy(id)
|
||||
}}
|
||||
>
|
||||
{t('common.copy', { ns: 'workflow' })}
|
||||
<ShortcutsName keys={['ctrl', 'c']} />
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
<ShortcutKbd shortcut="workflow.copy" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
onClosePopup()
|
||||
handleNodesDuplicate(id)
|
||||
}}
|
||||
>
|
||||
{t('common.duplicate', { ns: 'workflow' })}
|
||||
<ShortcutsName keys={['ctrl', 'd']} />
|
||||
</div>
|
||||
<ShortcutKbd shortcut="workflow.duplicate" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-px bg-divider-regular"></div>
|
||||
</>
|
||||
@ -133,16 +136,17 @@ const PanelOperatorPopup = ({
|
||||
!nodeMetaData.isUndeletable && (
|
||||
<>
|
||||
<div className="p-1">
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
className={`
|
||||
flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary
|
||||
flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary
|
||||
hover:bg-state-destructive-hover hover:text-text-destructive
|
||||
`}
|
||||
onClick={() => handleNodeDelete(id)}
|
||||
>
|
||||
{t('operation.delete', { ns: 'common' })}
|
||||
<ShortcutsName keys={['del']} />
|
||||
</div>
|
||||
<ShortcutKbd shortcut="workflow.delete" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-px bg-divider-regular"></div>
|
||||
</>
|
||||
|
||||
@ -43,7 +43,7 @@ const Field: FC<Props> = ({
|
||||
disabled={depth !== MAX_DEPTH + 1}
|
||||
render={(
|
||||
<div
|
||||
className={cn('flex items-center justify-between rounded-md pr-2', !readonly && 'hover:bg-state-base-hover', depth !== MAX_DEPTH + 1 && 'cursor-pointer')}
|
||||
className={cn('flex items-center justify-between rounded-md pr-2 outline-none focus:outline-none focus-visible:outline-none', !readonly && 'hover:bg-state-base-hover', depth !== MAX_DEPTH + 1 && 'cursor-pointer')}
|
||||
onMouseDown={() => !readonly && onSelect?.([...valueSelector, name])}
|
||||
>
|
||||
<div className="flex grow items-stretch">
|
||||
|
||||
@ -29,6 +29,7 @@ import {
|
||||
} from './var-reference-vars.helpers'
|
||||
|
||||
const VAR_SEARCH_INPUT_CLASS_NAME = 'var-search-input'
|
||||
export const VAR_REFERENCE_CHILD_POPUP_CLASS_NAME = 'var-reference-vars-child-popup'
|
||||
|
||||
const resolveValueSelector = ({
|
||||
itemData,
|
||||
@ -210,7 +211,7 @@ const Item: FC<ItemProps> = ({
|
||||
className={cn(
|
||||
(isObj || isStructureOutput) ? 'pr-1' : 'pr-[18px]',
|
||||
(isHovering || isSelected) && ((isObj || isStructureOutput) ? 'bg-components-panel-on-panel-item-bg-hover' : 'bg-state-base-hover'),
|
||||
'relative flex h-6 w-full cursor-pointer items-center rounded-md pl-3',
|
||||
'relative flex h-6 w-full cursor-pointer items-center rounded-md pl-3 outline-none focus:outline-none focus-visible:outline-none',
|
||||
className,
|
||||
)}
|
||||
data-selected={isSelected ? 'true' : 'false'}
|
||||
@ -263,7 +264,7 @@ const Item: FC<ItemProps> = ({
|
||||
<PopoverContent
|
||||
placement="left-start"
|
||||
sideOffset={0}
|
||||
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
popupClassName={cn(VAR_REFERENCE_CHILD_POPUP_CLASS_NAME, 'border-none bg-transparent p-0 shadow-none backdrop-blur-none')}
|
||||
positionerProps={{
|
||||
style: {
|
||||
zIndex: zIndex || 100,
|
||||
|
||||
@ -284,13 +284,15 @@ const BaseNode: FC<BaseNodeProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<NodeHeaderMeta
|
||||
data={data}
|
||||
hasVarValue={hasVarValue}
|
||||
isLoading={isLoading}
|
||||
loopIndex={LoopIndex}
|
||||
t={t}
|
||||
/>
|
||||
<div className="flex shrink-0 items-center">
|
||||
<NodeHeaderMeta
|
||||
data={data}
|
||||
hasVarValue={hasVarValue}
|
||||
isLoading={isLoading}
|
||||
loopIndex={LoopIndex}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<NodeBody
|
||||
data={data}
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
import type { FC } from 'react'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
|
||||
import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils'
|
||||
import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
|
||||
import { useWorkflowShortcut } from '@/app/components/workflow/shortcuts/use-workflow-hotkeys'
|
||||
|
||||
type AdvancedActionsProps = {
|
||||
isConfirmDisabled: boolean
|
||||
@ -19,12 +18,11 @@ const AdvancedActions: FC<AdvancedActionsProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
useKeyPress([`${getKeyboardKeyCodeBySystem('ctrl')}.enter`], (e) => {
|
||||
e.preventDefault()
|
||||
useWorkflowShortcut('workflow.json-schema-confirm', () => {
|
||||
onConfirm()
|
||||
}, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
enabled: !isConfirmDisabled,
|
||||
ignoreInputs: false,
|
||||
})
|
||||
|
||||
return (
|
||||
@ -40,7 +38,7 @@ const AdvancedActions: FC<AdvancedActionsProps> = ({
|
||||
onClick={onConfirm}
|
||||
>
|
||||
<span>{t('operation.confirm', { ns: 'common' })}</span>
|
||||
<ShortcutsName keys={['ctrl', '⏎']} bgColor="white" />
|
||||
<ShortcutKbd shortcut="workflow.json-schema-confirm" bgColor="white" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -14,7 +14,6 @@ const {
|
||||
mockHandleNodesDuplicate,
|
||||
mockHandleShowAuthorChange,
|
||||
mockHandleThemeChange,
|
||||
mockSetShortcutsEnabled,
|
||||
} = vi.hoisted(() => ({
|
||||
mockHandleEditorChange: vi.fn(),
|
||||
mockHandleNodeDataUpdateWithSyncDraft: vi.fn(),
|
||||
@ -23,7 +22,6 @@ const {
|
||||
mockHandleNodesDuplicate: vi.fn(),
|
||||
mockHandleShowAuthorChange: vi.fn(),
|
||||
mockHandleThemeChange: vi.fn(),
|
||||
mockSetShortcutsEnabled: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks', async (importOriginal) => {
|
||||
@ -49,12 +47,6 @@ vi.mock('../hooks', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../workflow-history-store', () => ({
|
||||
useWorkflowHistoryStore: () => ({
|
||||
setShortcutsEnabled: mockSetShortcutsEnabled,
|
||||
}),
|
||||
}))
|
||||
|
||||
const createNoteData = (overrides: Partial<NoteNodeType> = {}): NoteNodeType => ({
|
||||
title: '',
|
||||
desc: '',
|
||||
|
||||
@ -12,8 +12,7 @@ import {
|
||||
useNodesInteractions,
|
||||
} from '../hooks'
|
||||
import NodeResizer from '../nodes/_base/components/node-resizer'
|
||||
import { useStore } from '../store'
|
||||
import { useWorkflowHistoryStore } from '../workflow-history-store'
|
||||
import { useStore } from '../store/workflow'
|
||||
import { THEME_MAP } from './constants'
|
||||
import { useNote } from './hooks'
|
||||
import {
|
||||
@ -36,6 +35,7 @@ const NoteNode = ({
|
||||
}: NodeProps<NoteNodeType>) => {
|
||||
const { t } = useTranslation()
|
||||
const controlPromptEditorRerenderKey = useStore(s => s.controlPromptEditorRerenderKey)
|
||||
const setHistoryShortcutsEnabled = useStore(s => s.setHistoryShortcutsEnabled)
|
||||
const ref = useRef<HTMLDivElement | null>(null)
|
||||
const theme = data.theme
|
||||
const {
|
||||
@ -54,8 +54,6 @@ const NoteNode = ({
|
||||
handleNodeDataUpdateWithSyncDraft({ id, data: { selected: false } })
|
||||
}, ref)
|
||||
|
||||
const { setShortcutsEnabled } = useWorkflowHistoryStore()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@ -117,7 +115,7 @@ const NoteNode = ({
|
||||
containerElement={ref.current}
|
||||
placeholder={t('nodes.note.editor.placeholder', { ns: 'workflow' }) || ''}
|
||||
onChange={handleEditorChange}
|
||||
setShortcutsEnabled={setShortcutsEnabled}
|
||||
setHistoryShortcutsEnabled={setHistoryShortcutsEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -93,17 +93,17 @@ describe('Editor', () => {
|
||||
// Focus and blur should toggle workflow shortcuts while editing content.
|
||||
describe('Focus Management', () => {
|
||||
it('should disable shortcuts on focus and re-enable them on blur-sm', () => {
|
||||
const setShortcutsEnabled = vi.fn()
|
||||
const setHistoryShortcutsEnabled = vi.fn()
|
||||
|
||||
renderEditor({ setShortcutsEnabled })
|
||||
renderEditor({ setHistoryShortcutsEnabled })
|
||||
|
||||
const contentEditable = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.focus(contentEditable)
|
||||
fireEvent.blur(contentEditable)
|
||||
|
||||
expect(setShortcutsEnabled).toHaveBeenNthCalledWith(1, false)
|
||||
expect(setShortcutsEnabled).toHaveBeenNthCalledWith(2, true)
|
||||
expect(setHistoryShortcutsEnabled).toHaveBeenNthCalledWith(1, false)
|
||||
expect(setHistoryShortcutsEnabled).toHaveBeenNthCalledWith(2, true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -22,13 +22,13 @@ type EditorProps = {
|
||||
placeholder?: string
|
||||
onChange?: (editorState: EditorState) => void
|
||||
containerElement: HTMLDivElement | null
|
||||
setShortcutsEnabled?: (v: boolean) => void
|
||||
setHistoryShortcutsEnabled?: (v: boolean) => void
|
||||
}
|
||||
const Editor = ({
|
||||
placeholder = 'write you note...',
|
||||
onChange,
|
||||
containerElement,
|
||||
setShortcutsEnabled,
|
||||
setHistoryShortcutsEnabled,
|
||||
}: EditorProps) => {
|
||||
const handleEditorChange = useCallback((editorState: EditorState) => {
|
||||
onChange?.(editorState)
|
||||
@ -40,8 +40,8 @@ const Editor = ({
|
||||
contentEditable={(
|
||||
<div>
|
||||
<ContentEditable
|
||||
onFocus={() => setShortcutsEnabled?.(false)}
|
||||
onBlur={() => setShortcutsEnabled?.(true)}
|
||||
onFocus={() => setHistoryShortcutsEnabled?.(false)}
|
||||
onBlur={() => setHistoryShortcutsEnabled?.(true)}
|
||||
spellCheck={false}
|
||||
className="h-full w-full text-text-secondary caret-primary-600 outline-hidden"
|
||||
/>
|
||||
|
||||
@ -12,7 +12,7 @@ import {
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
|
||||
import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
|
||||
|
||||
export type OperatorProps = {
|
||||
onCopy: () => void
|
||||
@ -69,7 +69,7 @@ const Operator = ({
|
||||
}}
|
||||
>
|
||||
{t('common.copy', { ns: 'workflow' })}
|
||||
<ShortcutsName keys={['ctrl', 'c']} />
|
||||
<ShortcutKbd shortcut="workflow.copy" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="justify-between rounded-md px-3 text-sm text-text-secondary"
|
||||
@ -79,7 +79,7 @@ const Operator = ({
|
||||
}}
|
||||
>
|
||||
{t('common.duplicate', { ns: 'workflow' })}
|
||||
<ShortcutsName keys={['ctrl', 'd']} />
|
||||
<ShortcutKbd shortcut="workflow.duplicate" />
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
<DropdownMenuSeparator className="my-0" />
|
||||
@ -107,7 +107,7 @@ const Operator = ({
|
||||
}}
|
||||
>
|
||||
{t('operation.delete', { ns: 'common' })}
|
||||
<ShortcutsName keys={['del']} />
|
||||
<ShortcutKbd shortcut="workflow.delete" />
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -47,7 +47,7 @@ const Control = () => {
|
||||
} = useNodesReadOnly()
|
||||
const { handleToggleMaximizeCanvas } = useWorkflowCanvasMaximize()
|
||||
|
||||
const addNote = (e: MouseEvent<HTMLDivElement>) => {
|
||||
const addNote = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
@ -59,19 +59,25 @@ const Control = () => {
|
||||
<div className="pointer-events-auto flex flex-col items-center rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 text-text-tertiary shadow-lg">
|
||||
<AddBlock />
|
||||
<TipPopup title={t('nodes.note.addNote', { ns: 'workflow' })}>
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('nodes.note.addNote', { ns: 'workflow' })}
|
||||
disabled={nodesReadOnly}
|
||||
className={cn(
|
||||
'ml-px flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover hover:text-text-secondary',
|
||||
`${nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled'}`,
|
||||
)}
|
||||
onClick={addNote}
|
||||
>
|
||||
<RiStickyNoteAddLine className="h-4 w-4" />
|
||||
</div>
|
||||
<RiStickyNoteAddLine aria-hidden className="h-4 w-4" />
|
||||
</button>
|
||||
</TipPopup>
|
||||
<Divider className="my-1 w-3.5" />
|
||||
<TipPopup title={t('common.pointerMode', { ns: 'workflow' })} shortcuts={['v']}>
|
||||
<div
|
||||
<TipPopup title={t('common.pointerMode', { ns: 'workflow' })} shortcut="workflow.pointer-mode">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.pointerMode', { ns: 'workflow' })}
|
||||
disabled={nodesReadOnly}
|
||||
className={cn(
|
||||
'mr-px flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg',
|
||||
controlMode === ControlMode.Pointer ? 'bg-state-accent-active text-text-accent' : 'hover:bg-state-base-hover hover:text-text-secondary',
|
||||
@ -79,11 +85,14 @@ const Control = () => {
|
||||
)}
|
||||
onClick={handleModePointer}
|
||||
>
|
||||
<RiCursorLine className="h-4 w-4" />
|
||||
</div>
|
||||
<RiCursorLine aria-hidden className="h-4 w-4" />
|
||||
</button>
|
||||
</TipPopup>
|
||||
<TipPopup title={t('common.handMode', { ns: 'workflow' })} shortcuts={['h']}>
|
||||
<div
|
||||
<TipPopup title={t('common.handMode', { ns: 'workflow' })} shortcut="workflow.hand-mode">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.handMode', { ns: 'workflow' })}
|
||||
disabled={nodesReadOnly}
|
||||
className={cn(
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg',
|
||||
controlMode === ControlMode.Hand ? 'bg-state-accent-active text-text-accent' : 'hover:bg-state-base-hover hover:text-text-secondary',
|
||||
@ -91,12 +100,15 @@ const Control = () => {
|
||||
)}
|
||||
onClick={handleModeHand}
|
||||
>
|
||||
<RiHand className="h-4 w-4" />
|
||||
</div>
|
||||
<RiHand aria-hidden className="h-4 w-4" />
|
||||
</button>
|
||||
</TipPopup>
|
||||
{isCommentModeAvailable && (
|
||||
<TipPopup title={t('common.commentMode', { ns: 'workflow' })} shortcuts={['c']}>
|
||||
<div
|
||||
<TipPopup title={t('common.commentMode', { ns: 'workflow' })} shortcut="workflow.comment-mode">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.commentMode', { ns: 'workflow' })}
|
||||
disabled={nodesReadOnly}
|
||||
className={cn(
|
||||
'ml-[1px] flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg',
|
||||
controlMode === ControlMode.Comment ? 'bg-state-accent-active text-text-accent' : 'hover:bg-state-base-hover hover:text-text-secondary',
|
||||
@ -104,24 +116,30 @@ const Control = () => {
|
||||
)}
|
||||
onClick={handleModeComment}
|
||||
>
|
||||
<Comment className="h-4 w-4" />
|
||||
</div>
|
||||
<Comment aria-hidden className="h-4 w-4" />
|
||||
</button>
|
||||
</TipPopup>
|
||||
)}
|
||||
<Divider className="my-1 w-3.5" />
|
||||
<TipPopup title={t('panel.organizeBlocks', { ns: 'workflow' })} shortcuts={['ctrl', 'o']}>
|
||||
<div
|
||||
<TipPopup title={t('panel.organizeBlocks', { ns: 'workflow' })} shortcut="workflow.organize">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('panel.organizeBlocks', { ns: 'workflow' })}
|
||||
disabled={nodesReadOnly}
|
||||
className={cn(
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover hover:text-text-secondary',
|
||||
`${nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled'}`,
|
||||
)}
|
||||
onClick={handleLayout}
|
||||
>
|
||||
<RiFunctionAddLine className="h-4 w-4" />
|
||||
</div>
|
||||
<RiFunctionAddLine aria-hidden className="h-4 w-4" />
|
||||
</button>
|
||||
</TipPopup>
|
||||
<TipPopup title={maximizeCanvas ? t('panel.minimize', { ns: 'workflow' }) : t('panel.maximize', { ns: 'workflow' })} shortcuts={['f']}>
|
||||
<div
|
||||
<TipPopup title={maximizeCanvas ? t('panel.minimize', { ns: 'workflow' }) : t('panel.maximize', { ns: 'workflow' })} shortcut="workflow.toggle-maximize">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={maximizeCanvas ? t('panel.minimize', { ns: 'workflow' }) : t('panel.maximize', { ns: 'workflow' })}
|
||||
disabled={nodesReadOnly}
|
||||
className={cn(
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover hover:text-text-secondary',
|
||||
maximizeCanvas ? 'bg-state-accent-active text-text-accent hover:text-text-accent' : 'hover:bg-state-base-hover hover:text-text-secondary',
|
||||
@ -129,9 +147,9 @@ const Control = () => {
|
||||
)}
|
||||
onClick={handleToggleMaximizeCanvas}
|
||||
>
|
||||
{maximizeCanvas && <RiAspectRatioFill className="h-4 w-4" />}
|
||||
{!maximizeCanvas && <RiAspectRatioLine className="h-4 w-4" />}
|
||||
</div>
|
||||
{maximizeCanvas && <RiAspectRatioFill aria-hidden className="h-4 w-4" />}
|
||||
{!maximizeCanvas && <RiAspectRatioLine aria-hidden className="h-4 w-4" />}
|
||||
</button>
|
||||
</TipPopup>
|
||||
<MoreActions />
|
||||
</div>
|
||||
|
||||
@ -1,32 +1,37 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { WorkflowShortcutId } from '../shortcuts/definitions'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@langgenius/dify-ui/tooltip'
|
||||
import { memo } from 'react'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import ShortcutsName from '../shortcuts-name'
|
||||
import { ShortcutKbd } from '../shortcuts/shortcut-kbd'
|
||||
|
||||
type TipPopupProps = {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
shortcuts?: string[]
|
||||
children: ReactElement
|
||||
shortcut?: WorkflowShortcutId
|
||||
}
|
||||
const TipPopup = ({
|
||||
title,
|
||||
children,
|
||||
shortcuts,
|
||||
shortcut,
|
||||
}: TipPopupProps) => {
|
||||
return (
|
||||
<Tooltip
|
||||
needsDelay={false}
|
||||
offset={4}
|
||||
popupClassName="p-0 bg-transparent"
|
||||
popupContent={(
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={children} />
|
||||
<TooltipContent
|
||||
sideOffset={4}
|
||||
className="max-w-none bg-transparent p-0 shadow-none"
|
||||
>
|
||||
<div className="flex items-center gap-1 rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg p-1.5 shadow-lg backdrop-blur-[5px]">
|
||||
<span className="system-xs-medium text-text-secondary">{title}</span>
|
||||
{
|
||||
shortcuts && <ShortcutsName keys={shortcuts} />
|
||||
shortcut && <ShortcutKbd shortcut={shortcut} />
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@ -23,7 +23,7 @@ import {
|
||||
useNodesSyncDraft,
|
||||
useWorkflowReadOnly,
|
||||
} from '../hooks'
|
||||
import ShortcutsName from '../shortcuts-name'
|
||||
import { ShortcutKbd } from '../shortcuts/shortcut-kbd'
|
||||
import TipPopup from './tip-popup'
|
||||
|
||||
enum ZoomType {
|
||||
@ -181,9 +181,12 @@ const ZoomInOut: FC<ZoomInOutProps> = ({
|
||||
<div className="flex h-8 w-[98px] items-center justify-between rounded-lg">
|
||||
<TipPopup
|
||||
title={t('operator.zoomOut', { ns: 'workflow' })}
|
||||
shortcuts={['ctrl', '-']}
|
||||
shortcut="workflow.zoom-out"
|
||||
>
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('operator.zoomOut', { ns: 'workflow' })}
|
||||
disabled={zoom <= 0.25}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg ${zoom <= 0.25 ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-black/5'}`}
|
||||
onClick={(e) => {
|
||||
if (zoom <= 0.25)
|
||||
@ -194,7 +197,7 @@ const ZoomInOut: FC<ZoomInOutProps> = ({
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className="i-ri-zoom-out-line h-4 w-4 text-text-tertiary hover:text-text-secondary" />
|
||||
</div>
|
||||
</button>
|
||||
</TipPopup>
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
@ -262,13 +265,13 @@ const ZoomInOut: FC<ZoomInOutProps> = ({
|
||||
</div>
|
||||
<div className="flex items-center space-x-0.5">
|
||||
{option.key === ZoomType.zoomToFit && (
|
||||
<ShortcutsName keys={['ctrl', '1']} />
|
||||
<ShortcutKbd shortcut="workflow.zoom-to-fit" />
|
||||
)}
|
||||
{option.key === ZoomType.zoomTo50 && (
|
||||
<ShortcutsName keys={['shift', '5']} />
|
||||
<ShortcutKbd shortcut="workflow.zoom-to-50" />
|
||||
)}
|
||||
{option.key === ZoomType.zoomTo100 && (
|
||||
<ShortcutsName keys={['shift', '1']} />
|
||||
<ShortcutKbd shortcut="workflow.zoom-to-100" />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
@ -281,9 +284,12 @@ const ZoomInOut: FC<ZoomInOutProps> = ({
|
||||
</DropdownMenu>
|
||||
<TipPopup
|
||||
title={t('operator.zoomIn', { ns: 'workflow' })}
|
||||
shortcuts={['ctrl', '+']}
|
||||
shortcut="workflow.zoom-in"
|
||||
>
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('operator.zoomIn', { ns: 'workflow' })}
|
||||
disabled={zoom >= 2}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg ${zoom >= 2 ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-black/5'}`}
|
||||
onClick={(e) => {
|
||||
if (zoom >= 2)
|
||||
@ -294,7 +300,7 @@ const ZoomInOut: FC<ZoomInOutProps> = ({
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className="i-ri-zoom-in-line h-4 w-4 text-text-tertiary hover:text-text-secondary" />
|
||||
</div>
|
||||
</button>
|
||||
</TipPopup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -15,7 +15,7 @@ import {
|
||||
} from './hooks'
|
||||
import AddBlock from './operator/add-block'
|
||||
import { useOperator } from './operator/hooks'
|
||||
import ShortcutsName from './shortcuts-name'
|
||||
import { ShortcutKbd } from './shortcuts/shortcut-kbd'
|
||||
import { useStore } from './store'
|
||||
|
||||
const PanelContextmenu = () => {
|
||||
@ -40,11 +40,12 @@ const PanelContextmenu = () => {
|
||||
|
||||
const renderTrigger = () => {
|
||||
return (
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
>
|
||||
{t('common.addBlock', { ns: 'workflow' })}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@ -68,8 +69,9 @@ const PanelContextmenu = () => {
|
||||
crossAxis: -4,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleAddNote()
|
||||
@ -77,11 +79,13 @@ const PanelContextmenu = () => {
|
||||
}}
|
||||
>
|
||||
{t('nodes.note.addNote', { ns: 'workflow' })}
|
||||
</div>
|
||||
</button>
|
||||
{isCommentModeAvailable && (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!pendingComment}
|
||||
className={cn(
|
||||
'flex h-8 items-center justify-between rounded-lg px-3 text-sm text-text-secondary',
|
||||
'flex h-8 w-full items-center justify-between rounded-lg px-3 text-sm text-text-secondary',
|
||||
pendingComment ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:bg-state-base-hover',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
@ -94,24 +98,27 @@ const PanelContextmenu = () => {
|
||||
}}
|
||||
>
|
||||
{t('comments.actions.addComment', { ns: 'workflow' })}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
handleStartWorkflowRun()
|
||||
handlePaneContextmenuCancel()
|
||||
}}
|
||||
>
|
||||
{t('common.run', { ns: 'workflow' })}
|
||||
<ShortcutsName keys={['alt', 'r']} />
|
||||
</div>
|
||||
<ShortcutKbd shortcut="workflow.open-test-run-menu" />
|
||||
</button>
|
||||
</div>
|
||||
<Divider className="m-0" />
|
||||
<div className="p-1">
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
disabled={!clipboardElements.length}
|
||||
className={cn(
|
||||
'flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary',
|
||||
'flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary',
|
||||
!clipboardElements.length ? 'cursor-not-allowed opacity-50' : 'hover:bg-state-base-hover',
|
||||
)}
|
||||
onClick={() => {
|
||||
@ -122,23 +129,25 @@ const PanelContextmenu = () => {
|
||||
}}
|
||||
>
|
||||
{t('common.pasteHere', { ns: 'workflow' })}
|
||||
<ShortcutsName keys={['ctrl', 'v']} />
|
||||
</div>
|
||||
<ShortcutKbd shortcut="workflow.paste" />
|
||||
</button>
|
||||
</div>
|
||||
<Divider className="m-0" />
|
||||
<div className="p-1">
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => exportCheck?.()}
|
||||
>
|
||||
{t('export', { ns: 'app' })}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => setShowImportDSLModal(true)}
|
||||
>
|
||||
{t('importApp', { ns: 'app' })}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -20,7 +20,7 @@ import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-co
|
||||
import { useNodesInteractions, useNodesReadOnly, useNodesSyncDraft } from './hooks'
|
||||
import { useSelectionInteractions } from './hooks/use-selection-interactions'
|
||||
import { useWorkflowHistory, WorkflowHistoryEvent } from './hooks/use-workflow-history'
|
||||
import ShortcutsName from './shortcuts-name'
|
||||
import { ShortcutKbd } from './shortcuts/shortcut-kbd'
|
||||
import { useStore, useWorkflowStore } from './store'
|
||||
|
||||
const AlignType = {
|
||||
@ -387,7 +387,7 @@ const SelectionContextmenu = () => {
|
||||
onClick={handleCopyNodes}
|
||||
>
|
||||
<span>{t('common.copy', { defaultValue: 'common.copy', ns: 'workflow' })}</span>
|
||||
<ShortcutsName keys={['ctrl', 'c']} />
|
||||
<ShortcutKbd shortcut="workflow.copy" />
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="justify-between px-3 text-text-secondary"
|
||||
@ -395,7 +395,7 @@ const SelectionContextmenu = () => {
|
||||
onClick={handleDuplicateNodes}
|
||||
>
|
||||
<span>{t('common.duplicate', { defaultValue: 'common.duplicate', ns: 'workflow' })}</span>
|
||||
<ShortcutsName keys={['ctrl', 'd']} />
|
||||
<ShortcutKbd shortcut="workflow.duplicate" />
|
||||
</ContextMenuItem>
|
||||
</ContextMenuGroup>
|
||||
<ContextMenuSeparator />
|
||||
@ -406,7 +406,7 @@ const SelectionContextmenu = () => {
|
||||
onClick={handleDeleteNodes}
|
||||
>
|
||||
<span>{t('operation.delete', { defaultValue: 'operation.delete', ns: 'common' })}</span>
|
||||
<ShortcutsName keys={['del']} />
|
||||
<ShortcutKbd shortcut="workflow.delete" />
|
||||
</ContextMenuItem>
|
||||
</ContextMenuGroup>
|
||||
<ContextMenuSeparator />
|
||||
|
||||
@ -0,0 +1,51 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { ShortcutKbd } from '../shortcut-kbd'
|
||||
|
||||
describe('ShortcutKbd', () => {
|
||||
it('renders shortcut chords as separate keycaps with the legacy visual classes', () => {
|
||||
const { container } = render(
|
||||
<ShortcutKbd
|
||||
shortcut="workflow.copy"
|
||||
platform="mac"
|
||||
bgColor="white"
|
||||
textColor="secondary"
|
||||
className="ml-2"
|
||||
/>,
|
||||
)
|
||||
|
||||
const wrapper = container.firstElementChild
|
||||
expect(wrapper).toHaveClass('flex', 'items-center', 'gap-0.5', 'ml-2')
|
||||
|
||||
const keys = container.querySelectorAll('kbd')
|
||||
expect(keys).toHaveLength(2)
|
||||
expect(screen.getByText('⌘')).toBeInTheDocument()
|
||||
expect(screen.getByText('C')).toBeInTheDocument()
|
||||
expect(keys[0]).toHaveClass(
|
||||
'h-4',
|
||||
'min-w-4',
|
||||
'rounded-sm',
|
||||
'font-sans',
|
||||
'not-italic',
|
||||
'system-kbd',
|
||||
'capitalize',
|
||||
'bg-components-kbd-bg-white',
|
||||
'text-text-tertiary',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps single-key shortcuts in one keycap', () => {
|
||||
const { container } = render(
|
||||
<ShortcutKbd shortcut="workflow.delete" platform="windows" />,
|
||||
)
|
||||
|
||||
expect(container.querySelectorAll('kbd')).toHaveLength(1)
|
||||
expect(screen.getByText('⌦')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses TanStack non-mac modifier labels', () => {
|
||||
render(<ShortcutKbd shortcut="workflow.copy" platform="windows" />)
|
||||
|
||||
expect(screen.getByText('Ctrl')).toBeInTheDocument()
|
||||
expect(screen.getByText('C')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
19
web/app/components/workflow/shortcuts/commands.ts
Normal file
19
web/app/components/workflow/shortcuts/commands.ts
Normal file
@ -0,0 +1,19 @@
|
||||
export const WorkflowCommand = {
|
||||
ToggleCanvasMaximize: 'workflow:toggle-canvas-maximize',
|
||||
} as const
|
||||
|
||||
type WorkflowCommandType = typeof WorkflowCommand[keyof typeof WorkflowCommand]
|
||||
|
||||
const workflowCommandTarget = new EventTarget()
|
||||
|
||||
export const emitWorkflowCommand = (command: WorkflowCommandType) => {
|
||||
workflowCommandTarget.dispatchEvent(new Event(command))
|
||||
}
|
||||
|
||||
export const subscribeWorkflowCommand = (
|
||||
command: WorkflowCommandType,
|
||||
listener: () => void,
|
||||
) => {
|
||||
workflowCommandTarget.addEventListener(command, listener)
|
||||
return () => workflowCommandTarget.removeEventListener(command, listener)
|
||||
}
|
||||
177
web/app/components/workflow/shortcuts/definitions.ts
Normal file
177
web/app/components/workflow/shortcuts/definitions.ts
Normal file
@ -0,0 +1,177 @@
|
||||
import type { RegisterableHotkey } from '@tanstack/react-hotkeys'
|
||||
|
||||
export type WorkflowShortcutId
|
||||
= | 'workflow.delete'
|
||||
| 'workflow.copy'
|
||||
| 'workflow.paste'
|
||||
| 'workflow.duplicate'
|
||||
| 'workflow.open-test-run-menu'
|
||||
| 'workflow.undo'
|
||||
| 'workflow.redo'
|
||||
| 'workflow.pointer-mode'
|
||||
| 'workflow.hand-mode'
|
||||
| 'workflow.comment-mode'
|
||||
| 'workflow.organize'
|
||||
| 'workflow.toggle-maximize'
|
||||
| 'workflow.zoom-to-fit'
|
||||
| 'workflow.zoom-to-100'
|
||||
| 'workflow.zoom-to-50'
|
||||
| 'workflow.zoom-out'
|
||||
| 'workflow.zoom-in'
|
||||
| 'workflow.download-import-log'
|
||||
| 'workflow.dim-other-nodes'
|
||||
| 'workflow.json-schema-confirm'
|
||||
| 'workflow.version-history'
|
||||
|
||||
export type WorkflowHotkeyMeta = {
|
||||
id: WorkflowShortcutId
|
||||
scope: 'workflow'
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export type WorkflowShortcutDefinition = {
|
||||
id: WorkflowShortcutId
|
||||
hotkeys: readonly RegisterableHotkey[]
|
||||
displayHotkey?: RegisterableHotkey | (string & {})
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export const WORKFLOW_SHORTCUTS: Record<WorkflowShortcutId, WorkflowShortcutDefinition> = {
|
||||
'workflow.delete': {
|
||||
id: 'workflow.delete',
|
||||
hotkeys: ['Delete', 'Backspace'],
|
||||
displayHotkey: 'Delete',
|
||||
name: 'Delete selection',
|
||||
description: 'Delete selected workflow nodes or edges',
|
||||
},
|
||||
'workflow.copy': {
|
||||
id: 'workflow.copy',
|
||||
hotkeys: ['Mod+C'],
|
||||
name: 'Copy',
|
||||
description: 'Copy selected workflow nodes',
|
||||
},
|
||||
'workflow.paste': {
|
||||
id: 'workflow.paste',
|
||||
hotkeys: ['Mod+V'],
|
||||
name: 'Paste',
|
||||
description: 'Paste copied workflow nodes',
|
||||
},
|
||||
'workflow.duplicate': {
|
||||
id: 'workflow.duplicate',
|
||||
hotkeys: ['Mod+D'],
|
||||
name: 'Duplicate',
|
||||
description: 'Duplicate selected workflow nodes',
|
||||
},
|
||||
'workflow.open-test-run-menu': {
|
||||
id: 'workflow.open-test-run-menu',
|
||||
hotkeys: ['Alt+R'],
|
||||
name: 'Open test run menu',
|
||||
description: 'Open the workflow test run menu',
|
||||
},
|
||||
'workflow.undo': {
|
||||
id: 'workflow.undo',
|
||||
hotkeys: ['Mod+Z'],
|
||||
name: 'Undo',
|
||||
description: 'Undo the previous workflow change',
|
||||
},
|
||||
'workflow.redo': {
|
||||
id: 'workflow.redo',
|
||||
hotkeys: ['Mod+Y', 'Mod+Shift+Z'],
|
||||
displayHotkey: 'Mod+Y',
|
||||
name: 'Redo',
|
||||
description: 'Redo the next workflow change',
|
||||
},
|
||||
'workflow.pointer-mode': {
|
||||
id: 'workflow.pointer-mode',
|
||||
hotkeys: ['V'],
|
||||
name: 'Pointer mode',
|
||||
description: 'Switch to pointer mode',
|
||||
},
|
||||
'workflow.hand-mode': {
|
||||
id: 'workflow.hand-mode',
|
||||
hotkeys: ['H'],
|
||||
name: 'Hand mode',
|
||||
description: 'Switch to hand mode',
|
||||
},
|
||||
'workflow.comment-mode': {
|
||||
id: 'workflow.comment-mode',
|
||||
hotkeys: ['C'],
|
||||
name: 'Comment mode',
|
||||
description: 'Switch to comment mode',
|
||||
},
|
||||
'workflow.organize': {
|
||||
id: 'workflow.organize',
|
||||
hotkeys: ['Mod+O'],
|
||||
name: 'Organize blocks',
|
||||
description: 'Automatically organize workflow blocks',
|
||||
},
|
||||
'workflow.toggle-maximize': {
|
||||
id: 'workflow.toggle-maximize',
|
||||
hotkeys: ['F'],
|
||||
name: 'Toggle maximize',
|
||||
description: 'Maximize or minimize the workflow canvas',
|
||||
},
|
||||
'workflow.zoom-to-fit': {
|
||||
id: 'workflow.zoom-to-fit',
|
||||
hotkeys: ['Mod+1'],
|
||||
name: 'Zoom to fit',
|
||||
description: 'Fit the workflow canvas into view',
|
||||
},
|
||||
'workflow.zoom-to-100': {
|
||||
id: 'workflow.zoom-to-100',
|
||||
hotkeys: ['Shift+1'],
|
||||
name: 'Zoom to 100%',
|
||||
description: 'Zoom the workflow canvas to 100%',
|
||||
},
|
||||
'workflow.zoom-to-50': {
|
||||
id: 'workflow.zoom-to-50',
|
||||
hotkeys: ['Shift+5'],
|
||||
name: 'Zoom to 50%',
|
||||
description: 'Zoom the workflow canvas to 50%',
|
||||
},
|
||||
'workflow.zoom-out': {
|
||||
id: 'workflow.zoom-out',
|
||||
hotkeys: ['Mod+-'],
|
||||
name: 'Zoom out',
|
||||
description: 'Zoom out of the workflow canvas',
|
||||
},
|
||||
'workflow.zoom-in': {
|
||||
id: 'workflow.zoom-in',
|
||||
hotkeys: ['Mod+='],
|
||||
displayHotkey: 'Mod+=',
|
||||
name: 'Zoom in',
|
||||
description: 'Zoom into the workflow canvas',
|
||||
},
|
||||
'workflow.download-import-log': {
|
||||
id: 'workflow.download-import-log',
|
||||
hotkeys: ['Mod+Shift+L'],
|
||||
name: 'Download import log',
|
||||
description: 'Download the workflow graph import log',
|
||||
},
|
||||
'workflow.dim-other-nodes': {
|
||||
id: 'workflow.dim-other-nodes',
|
||||
hotkeys: [{ key: 'Shift', shift: true }],
|
||||
displayHotkey: 'Shift',
|
||||
name: 'Dim other nodes',
|
||||
description: 'Dim nodes outside the current workflow selection',
|
||||
},
|
||||
'workflow.json-schema-confirm': {
|
||||
id: 'workflow.json-schema-confirm',
|
||||
hotkeys: ['Mod+Enter'],
|
||||
name: 'Confirm JSON schema edit',
|
||||
description: 'Confirm the current JSON schema edit',
|
||||
},
|
||||
'workflow.version-history': {
|
||||
id: 'workflow.version-history',
|
||||
hotkeys: ['Mod+Shift+H'],
|
||||
name: 'Version history',
|
||||
description: 'Open workflow version history',
|
||||
},
|
||||
}
|
||||
|
||||
export const getWorkflowShortcutDisplayHotkey = (id: WorkflowShortcutId): RegisterableHotkey | (string & {}) => {
|
||||
const shortcut = WORKFLOW_SHORTCUTS[id]
|
||||
return shortcut.displayHotkey ?? shortcut.hotkeys[0]!
|
||||
}
|
||||
70
web/app/components/workflow/shortcuts/shortcut-kbd.tsx
Normal file
70
web/app/components/workflow/shortcuts/shortcut-kbd.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import type { FormatDisplayOptions, RegisterableHotkey } from '@tanstack/react-hotkeys'
|
||||
import type { WorkflowShortcutId } from './definitions'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||
import { getWorkflowShortcutDisplayHotkey } from './definitions'
|
||||
|
||||
type ShortcutKbdProps = {
|
||||
shortcut?: WorkflowShortcutId
|
||||
hotkey?: RegisterableHotkey | (string & {})
|
||||
className?: string
|
||||
textColor?: 'default' | 'secondary'
|
||||
bgColor?: 'gray' | 'white'
|
||||
platform?: FormatDisplayOptions['platform']
|
||||
}
|
||||
|
||||
const getDisplayKeys = (
|
||||
hotkey: RegisterableHotkey | (string & {}),
|
||||
platform?: FormatDisplayOptions['platform'],
|
||||
) => {
|
||||
const displayOptions = platform ? { platform } : undefined
|
||||
|
||||
if (typeof hotkey !== 'string')
|
||||
return [formatForDisplay(hotkey, displayOptions)]
|
||||
|
||||
return hotkey
|
||||
.split('+')
|
||||
.filter(Boolean)
|
||||
.map(key => formatForDisplay(key, displayOptions))
|
||||
}
|
||||
|
||||
export const ShortcutKbd = ({
|
||||
shortcut,
|
||||
hotkey,
|
||||
className,
|
||||
textColor = 'default',
|
||||
bgColor = 'gray',
|
||||
platform,
|
||||
}: ShortcutKbdProps) => {
|
||||
const displayHotkey = hotkey ?? (shortcut ? getWorkflowShortcutDisplayHotkey(shortcut) : undefined)
|
||||
|
||||
if (!displayHotkey)
|
||||
return null
|
||||
|
||||
const displayKeys = getDisplayKeys(displayHotkey, platform)
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center gap-0.5',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{
|
||||
displayKeys.map((key, index) => (
|
||||
<kbd
|
||||
key={`${key}-${index}`}
|
||||
className={cn(
|
||||
'flex h-4 min-w-4 items-center justify-center rounded-sm px-1 font-sans system-kbd capitalize not-italic',
|
||||
bgColor === 'gray' && 'bg-components-kbd-bg-gray',
|
||||
bgColor === 'white' && 'bg-components-kbd-bg-white text-text-primary-on-surface',
|
||||
textColor === 'secondary' && 'text-text-tertiary',
|
||||
)}
|
||||
>
|
||||
{key}
|
||||
</kbd>
|
||||
))
|
||||
}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
255
web/app/components/workflow/shortcuts/use-workflow-hotkeys.ts
Normal file
255
web/app/components/workflow/shortcuts/use-workflow-hotkeys.ts
Normal file
@ -0,0 +1,255 @@
|
||||
import type {
|
||||
HotkeyCallback,
|
||||
UseHotkeyDefinition,
|
||||
UseHotkeyOptions,
|
||||
} from '@tanstack/react-hotkeys'
|
||||
import type { WorkflowHotkeyMeta, WorkflowShortcutDefinition, WorkflowShortcutId } from './definitions'
|
||||
import { useHotkeys, useKeyHold } from '@tanstack/react-hotkeys'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import { collaborationManager } from '../collaboration/core/collaboration-manager'
|
||||
import { useEdgesInteractions } from '../hooks/use-edges-interactions'
|
||||
import { useNodesInteractions } from '../hooks/use-nodes-interactions'
|
||||
import { useNodesSyncDraft } from '../hooks/use-nodes-sync-draft'
|
||||
import { useWorkflowCanvasMaximize } from '../hooks/use-workflow-canvas-maximize'
|
||||
import { useWorkflowOrganize } from '../hooks/use-workflow-organize'
|
||||
import { useWorkflowMoveMode } from '../hooks/use-workflow-panel-interactions'
|
||||
import { useStore } from '../store/workflow'
|
||||
import { isEventTargetInputArea } from '../utils'
|
||||
import {
|
||||
subscribeWorkflowCommand,
|
||||
WorkflowCommand,
|
||||
} from './commands'
|
||||
import { WORKFLOW_SHORTCUTS } from './definitions'
|
||||
|
||||
const workflowHotkeyOptions = {
|
||||
ignoreInputs: true,
|
||||
conflictBehavior: 'warn',
|
||||
} satisfies UseHotkeyOptions
|
||||
|
||||
const toHotkeyDefinitions = (
|
||||
shortcut: WorkflowShortcutDefinition,
|
||||
callback: HotkeyCallback,
|
||||
options?: UseHotkeyOptions,
|
||||
): UseHotkeyDefinition[] => {
|
||||
return shortcut.hotkeys.map(hotkey => ({
|
||||
hotkey,
|
||||
callback,
|
||||
options: {
|
||||
...options,
|
||||
meta: {
|
||||
id: shortcut.id,
|
||||
scope: 'workflow',
|
||||
name: shortcut.name,
|
||||
description: shortcut.description,
|
||||
} satisfies WorkflowHotkeyMeta,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
export const useWorkflowShortcut = (
|
||||
id: WorkflowShortcutId,
|
||||
callback: HotkeyCallback,
|
||||
options?: UseHotkeyOptions,
|
||||
) => {
|
||||
const shortcut = WORKFLOW_SHORTCUTS[id]
|
||||
const hotkeys = useMemo(
|
||||
() => toHotkeyDefinitions(shortcut, callback, options),
|
||||
[callback, options, shortcut],
|
||||
)
|
||||
|
||||
useHotkeys(hotkeys, workflowHotkeyOptions)
|
||||
}
|
||||
|
||||
export const useWorkflowHotkeys = (): void => {
|
||||
const {
|
||||
handleNodesCopy,
|
||||
handleNodesPaste,
|
||||
handleNodesDuplicate,
|
||||
handleNodesDelete,
|
||||
handleHistoryBack,
|
||||
handleHistoryForward,
|
||||
dimOtherNodes,
|
||||
undimAllNodes,
|
||||
} = useNodesInteractions()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const { handleEdgeDelete } = useEdgesInteractions()
|
||||
const showDebugAndPreviewPanel = useStore(s => s.showDebugAndPreviewPanel)
|
||||
const historyShortcutsEnabled = useStore(s => s.historyShortcutsEnabled)
|
||||
const {
|
||||
handleModeHand,
|
||||
handleModePointer,
|
||||
handleModeComment,
|
||||
isCommentModeAvailable,
|
||||
} = useWorkflowMoveMode()
|
||||
const { handleLayout } = useWorkflowOrganize()
|
||||
const { handleToggleMaximizeCanvas } = useWorkflowCanvasMaximize()
|
||||
|
||||
const {
|
||||
zoomTo,
|
||||
getZoom,
|
||||
fitView,
|
||||
getNodes,
|
||||
} = useReactFlow()
|
||||
const isShiftHeld = useKeyHold('Shift')
|
||||
const shiftDimmedRef = useRef(false)
|
||||
const undimAllNodesRef = useRef(undimAllNodes)
|
||||
undimAllNodesRef.current = undimAllNodes
|
||||
|
||||
const constrainedZoomOut = useCallback(() => {
|
||||
const currentZoom = getZoom()
|
||||
const newZoom = Math.max(currentZoom - 0.1, 0.25)
|
||||
zoomTo(newZoom)
|
||||
}, [getZoom, zoomTo])
|
||||
|
||||
const constrainedZoomIn = useCallback(() => {
|
||||
const currentZoom = getZoom()
|
||||
const newZoom = Math.min(currentZoom + 0.1, 2)
|
||||
zoomTo(newZoom)
|
||||
}, [getZoom, zoomTo])
|
||||
|
||||
const shouldHandleCopy = useCallback(() => {
|
||||
if (getNodes().some(node => node.data._isBundled))
|
||||
return true
|
||||
|
||||
const selection = document.getSelection()
|
||||
return !selection || selection.isCollapsed || !selection.rangeCount
|
||||
}, [getNodes])
|
||||
|
||||
const handleCopy = useCallback<HotkeyCallback>((event) => {
|
||||
if (!shouldHandleCopy())
|
||||
return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
handleNodesCopy()
|
||||
}, [handleNodesCopy, shouldHandleCopy])
|
||||
|
||||
const handleZenToggle = useCallback(() => {
|
||||
handleToggleMaximizeCanvas()
|
||||
}, [handleToggleMaximizeCanvas])
|
||||
|
||||
const hotkeys = useMemo<UseHotkeyDefinition[]>(() => [
|
||||
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.delete'], () => {
|
||||
handleNodesDelete()
|
||||
handleEdgeDelete()
|
||||
}),
|
||||
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.copy'], handleCopy, {
|
||||
preventDefault: false,
|
||||
stopPropagation: false,
|
||||
enabled: !showDebugAndPreviewPanel,
|
||||
}),
|
||||
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.paste'], () => {
|
||||
handleNodesPaste()
|
||||
}, {
|
||||
enabled: !showDebugAndPreviewPanel,
|
||||
}),
|
||||
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.duplicate'], () => {
|
||||
handleNodesDuplicate()
|
||||
}),
|
||||
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.undo'], () => {
|
||||
handleHistoryBack()
|
||||
}, {
|
||||
enabled: !showDebugAndPreviewPanel && historyShortcutsEnabled,
|
||||
}),
|
||||
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.redo'], () => {
|
||||
handleHistoryForward()
|
||||
}, {
|
||||
enabled: !showDebugAndPreviewPanel && historyShortcutsEnabled,
|
||||
}),
|
||||
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.hand-mode'], () => {
|
||||
handleModeHand()
|
||||
}),
|
||||
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.pointer-mode'], () => {
|
||||
handleModePointer()
|
||||
}),
|
||||
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.comment-mode'], () => {
|
||||
handleModeComment()
|
||||
}, {
|
||||
enabled: isCommentModeAvailable,
|
||||
}),
|
||||
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.organize'], () => {
|
||||
handleLayout()
|
||||
}),
|
||||
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.toggle-maximize'], () => {
|
||||
handleToggleMaximizeCanvas()
|
||||
}),
|
||||
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.zoom-to-fit'], () => {
|
||||
fitView()
|
||||
handleSyncWorkflowDraft()
|
||||
}),
|
||||
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.zoom-to-100'], () => {
|
||||
zoomTo(1)
|
||||
handleSyncWorkflowDraft()
|
||||
}),
|
||||
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.zoom-to-50'], () => {
|
||||
zoomTo(0.5)
|
||||
handleSyncWorkflowDraft()
|
||||
}),
|
||||
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.zoom-out'], () => {
|
||||
constrainedZoomOut()
|
||||
handleSyncWorkflowDraft()
|
||||
}),
|
||||
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.zoom-in'], () => {
|
||||
constrainedZoomIn()
|
||||
handleSyncWorkflowDraft()
|
||||
}),
|
||||
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.download-import-log'], () => {
|
||||
collaborationManager.downloadGraphImportLog()
|
||||
}),
|
||||
], [
|
||||
constrainedZoomIn,
|
||||
constrainedZoomOut,
|
||||
fitView,
|
||||
handleCopy,
|
||||
handleEdgeDelete,
|
||||
handleHistoryBack,
|
||||
handleHistoryForward,
|
||||
handleLayout,
|
||||
handleModeComment,
|
||||
handleModeHand,
|
||||
handleModePointer,
|
||||
handleNodesDelete,
|
||||
handleNodesDuplicate,
|
||||
handleNodesPaste,
|
||||
handleSyncWorkflowDraft,
|
||||
handleToggleMaximizeCanvas,
|
||||
historyShortcutsEnabled,
|
||||
isCommentModeAvailable,
|
||||
showDebugAndPreviewPanel,
|
||||
zoomTo,
|
||||
])
|
||||
|
||||
useHotkeys(hotkeys, workflowHotkeyOptions)
|
||||
|
||||
useEffect(() => {
|
||||
if (isShiftHeld) {
|
||||
if (shiftDimmedRef.current)
|
||||
return
|
||||
|
||||
if (isEventTargetInputArea(document.activeElement as HTMLElement))
|
||||
return
|
||||
|
||||
shiftDimmedRef.current = true
|
||||
dimOtherNodes()
|
||||
return
|
||||
}
|
||||
|
||||
if (!shiftDimmedRef.current)
|
||||
return
|
||||
|
||||
shiftDimmedRef.current = false
|
||||
undimAllNodes()
|
||||
}, [dimOtherNodes, isShiftHeld, undimAllNodes])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (shiftDimmedRef.current)
|
||||
undimAllNodesRef.current()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeWorkflowCommand(WorkflowCommand.ToggleCanvasMaximize, handleZenToggle)
|
||||
}, [handleZenToggle])
|
||||
}
|
||||
@ -1,12 +1,47 @@
|
||||
import type { StateCreator } from 'zustand'
|
||||
import type { WorkflowHistoryEventT } from '../../hooks/use-workflow-history'
|
||||
import type { Edge, Node } from '../../types'
|
||||
import type {
|
||||
HistoryWorkflowData,
|
||||
} from '@/app/components/workflow/types'
|
||||
import type {
|
||||
VersionHistory,
|
||||
} from '@/types/workflow'
|
||||
import isDeepEqual from 'fast-deep-equal'
|
||||
|
||||
export type WorkflowHistoryEventMeta = {
|
||||
nodeId?: string
|
||||
nodeTitle?: string
|
||||
}
|
||||
|
||||
export type WorkflowHistoryState = {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
workflowHistoryEvent: WorkflowHistoryEventT | undefined
|
||||
workflowHistoryEventMeta?: WorkflowHistoryEventMeta
|
||||
}
|
||||
|
||||
export type WorkflowHistoryTemporalState = Pick<HistorySliceShape, 'workflowHistory'>
|
||||
|
||||
export const getWorkflowHistoryTemporalState = (state: HistorySliceShape): WorkflowHistoryTemporalState => ({
|
||||
workflowHistory: state.workflowHistory,
|
||||
})
|
||||
|
||||
export const isWorkflowHistoryTemporalStateEqual = (
|
||||
pastState: WorkflowHistoryTemporalState,
|
||||
currentState: WorkflowHistoryTemporalState,
|
||||
) => {
|
||||
if (pastState.workflowHistory === currentState.workflowHistory)
|
||||
return true
|
||||
|
||||
return isDeepEqual(pastState.workflowHistory, currentState.workflowHistory)
|
||||
}
|
||||
|
||||
export type HistorySliceShape = {
|
||||
workflowHistory: WorkflowHistoryState
|
||||
setWorkflowHistory: (workflowHistory: WorkflowHistoryState) => void
|
||||
historyShortcutsEnabled: boolean
|
||||
setHistoryShortcutsEnabled: (enabled: boolean) => void
|
||||
historyWorkflowData?: HistoryWorkflowData
|
||||
setHistoryWorkflowData: (historyWorkflowData?: HistoryWorkflowData) => void
|
||||
showRunHistory: boolean
|
||||
@ -16,6 +51,15 @@ export type HistorySliceShape = {
|
||||
}
|
||||
|
||||
export const createHistorySlice: StateCreator<HistorySliceShape> = set => ({
|
||||
workflowHistory: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
workflowHistoryEvent: undefined,
|
||||
workflowHistoryEventMeta: undefined,
|
||||
},
|
||||
setWorkflowHistory: workflowHistory => set(() => ({ workflowHistory })),
|
||||
historyShortcutsEnabled: true,
|
||||
setHistoryShortcutsEnabled: historyShortcutsEnabled => set(() => ({ historyShortcutsEnabled })),
|
||||
historyWorkflowData: undefined,
|
||||
setHistoryWorkflowData: historyWorkflowData => set(() => ({ historyWorkflowData })),
|
||||
showRunHistory: false,
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import type { TemporalState } from 'zundo'
|
||||
import type {
|
||||
StateCreator,
|
||||
StoreApi,
|
||||
} from 'zustand'
|
||||
import type { ChatVariableSliceShape } from './chat-variable-slice'
|
||||
import type { CommentSliceShape } from './comment-slice'
|
||||
@ -7,7 +9,7 @@ import type { InspectVarsSliceShape } from './debug/inspect-vars-slice'
|
||||
import type { EnvVariableSliceShape } from './env-variable-slice'
|
||||
import type { FormSliceShape } from './form-slice'
|
||||
import type { HelpLineSliceShape } from './help-line-slice'
|
||||
import type { HistorySliceShape } from './history-slice'
|
||||
import type { HistorySliceShape, WorkflowHistoryTemporalState } from './history-slice'
|
||||
import type { LayoutSliceShape } from './layout-slice'
|
||||
import type { NodeSliceShape } from './node-slice'
|
||||
import type { PanelSliceShape } from './panel-slice'
|
||||
@ -17,7 +19,8 @@ import type { WorkflowDraftSliceShape } from './workflow-draft-slice'
|
||||
import type { WorkflowSliceShape } from './workflow-slice'
|
||||
import type { RagPipelineSliceShape } from '@/app/components/rag-pipeline/store'
|
||||
import type { WorkflowSliceShape as WorkflowAppSliceShape } from '@/app/components/workflow-app/store/workflow/workflow-slice'
|
||||
import { useContext } from 'react'
|
||||
import { use } from 'react'
|
||||
import { temporal } from 'zundo'
|
||||
import {
|
||||
useStore as useZustandStore,
|
||||
} from 'zustand'
|
||||
@ -29,7 +32,11 @@ import { createInspectVarsSlice } from './debug/inspect-vars-slice'
|
||||
import { createEnvVariableSlice } from './env-variable-slice'
|
||||
import { createFormSlice } from './form-slice'
|
||||
import { createHelpLineSlice } from './help-line-slice'
|
||||
import { createHistorySlice } from './history-slice'
|
||||
import {
|
||||
createHistorySlice,
|
||||
getWorkflowHistoryTemporalState,
|
||||
isWorkflowHistoryTemporalStateEqual,
|
||||
} from './history-slice'
|
||||
import { createLayoutSlice } from './layout-slice'
|
||||
import { createNodeSlice } from './node-slice'
|
||||
|
||||
@ -60,6 +67,10 @@ export type Shape
|
||||
& LayoutSliceShape
|
||||
& SliceFromInjection
|
||||
|
||||
type WorkflowStoreApi = StoreApi<Shape> & {
|
||||
temporal: StoreApi<TemporalState<WorkflowHistoryTemporalState>>
|
||||
}
|
||||
|
||||
export type InjectWorkflowStoreSliceFn = StateCreator<SliceFromInjection>
|
||||
|
||||
type CreateWorkflowStoreParams = {
|
||||
@ -69,27 +80,35 @@ type CreateWorkflowStoreParams = {
|
||||
export const createWorkflowStore = (params: CreateWorkflowStoreParams) => {
|
||||
const { injectWorkflowStoreSliceFn } = params || {}
|
||||
|
||||
return createStore<Shape>((...args) => ({
|
||||
...createChatVariableSlice(...args),
|
||||
...createEnvVariableSlice(...args),
|
||||
...createFormSlice(...args),
|
||||
...createHelpLineSlice(...args),
|
||||
...createHistorySlice(...args),
|
||||
...createNodeSlice(...args),
|
||||
...createPanelSlice(...args),
|
||||
...createCommentSlice(...args),
|
||||
...createToolSlice(...args),
|
||||
...createVersionSlice(...args),
|
||||
...createWorkflowDraftSlice(...args),
|
||||
...createWorkflowSlice(...args),
|
||||
...createInspectVarsSlice(...args),
|
||||
...createLayoutSlice(...args),
|
||||
...(injectWorkflowStoreSliceFn?.(...args) || {} as SliceFromInjection),
|
||||
}))
|
||||
return createStore<Shape>()(
|
||||
temporal<Shape, [], [], WorkflowHistoryTemporalState>(
|
||||
(...args) => ({
|
||||
...createChatVariableSlice(...args),
|
||||
...createEnvVariableSlice(...args),
|
||||
...createFormSlice(...args),
|
||||
...createHelpLineSlice(...args),
|
||||
...createHistorySlice(...args),
|
||||
...createNodeSlice(...args),
|
||||
...createPanelSlice(...args),
|
||||
...createCommentSlice(...args),
|
||||
...createToolSlice(...args),
|
||||
...createVersionSlice(...args),
|
||||
...createWorkflowDraftSlice(...args),
|
||||
...createWorkflowSlice(...args),
|
||||
...createInspectVarsSlice(...args),
|
||||
...createLayoutSlice(...args),
|
||||
...(injectWorkflowStoreSliceFn?.(...args) || {} as SliceFromInjection),
|
||||
}),
|
||||
{
|
||||
partialize: getWorkflowHistoryTemporalState,
|
||||
equality: isWorkflowHistoryTemporalStateEqual,
|
||||
},
|
||||
),
|
||||
) as WorkflowStoreApi
|
||||
}
|
||||
|
||||
export function useStore<T>(selector: (state: Shape) => T): T {
|
||||
const store = useContext(WorkflowContext)
|
||||
const store = use(WorkflowContext)
|
||||
if (!store)
|
||||
throw new Error('Missing WorkflowContext.Provider in the tree')
|
||||
|
||||
@ -97,5 +116,5 @@ export function useStore<T>(selector: (state: Shape) => T): T {
|
||||
}
|
||||
|
||||
export const useWorkflowStore = () => {
|
||||
return useContext(WorkflowContext)!
|
||||
return use(WorkflowContext)!
|
||||
}
|
||||
|
||||
99
web/app/components/workflow/workflow-history-store.ts
Normal file
99
web/app/components/workflow/workflow-history-store.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import type { TemporalState } from 'zundo'
|
||||
import type {
|
||||
WorkflowHistoryState,
|
||||
} from './store/workflow/history-slice'
|
||||
import type { Edge, Node } from './types'
|
||||
import { use, useMemo } from 'react'
|
||||
import { WorkflowContext } from './context'
|
||||
|
||||
type WorkflowHistoryTemporalSnapshot = {
|
||||
workflowHistory: WorkflowHistoryState
|
||||
}
|
||||
|
||||
type WorkflowHistoryTemporalStore = {
|
||||
getState: () => TemporalState<WorkflowHistoryState>
|
||||
subscribe: (listener: (state: TemporalState<WorkflowHistoryState>) => void) => () => void
|
||||
}
|
||||
|
||||
type WorkflowHistoryStore = {
|
||||
getState: () => WorkflowHistoryState
|
||||
setState: (state: WorkflowHistoryState) => void
|
||||
subscribe: (listener: (state: WorkflowHistoryState) => void) => () => void
|
||||
temporal: WorkflowHistoryTemporalStore
|
||||
}
|
||||
|
||||
const sanitizeWorkflowHistory = (state: WorkflowHistoryState): WorkflowHistoryState => ({
|
||||
workflowHistoryEvent: state.workflowHistoryEvent,
|
||||
workflowHistoryEventMeta: state.workflowHistoryEventMeta,
|
||||
nodes: state.nodes.map((node: Node) => ({
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
selected: false,
|
||||
},
|
||||
})),
|
||||
edges: state.edges.map((edge: Edge) => ({
|
||||
...edge,
|
||||
selected: false,
|
||||
}) as Edge),
|
||||
})
|
||||
|
||||
const toHistoryState = (
|
||||
state?: Partial<WorkflowHistoryTemporalSnapshot>,
|
||||
): Partial<WorkflowHistoryState> => {
|
||||
return state?.workflowHistory ?? {}
|
||||
}
|
||||
|
||||
const toTemporalState = (
|
||||
temporalState: TemporalState<WorkflowHistoryTemporalSnapshot>,
|
||||
): TemporalState<WorkflowHistoryState> => ({
|
||||
pastStates: temporalState.pastStates.map(toHistoryState),
|
||||
futureStates: temporalState.futureStates.map(toHistoryState),
|
||||
undo: temporalState.undo,
|
||||
redo: temporalState.redo,
|
||||
clear: temporalState.clear,
|
||||
isTracking: temporalState.isTracking,
|
||||
pause: temporalState.pause,
|
||||
resume: temporalState.resume,
|
||||
setOnSave: onSave => temporalState.setOnSave(
|
||||
onSave
|
||||
? (pastState, currentState) => {
|
||||
onSave(
|
||||
toHistoryState(pastState) as WorkflowHistoryState,
|
||||
toHistoryState(currentState) as WorkflowHistoryState,
|
||||
)
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
})
|
||||
|
||||
export function useWorkflowHistoryStore() {
|
||||
const workflowStore = use(WorkflowContext)
|
||||
|
||||
if (!workflowStore)
|
||||
throw new Error('Missing WorkflowContext.Provider in the tree')
|
||||
|
||||
return {
|
||||
store: useMemo(
|
||||
() => ({
|
||||
getState: () => workflowStore.getState().workflowHistory,
|
||||
setState: (state: WorkflowHistoryState) => {
|
||||
workflowStore.getState().setWorkflowHistory(sanitizeWorkflowHistory(state))
|
||||
},
|
||||
subscribe: (listener: (state: WorkflowHistoryState) => void) => {
|
||||
return workflowStore.subscribe((state, previousState) => {
|
||||
if (state.workflowHistory !== previousState.workflowHistory)
|
||||
listener(state.workflowHistory)
|
||||
})
|
||||
},
|
||||
temporal: {
|
||||
getState: () => toTemporalState(workflowStore.temporal.getState()),
|
||||
subscribe: listener => workflowStore.temporal.subscribe((state) => {
|
||||
listener(toTemporalState(state))
|
||||
}),
|
||||
},
|
||||
}) satisfies WorkflowHistoryStore,
|
||||
[workflowStore],
|
||||
),
|
||||
}
|
||||
}
|
||||
@ -1,132 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { TemporalState } from 'zundo'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { WorkflowHistoryEventT } from './hooks'
|
||||
import type { Edge, Node } from './types'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import isDeepEqual from 'fast-deep-equal'
|
||||
import { createContext, useContext, useMemo, useState } from 'react'
|
||||
import { temporal } from 'zundo'
|
||||
import { create } from 'zustand'
|
||||
|
||||
export const WorkflowHistoryStoreContext = createContext<WorkflowHistoryStoreContextType>({ store: null, shortcutsEnabled: true, setShortcutsEnabled: noop })
|
||||
const Provider = WorkflowHistoryStoreContext.Provider
|
||||
|
||||
export function WorkflowHistoryProvider({
|
||||
nodes,
|
||||
edges,
|
||||
children,
|
||||
}: WorkflowWithHistoryProviderProps) {
|
||||
const [shortcutsEnabled, setShortcutsEnabled] = useState(true)
|
||||
const [store] = useState(() =>
|
||||
createStore({
|
||||
nodes,
|
||||
edges,
|
||||
}),
|
||||
)
|
||||
|
||||
const contextValue = {
|
||||
store,
|
||||
shortcutsEnabled,
|
||||
setShortcutsEnabled,
|
||||
}
|
||||
|
||||
return (
|
||||
<Provider value={contextValue}>
|
||||
{children}
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useWorkflowHistoryStore() {
|
||||
const {
|
||||
store,
|
||||
shortcutsEnabled,
|
||||
setShortcutsEnabled,
|
||||
} = useContext(WorkflowHistoryStoreContext)
|
||||
if (store === null)
|
||||
throw new Error('useWorkflowHistoryStoreApi must be used within a WorkflowHistoryProvider')
|
||||
|
||||
return {
|
||||
store: useMemo(
|
||||
() => ({
|
||||
getState: store.getState,
|
||||
setState: (state: WorkflowHistoryState) => {
|
||||
store.setState({
|
||||
workflowHistoryEvent: state.workflowHistoryEvent,
|
||||
workflowHistoryEventMeta: state.workflowHistoryEventMeta,
|
||||
nodes: state.nodes.map((node: Node) => ({ ...node, data: { ...node.data, selected: false } })),
|
||||
edges: state.edges.map((edge: Edge) => ({ ...edge, selected: false }) as Edge),
|
||||
})
|
||||
},
|
||||
subscribe: store.subscribe,
|
||||
temporal: store.temporal,
|
||||
}),
|
||||
[store],
|
||||
),
|
||||
shortcutsEnabled,
|
||||
setShortcutsEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
function createStore({
|
||||
nodes: storeNodes,
|
||||
edges: storeEdges,
|
||||
}: {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
}): WorkflowHistoryStoreApi {
|
||||
const store = create(temporal<WorkflowHistoryState>(
|
||||
(set, get) => {
|
||||
return {
|
||||
workflowHistoryEvent: undefined,
|
||||
workflowHistoryEventMeta: undefined,
|
||||
nodes: storeNodes,
|
||||
edges: storeEdges,
|
||||
getNodes: () => get().nodes,
|
||||
setNodes: (nodes: Node[]) => set({ nodes }),
|
||||
setEdges: (edges: Edge[]) => set({ edges }),
|
||||
}
|
||||
},
|
||||
{
|
||||
equality: (pastState, currentState) =>
|
||||
isDeepEqual(pastState, currentState),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
type WorkflowHistoryStore = {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
workflowHistoryEvent: WorkflowHistoryEventT | undefined
|
||||
workflowHistoryEventMeta?: WorkflowHistoryEventMeta
|
||||
}
|
||||
|
||||
type WorkflowHistoryActions = {
|
||||
setNodes?: (nodes: Node[]) => void
|
||||
setEdges?: (edges: Edge[]) => void
|
||||
}
|
||||
|
||||
export type WorkflowHistoryState = WorkflowHistoryStore & WorkflowHistoryActions
|
||||
|
||||
type WorkflowHistoryStoreContextType = {
|
||||
store: ReturnType<typeof createStore> | null
|
||||
shortcutsEnabled: boolean
|
||||
setShortcutsEnabled: (enabled: boolean) => void
|
||||
}
|
||||
|
||||
export type WorkflowHistoryStoreApi = StoreApi<WorkflowHistoryState> & { temporal: StoreApi<TemporalState<WorkflowHistoryState>> }
|
||||
|
||||
type WorkflowWithHistoryProviderProps = {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export type WorkflowHistoryEventMeta = {
|
||||
nodeId?: string
|
||||
nodeTitle?: string
|
||||
}
|
||||
@ -18,7 +18,7 @@ import {
|
||||
useViewport,
|
||||
} from 'reactflow'
|
||||
import TipPopup from '@/app/components/workflow/operator/tip-popup'
|
||||
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
|
||||
import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
|
||||
|
||||
enum ZoomType {
|
||||
zoomToFit = 'zoomToFit',
|
||||
@ -104,9 +104,12 @@ const ZoomInOut: FC = () => {
|
||||
<div className="flex h-8 w-[98px] items-center justify-between rounded-lg">
|
||||
<TipPopup
|
||||
title={t('operator.zoomOut', { ns: 'workflow' })}
|
||||
shortcuts={['ctrl', '-']}
|
||||
shortcut="workflow.zoom-out"
|
||||
>
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('operator.zoomOut', { ns: 'workflow' })}
|
||||
disabled={zoom <= 0.25}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg ${zoom <= 0.25 ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-black/5'}`}
|
||||
onClick={(e) => {
|
||||
if (zoom <= 0.25)
|
||||
@ -117,7 +120,7 @@ const ZoomInOut: FC = () => {
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className="i-ri-zoom-out-line h-4 w-4 text-text-tertiary hover:text-text-secondary" />
|
||||
</div>
|
||||
</button>
|
||||
</TipPopup>
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
@ -149,13 +152,13 @@ const ZoomInOut: FC = () => {
|
||||
<span>{option.text}</span>
|
||||
<div className="flex items-center space-x-0.5">
|
||||
{option.key === ZoomType.zoomToFit && (
|
||||
<ShortcutsName keys={['ctrl', '1']} />
|
||||
<ShortcutKbd shortcut="workflow.zoom-to-fit" />
|
||||
)}
|
||||
{option.key === ZoomType.zoomTo50 && (
|
||||
<ShortcutsName keys={['shift', '5']} />
|
||||
<ShortcutKbd shortcut="workflow.zoom-to-50" />
|
||||
)}
|
||||
{option.key === ZoomType.zoomTo100 && (
|
||||
<ShortcutsName keys={['shift', '1']} />
|
||||
<ShortcutKbd shortcut="workflow.zoom-to-100" />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
@ -168,9 +171,12 @@ const ZoomInOut: FC = () => {
|
||||
</DropdownMenu>
|
||||
<TipPopup
|
||||
title={t('operator.zoomIn', { ns: 'workflow' })}
|
||||
shortcuts={['ctrl', '+']}
|
||||
shortcut="workflow.zoom-in"
|
||||
>
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('operator.zoomIn', { ns: 'workflow' })}
|
||||
disabled={zoom >= 2}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg ${zoom >= 2 ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-black/5'}`}
|
||||
onClick={(e) => {
|
||||
if (zoom >= 2)
|
||||
@ -181,7 +187,7 @@ const ZoomInOut: FC = () => {
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className="i-ri-zoom-in-line h-4 w-4 text-text-tertiary hover:text-text-secondary" />
|
||||
</div>
|
||||
</button>
|
||||
</TipPopup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
138
web/contract/generated/enterprise/orpc.gen.ts
Normal file
138
web/contract/generated/enterprise/orpc.gen.ts
Normal file
@ -0,0 +1,138 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { oc } from '@orpc/contract'
|
||||
import * as z from 'zod'
|
||||
|
||||
import {
|
||||
zConsoleSsoOAuth2LoginResponse,
|
||||
zConsoleSsoOidcLoginResponse,
|
||||
zConsoleSsoSamlLoginResponse,
|
||||
zWebAppAuthGetGroupSubjectsQuery,
|
||||
zWebAppAuthGetGroupSubjectsResponse,
|
||||
zWebAppAuthGetWebAppAccessModeQuery,
|
||||
zWebAppAuthGetWebAppAccessModeResponse,
|
||||
zWebAppAuthGetWebAppWhitelistSubjectsQuery,
|
||||
zWebAppAuthGetWebAppWhitelistSubjectsResponse,
|
||||
zWebAppAuthIsUserAllowedToAccessWebAppQuery,
|
||||
zWebAppAuthIsUserAllowedToAccessWebAppResponse,
|
||||
zWebAppAuthSearchForWhilteListCandidatesQuery,
|
||||
zWebAppAuthSearchForWhilteListCandidatesResponse,
|
||||
zWebAppAuthUpdateWebAppWhitelistSubjectsBody,
|
||||
zWebAppAuthUpdateWebAppWhitelistSubjectsResponse,
|
||||
} from './zod.gen'
|
||||
|
||||
export const oAuth2Login = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'ConsoleSSO_OAuth2Login',
|
||||
path: '/enterprise/sso/oauth2/login',
|
||||
tags: ['ConsoleSSO'],
|
||||
})
|
||||
.output(zConsoleSsoOAuth2LoginResponse)
|
||||
|
||||
export const oidcLogin = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'ConsoleSSO_OIDCLogin',
|
||||
path: '/enterprise/sso/oidc/login',
|
||||
tags: ['ConsoleSSO'],
|
||||
})
|
||||
.output(zConsoleSsoOidcLoginResponse)
|
||||
|
||||
export const samlLogin = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'ConsoleSSO_SAMLLogin',
|
||||
path: '/enterprise/sso/saml/login',
|
||||
tags: ['ConsoleSSO'],
|
||||
})
|
||||
.output(zConsoleSsoSamlLoginResponse)
|
||||
|
||||
export const consoleSso = {
|
||||
oAuth2Login,
|
||||
oidcLogin,
|
||||
samlLogin,
|
||||
}
|
||||
|
||||
export const getWebAppAccessMode = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'WebAppAuth_GetWebAppAccessMode',
|
||||
path: '/enterprise/webapp/app/access-mode',
|
||||
tags: ['WebAppAuth'],
|
||||
})
|
||||
.input(z.object({ query: zWebAppAuthGetWebAppAccessModeQuery.optional() }))
|
||||
.output(zWebAppAuthGetWebAppAccessModeResponse)
|
||||
|
||||
export const updateWebAppWhitelistSubjects = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'WebAppAuth_UpdateWebAppWhitelistSubjects',
|
||||
path: '/enterprise/webapp/app/access-mode',
|
||||
tags: ['WebAppAuth'],
|
||||
})
|
||||
.input(z.object({ body: zWebAppAuthUpdateWebAppWhitelistSubjectsBody }))
|
||||
.output(zWebAppAuthUpdateWebAppWhitelistSubjectsResponse)
|
||||
|
||||
export const searchForWhilteListCandidates = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'WebAppAuth_SearchForWhilteListCandidates',
|
||||
path: '/enterprise/webapp/app/subject/search',
|
||||
tags: ['WebAppAuth'],
|
||||
})
|
||||
.input(z.object({ query: zWebAppAuthSearchForWhilteListCandidatesQuery.optional() }))
|
||||
.output(zWebAppAuthSearchForWhilteListCandidatesResponse)
|
||||
|
||||
export const getWebAppWhitelistSubjects = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'WebAppAuth_GetWebAppWhitelistSubjects',
|
||||
path: '/enterprise/webapp/app/subjects',
|
||||
tags: ['WebAppAuth'],
|
||||
})
|
||||
.input(z.object({ query: zWebAppAuthGetWebAppWhitelistSubjectsQuery.optional() }))
|
||||
.output(zWebAppAuthGetWebAppWhitelistSubjectsResponse)
|
||||
|
||||
export const getGroupSubjects = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'WebAppAuth_GetGroupSubjects',
|
||||
path: '/enterprise/webapp/group/subjects',
|
||||
tags: ['WebAppAuth'],
|
||||
})
|
||||
.input(z.object({ query: zWebAppAuthGetGroupSubjectsQuery.optional() }))
|
||||
.output(zWebAppAuthGetGroupSubjectsResponse)
|
||||
|
||||
export const isUserAllowedToAccessWebApp = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'WebAppAuth_IsUserAllowedToAccessWebApp',
|
||||
path: '/enterprise/webapp/permission',
|
||||
tags: ['WebAppAuth'],
|
||||
})
|
||||
.input(z.object({ query: zWebAppAuthIsUserAllowedToAccessWebAppQuery.optional() }))
|
||||
.output(zWebAppAuthIsUserAllowedToAccessWebAppResponse)
|
||||
|
||||
export const webAppAuth = {
|
||||
getWebAppAccessMode,
|
||||
updateWebAppWhitelistSubjects,
|
||||
searchForWhilteListCandidates,
|
||||
getWebAppWhitelistSubjects,
|
||||
getGroupSubjects,
|
||||
isUserAllowedToAccessWebApp,
|
||||
}
|
||||
|
||||
export const contract = {
|
||||
consoleSso,
|
||||
webAppAuth,
|
||||
}
|
||||
1103
web/contract/generated/enterprise/types.gen.ts
Normal file
1103
web/contract/generated/enterprise/types.gen.ts
Normal file
File diff suppressed because it is too large
Load Diff
1183
web/contract/generated/enterprise/zod.gen.ts
Normal file
1183
web/contract/generated/enterprise/zod.gen.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -42,6 +42,7 @@ import {
|
||||
workflowDraftUpdateFeaturesContract,
|
||||
} from './console/workflow'
|
||||
import { workflowCommentContracts } from './console/workflow-comment'
|
||||
import { contract as enterpriseContract } from './generated/enterprise/orpc.gen'
|
||||
import { collectionPluginsContract, collectionsContract, searchAdvancedContract, templateDetailContract } from './marketplace'
|
||||
|
||||
export const marketplaceRouterContract = {
|
||||
@ -54,6 +55,7 @@ export const marketplaceRouterContract = {
|
||||
export type MarketPlaceInputs = InferContractRouterInputs<typeof marketplaceRouterContract>
|
||||
|
||||
export const consoleRouterContract = {
|
||||
enterprise: enterpriseContract,
|
||||
account: {
|
||||
avatar: accountAvatarContract,
|
||||
},
|
||||
|
||||
@ -8,9 +8,11 @@ const config: KnipConfig = {
|
||||
'scripts/**/*.{js,ts,mjs}',
|
||||
'bin/**/*.{js,ts,mjs}',
|
||||
'tsslint.config.ts',
|
||||
'openapi-ts.*.config.ts',
|
||||
],
|
||||
ignore: [
|
||||
'public/**',
|
||||
'contract/generated/**',
|
||||
],
|
||||
ignoreBinaries: [
|
||||
'only-allow',
|
||||
|
||||
119
web/openapi-ts.enterprise.config.ts
Normal file
119
web/openapi-ts.enterprise.config.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig } from '@hey-api/openapi-ts'
|
||||
import yaml from 'js-yaml'
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
|
||||
type OpenApiDocument = JsonObject & {
|
||||
paths?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type ContractOperation = {
|
||||
id: string
|
||||
operationId?: string
|
||||
tags?: readonly string[]
|
||||
}
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const enterpriseServerDir = process.env.DIFY_ENTERPRISE_SERVER
|
||||
? path.resolve(process.env.DIFY_ENTERPRISE_SERVER)
|
||||
: path.resolve(currentDir, '../../dify-enterprise/server')
|
||||
const enterpriseOpenApiPath = path.join(enterpriseServerDir, 'pkg/apis/enterprise/openapi.yaml')
|
||||
|
||||
const isConsoleApiPath = (routePath: string) => routePath.startsWith('/console/api/')
|
||||
|
||||
const stripConsoleApiPrefix = (routePath: string) => {
|
||||
if (isConsoleApiPath(routePath))
|
||||
return routePath.replace('/console/api', '')
|
||||
|
||||
return routePath
|
||||
}
|
||||
|
||||
const stripSchemaNamePrefix = (schemaName: string) => {
|
||||
return schemaName
|
||||
.replace(/^dify\.enterprise\.api\.enterprise\./, '')
|
||||
.replace(/^pagination\./, '')
|
||||
}
|
||||
|
||||
const contractNameSegments = (operation: ContractOperation) => {
|
||||
const operationId = operation.operationId || operation.id
|
||||
const tag = operation.tags?.[0]
|
||||
const tagPrefixPattern = tag ? new RegExp(`^${tag}[._/-]`) : undefined
|
||||
const name = tagPrefixPattern ? operationId.replace(tagPrefixPattern, '') : operationId
|
||||
const segments = name.split(/[._/-]+/).filter(Boolean)
|
||||
|
||||
return segments.length > 0 ? segments : [operationId]
|
||||
}
|
||||
|
||||
const contractPathSegments = (operation: ContractOperation) => {
|
||||
return [operation.tags?.[0] || 'default', ...contractNameSegments(operation)]
|
||||
}
|
||||
|
||||
const normalizeEnterpriseOpenApi = () => {
|
||||
const openApi = yaml.load(fs.readFileSync(enterpriseOpenApiPath, 'utf8'))
|
||||
|
||||
if (!openApi || typeof openApi !== 'object' || Array.isArray(openApi))
|
||||
throw new Error(`Invalid enterprise OpenAPI document: ${enterpriseOpenApiPath}`)
|
||||
|
||||
const document = openApi as OpenApiDocument
|
||||
const paths = document.paths ?? {}
|
||||
|
||||
document.paths = Object.fromEntries(
|
||||
Object.entries(paths)
|
||||
.filter(([routePath]) => isConsoleApiPath(routePath))
|
||||
.map(([routePath, pathItem]) => [stripConsoleApiPrefix(routePath), pathItem]),
|
||||
)
|
||||
|
||||
return document
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
input: normalizeEnterpriseOpenApi(),
|
||||
output: {
|
||||
entryFile: false,
|
||||
path: 'contract/generated/enterprise',
|
||||
fileName: {
|
||||
suffix: '.gen',
|
||||
},
|
||||
postProcess: [
|
||||
{
|
||||
command: 'vp',
|
||||
args: ['fmt', '{{path}}'],
|
||||
},
|
||||
{
|
||||
command: 'eslint',
|
||||
args: ['--fix', '{{path}}'],
|
||||
},
|
||||
],
|
||||
},
|
||||
parser: {
|
||||
transforms: {
|
||||
schemaName: stripSchemaNamePrefix,
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
{
|
||||
name: '@hey-api/typescript',
|
||||
comments: false,
|
||||
},
|
||||
'zod',
|
||||
{
|
||||
name: 'orpc',
|
||||
contracts: {
|
||||
strategy: 'single',
|
||||
contractName: {
|
||||
name: '{{name}}',
|
||||
casing: 'camelCase',
|
||||
},
|
||||
nesting: contractPathSegments,
|
||||
segmentName: {
|
||||
name: '{{name}}',
|
||||
casing: 'camelCase',
|
||||
},
|
||||
},
|
||||
validator: 'zod',
|
||||
},
|
||||
],
|
||||
})
|
||||
@ -31,6 +31,7 @@
|
||||
"dev:proxy": "tsx ./scripts/dev-hono-proxy.ts",
|
||||
"dev:vinext": "vinext dev",
|
||||
"gen-doc-paths": "tsx ./scripts/gen-doc-paths.ts",
|
||||
"gen-enterprise-contract": "openapi-ts -f openapi-ts.enterprise.config.ts",
|
||||
"gen-icons": "pnpm --filter @dify/iconify-collections generate && node ./scripts/gen-icons.mjs && eslint --fix app/components/base/icons/src/",
|
||||
"i18n:check": "tsx ./scripts/check-i18n.js",
|
||||
"knip": "knip",
|
||||
@ -75,6 +76,7 @@
|
||||
"@t3-oss/env-nextjs": "catalog:",
|
||||
"@tailwindcss/typography": "catalog:",
|
||||
"@tanstack/react-form": "catalog:",
|
||||
"@tanstack/react-hotkeys": "catalog:",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tanstack/react-virtual": "catalog:",
|
||||
"abcjs": "catalog:",
|
||||
@ -158,6 +160,7 @@
|
||||
"@dify/tsconfig": "workspace:*",
|
||||
"@egoist/tailwindcss-icons": "catalog:",
|
||||
"@eslint-react/eslint-plugin": "catalog:",
|
||||
"@hey-api/openapi-ts": "catalog:",
|
||||
"@hono/node-server": "catalog:",
|
||||
"@iconify-json/heroicons": "catalog:",
|
||||
"@iconify-json/ri": "catalog:",
|
||||
|
||||
@ -2,7 +2,7 @@ import type { Plugin } from 'vite'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { codeInspectorPlugin } from 'code-inspector-plugin'
|
||||
import { injectClientSnippet, normalizeViteModuleId } from './utils'
|
||||
import { injectClientSnippet, normalizeViteModuleId } from './utils.ts'
|
||||
|
||||
type CodeInspectorPluginOptions = {
|
||||
injectTarget: string
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { Plugin } from 'vite'
|
||||
import fs from 'node:fs'
|
||||
import { injectClientSnippet, normalizeViteModuleId } from './utils'
|
||||
import { injectClientSnippet, normalizeViteModuleId } from './utils.ts'
|
||||
|
||||
type CustomI18nHmrPluginOptions = {
|
||||
injectTarget: string
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { Plugin } from 'vite'
|
||||
import path from 'node:path'
|
||||
import { normalizeViteModuleId } from './utils'
|
||||
import { normalizeViteModuleId } from './utils.ts'
|
||||
|
||||
type NextStaticImageTestPluginOptions = {
|
||||
projectRoot: string
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
"vitest/globals",
|
||||
"node"
|
||||
],
|
||||
"allowImportingTsExtensions": true,
|
||||
"allowJs": true
|
||||
},
|
||||
"include": [
|
||||
|
||||
@ -4,10 +4,10 @@ import react from '@vitejs/plugin-react'
|
||||
import vinext from 'vinext'
|
||||
import Inspect from 'vite-plugin-inspect'
|
||||
import { defineConfig } from 'vite-plus'
|
||||
import { createCodeInspectorPlugin, createForceInspectorClientInjectionPlugin } from './plugins/vite/code-inspector'
|
||||
import { customI18nHmrPlugin } from './plugins/vite/custom-i18n-hmr'
|
||||
import { getRootClientInjectTarget } from './plugins/vite/inject-target'
|
||||
import { nextStaticImageTestPlugin } from './plugins/vite/next-static-image-test'
|
||||
import { createCodeInspectorPlugin, createForceInspectorClientInjectionPlugin } from './plugins/vite/code-inspector.ts'
|
||||
import { customI18nHmrPlugin } from './plugins/vite/custom-i18n-hmr.ts'
|
||||
import { getRootClientInjectTarget } from './plugins/vite/inject-target.ts'
|
||||
import { nextStaticImageTestPlugin } from './plugins/vite/next-static-image-test.ts'
|
||||
|
||||
const projectRoot = fileURLToPath(new URL('.', import.meta.url))
|
||||
const isCI = !!process.env.CI
|
||||
|
||||
Loading…
Reference in New Issue
Block a user