flyteplugins-union 0.4.0__tar.gz → 0.4.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {flyteplugins_union-0.4.0/src/flyteplugins_union.egg-info → flyteplugins_union-0.4.2}/PKG-INFO +1 -1
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/licenses/BSL.txt +1 -1
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/pyproject.toml +1 -0
- flyteplugins_union-0.4.2/src/flyteplugins/union/cli/_tui/__init__.py +36 -0
- flyteplugins_union-0.4.2/src/flyteplugins/union/cli/_tui/_volume_explore.py +595 -0
- flyteplugins_union-0.4.2/src/flyteplugins/union/cli/_volume_session.py +224 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/cli/cluster_pool.py +2 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/cli/queue.py +16 -1
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/cli/volume.py +25 -60
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/errors.py +12 -0
- flyteplugins_union-0.4.2/src/flyteplugins/union/internal/cluster/definition_pb2.py +102 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/cluster/definition_pb2.pyi +8 -4
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/clusterpool/clusterpool_connect.py +0 -130
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/clusterpool/clusterpool_pb2.py +2 -6
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/clusterpool/payload_pb2.py +13 -23
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/clusterpool/payload_pb2.pyi +11 -36
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/queue/queue_pb2.py +36 -38
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/queue/queue_pb2.pyi +4 -2
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/io/_base_volume.py +50 -5
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/io/_internal/_juicefs/_backend.py +55 -15
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/io/_volume_transformer.py +11 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/remote/_cluster_pool.py +1 -35
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/remote/_queue.py +15 -1
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/remote/_volume_explore.py +10 -5
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2/src/flyteplugins_union.egg-info}/PKG-INFO +1 -1
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins_union.egg-info/SOURCES.txt +1 -0
- flyteplugins_union-0.4.0/src/flyteplugins/union/cli/_tui/__init__.py +0 -67
- flyteplugins_union-0.4.0/src/flyteplugins/union/cli/_tui/_volume_explore.py +0 -484
- flyteplugins_union-0.4.0/src/flyteplugins/union/internal/cluster/definition_pb2.py +0 -100
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/LICENSE +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/README.md +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/README_PYPI.md +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/licenses/APL.txt +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/setup.cfg +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/__init__.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/cli/__init__.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/cli/_volume_index.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/cli/api_key.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/cli/assignment.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/cli/cluster.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/cli/member.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/cli/policy.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/cli/role.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/cli/user.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/__init__.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/authorizer/authorizer_connect.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/authorizer/authorizer_pb2.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/authorizer/authorizer_pb2.pyi +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/authorizer/definition_pb2.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/authorizer/definition_pb2.pyi +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/authorizer/payload_pb2.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/authorizer/payload_pb2.pyi +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/cluster/cluster_connect.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/cluster/cluster_pb2.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/cluster/cluster_pb2.pyi +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/cluster/payload_pb2.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/cluster/payload_pb2.pyi +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/clusterpool/clusterpool_pb2.pyi +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/common/authorization_pb2.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/common/authorization_pb2.pyi +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/common/cluster_pb2.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/common/cluster_pb2.pyi +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/common/deployment_pb2.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/common/deployment_pb2.pyi +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/common/identifier_pb2.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/common/identifier_pb2.pyi +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/common/identity_pb2.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/common/identity_pb2.pyi +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/common/list_pb2.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/common/list_pb2.pyi +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/common/policy_pb2.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/common/policy_pb2.pyi +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/common/role_pb2.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/common/role_pb2.pyi +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/app_definition_pb2.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/app_definition_pb2.pyi +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/app_payload_pb2.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/app_payload_pb2.pyi +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/app_service_connect.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/app_service_pb2.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/app_service_pb2.pyi +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/enums_pb2.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/enums_pb2.pyi +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/member_payload_pb2.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/member_payload_pb2.pyi +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/member_service_connect.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/member_service_pb2.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/member_service_pb2.pyi +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/policy_payload_pb2.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/policy_payload_pb2.pyi +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/policy_service_connect.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/policy_service_pb2.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/policy_service_pb2.pyi +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/role_payload_pb2.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/role_payload_pb2.pyi +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/role_service_connect.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/role_service_pb2.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/role_service_pb2.pyi +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/user_payload_pb2.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/user_payload_pb2.pyi +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/user_service_connect.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/user_service_pb2.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/user_service_pb2.pyi +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/queue/queue_connect.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/validate/__init__.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/validate/validate/__init__.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/validate/validate/validate_pb2.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/io/__init__.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/io/_internal/__init__.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/io/_internal/_juicefs/__init__.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/io/_internal/_juicefs/_common.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/io/_internal/_juicefs/_metadata.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/io/_internal/_juicefs/_redis.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/io/_internal/_juicefs/_sqlite.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/io/_internal/_juicefs/bin/.gitkeep +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/io/_internal/backend.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/io/_ro_volume.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/io/_rw_volume.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/remote/__init__.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/remote/_api_key.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/remote/_assignment.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/remote/_cluster.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/remote/_member.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/remote/_policy.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/remote/_role.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/remote/_user.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/utils/__init__.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/utils/auth.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/utils/image.py +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins_union.egg-info/dependency_links.txt +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins_union.egg-info/entry_points.txt +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins_union.egg-info/requires.txt +0 -0
- {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins_union.egg-info/top_level.txt +0 -0
|
@@ -17,7 +17,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
|
|
|
17
17
|
computer programs whose source code are controlled by
|
|
18
18
|
such third parties.
|
|
19
19
|
|
|
20
|
-
Change Date: 2030-06-
|
|
20
|
+
Change Date: 2030-06-16
|
|
21
21
|
|
|
22
22
|
Change License: Apache License, Version 2.0
|
|
23
23
|
|
|
@@ -172,6 +172,7 @@ select = [
|
|
|
172
172
|
# headless installs don't pay the runtime/optional-dep import cost.
|
|
173
173
|
"src/flyteplugins/union/cli/volume.py" = ["PLC0415"]
|
|
174
174
|
"src/flyteplugins/union/cli/_volume_index.py" = ["PLC0415"]
|
|
175
|
+
"src/flyteplugins/union/cli/_volume_session.py" = ["PLC0415"]
|
|
175
176
|
"src/flyteplugins/union/cli/_tui/**/*.py" = ["PLC0415"]
|
|
176
177
|
# VolumeExplore lazy-imports flyte.io / msgpack / boto3 / the Volume model so
|
|
177
178
|
# `import flyteplugins.union.remote` stays light for non-volume callers.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Lazy entrypoint for the volume explore TUI.
|
|
2
|
+
|
|
3
|
+
Importing ``textual`` is gated behind the ``[tui]`` extra so headless
|
|
4
|
+
installs don't pay the cost. The launcher below catches the
|
|
5
|
+
:class:`ImportError` and re-raises with a clear install hint, matching the
|
|
6
|
+
ergonomic of :mod:`flyte.cli._tui` upstream.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from flyteplugins.union.cli._volume_session import ExploreSession
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def launch_volume_explore(*, session: "ExploreSession", title: str, subtitle: str = "") -> None:
|
|
18
|
+
"""Run the volume explore TUI over an :class:`ExploreSession`.
|
|
19
|
+
|
|
20
|
+
The session arrives with version 0 already open (reader + summary); the
|
|
21
|
+
app discovers ancestors in the background and opens their indexes on
|
|
22
|
+
demand, all without leaving the TUI. The caller owns the session's
|
|
23
|
+
lifecycle — close it (``session.aclose()``) after this returns.
|
|
24
|
+
|
|
25
|
+
Raises a helpful :class:`ImportError` when ``textual`` is not installed.
|
|
26
|
+
"""
|
|
27
|
+
try:
|
|
28
|
+
from flyteplugins.union.cli._tui._volume_explore import VolumeExploreApp
|
|
29
|
+
except ImportError as exc:
|
|
30
|
+
raise ImportError(
|
|
31
|
+
"The volume explore TUI requires the 'textual' package. "
|
|
32
|
+
"Install it with: pip install 'flyteplugins-union[tui]'"
|
|
33
|
+
) from exc
|
|
34
|
+
|
|
35
|
+
app = VolumeExploreApp(session=session, title=title, subtitle=subtitle)
|
|
36
|
+
await app.run_async()
|
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
"""Textual TUI for browsing a Volume metadata index and its lineage.
|
|
2
|
+
|
|
3
|
+
Three panes, one long-lived app:
|
|
4
|
+
|
|
5
|
+
* **Lineage (left)** — the version chain, oldest ancestor at the top, the
|
|
6
|
+
launched version at the bottom. Ancestors are discovered a couple at a
|
|
7
|
+
time (each hop is a metadata download, so a deep chain is never walked
|
|
8
|
+
wholesale): a ``--More--`` row at the top fetches the next batch, and it
|
|
9
|
+
becomes ``--root--`` once the chain is exhausted. Selecting a version
|
|
10
|
+
loads its file system *in place* (a worker downloads + opens the index);
|
|
11
|
+
the screen never tears down. Already-visited versions keep their tree —
|
|
12
|
+
including expansion state — so hopping back to the child is instant.
|
|
13
|
+
Below the list, a card shows the highlighted version's metadata
|
|
14
|
+
(producer, message, stats).
|
|
15
|
+
* **Files (middle)** — the lazy directory tree of the active version, exactly
|
|
16
|
+
as before (vim ``j``/``k`` + arrows; children fetched on first expand).
|
|
17
|
+
* **File (right)** — metadata about the node under the cursor, nothing else.
|
|
18
|
+
|
|
19
|
+
Top-level keys: ``q`` quit, ``r`` refresh tree, ``p`` jump to the active
|
|
20
|
+
version's parent (same as selecting it in the lineage pane).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import datetime
|
|
26
|
+
import stat as _stat
|
|
27
|
+
from typing import Any, ClassVar, Optional
|
|
28
|
+
|
|
29
|
+
from rich.text import Text
|
|
30
|
+
from textual.app import App, ComposeResult
|
|
31
|
+
from textual.binding import Binding, BindingType
|
|
32
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
33
|
+
from textual.widgets import ContentSwitcher, Footer, Header, OptionList, Static, Tree
|
|
34
|
+
from textual.widgets.option_list import Option
|
|
35
|
+
from textual.widgets.tree import TreeNode
|
|
36
|
+
|
|
37
|
+
from flyteplugins.union.cli._volume_index import IndexEntry, IndexReader
|
|
38
|
+
from flyteplugins.union.cli._volume_session import ExploreSession, VersionHandle
|
|
39
|
+
|
|
40
|
+
# Match flyte-sdk's _explore palette exactly so the experience feels
|
|
41
|
+
# native to anyone who's run `flyte start tui`.
|
|
42
|
+
_FLYTE_PURPLE = "#7652a2"
|
|
43
|
+
_FLYTE_PURPLE_LIGHT = "#f7f5fd"
|
|
44
|
+
_FLYTE_PURPLE_DARK = "#171020"
|
|
45
|
+
|
|
46
|
+
_KIND_ICON = {
|
|
47
|
+
"dir": "📁",
|
|
48
|
+
"file": "📄",
|
|
49
|
+
"symlink": "🔗",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# Lineage discovery is batched — every hop downloads one metadata object, so
|
|
53
|
+
# the app fetches a couple at a time and parks the rest behind ``--More--``
|
|
54
|
+
# instead of walking a deep (potentially 100-version) chain up front.
|
|
55
|
+
_LINEAGE_BATCH = 2
|
|
56
|
+
_MORE_ID = "lineage-more"
|
|
57
|
+
_ROOT_ID = "lineage-root"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _fmt_size(n: int) -> str:
|
|
61
|
+
units = ["B", "KB", "MB", "GB", "TB", "PB"]
|
|
62
|
+
f = float(n)
|
|
63
|
+
for u in units:
|
|
64
|
+
if f < 1024 or u == units[-1]:
|
|
65
|
+
return f"{f:,.1f} {u}" if u != "B" else f"{int(f):,} {u}"
|
|
66
|
+
f /= 1024
|
|
67
|
+
return f"{n} B"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _fmt_mtime(mtime_ns: int) -> str:
|
|
71
|
+
if not mtime_ns:
|
|
72
|
+
return ""
|
|
73
|
+
try:
|
|
74
|
+
dt = datetime.datetime.fromtimestamp(mtime_ns / 1e9)
|
|
75
|
+
except (OSError, OverflowError, ValueError):
|
|
76
|
+
return ""
|
|
77
|
+
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _fmt_mode(mode: int, kind: str) -> str:
|
|
81
|
+
if not mode:
|
|
82
|
+
return f"<{kind}>"
|
|
83
|
+
try:
|
|
84
|
+
return _stat.filemode(mode)
|
|
85
|
+
except (TypeError, ValueError):
|
|
86
|
+
return oct(mode)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _label(entry: IndexEntry) -> str:
|
|
90
|
+
icon = _KIND_ICON.get(entry.kind, "•")
|
|
91
|
+
if entry.kind == "dir":
|
|
92
|
+
return f"{icon} {entry.name}/"
|
|
93
|
+
if entry.kind == "symlink" and entry.symlink_target:
|
|
94
|
+
return f"{icon} {entry.name} → {entry.symlink_target}"
|
|
95
|
+
return f"{icon} {entry.name} ({_fmt_size(entry.size)})"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class _DetailBox(Static):
|
|
99
|
+
"""Bordered card; markup off so raw paths/JSON render literally."""
|
|
100
|
+
|
|
101
|
+
DEFAULT_CSS = """
|
|
102
|
+
_DetailBox {
|
|
103
|
+
border: solid $accent;
|
|
104
|
+
padding: 0 1;
|
|
105
|
+
margin-bottom: 1;
|
|
106
|
+
height: auto;
|
|
107
|
+
}
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
111
|
+
kwargs.setdefault("markup", False)
|
|
112
|
+
super().__init__(*args, **kwargs)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class VolumeFileTree(Tree[int]):
|
|
116
|
+
"""Lazy directory tree backed by an :class:`IndexReader`.
|
|
117
|
+
|
|
118
|
+
Each node's ``data`` carries the inode number. Directories are added
|
|
119
|
+
with a stub child so the disclosure arrow shows up; the stub is
|
|
120
|
+
replaced with real entries on first expand.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
124
|
+
Binding("down,j", "cursor_down", "Cursor Down", show=False),
|
|
125
|
+
Binding("up,k", "cursor_up", "Cursor Up", show=False),
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
def __init__(self, reader: IndexReader, root_inode: int, **kwargs: Any) -> None:
|
|
129
|
+
super().__init__("/", data=root_inode, **kwargs)
|
|
130
|
+
self.reader = reader
|
|
131
|
+
self._populated: set[int] = set()
|
|
132
|
+
|
|
133
|
+
def on_mount(self) -> None:
|
|
134
|
+
self.root.allow_expand = True
|
|
135
|
+
# Reader I/O is async (aiosqlite); populate + expand the root on a
|
|
136
|
+
# worker so the event loop stays responsive while it loads.
|
|
137
|
+
self.run_worker(self._populate_and_expand(self.root), exclusive=False)
|
|
138
|
+
|
|
139
|
+
def reload(self) -> None:
|
|
140
|
+
"""Reset the tree to its root and re-populate from scratch (the ``r``
|
|
141
|
+
refresh). Must clear ``_populated`` — otherwise the post-reset root is
|
|
142
|
+
still flagged as populated and :meth:`_populate` would no-op, leaving an
|
|
143
|
+
empty tree.
|
|
144
|
+
"""
|
|
145
|
+
self.reset(self.root.label, data=self.root.data)
|
|
146
|
+
self._populated.clear()
|
|
147
|
+
self.root.allow_expand = True
|
|
148
|
+
self.run_worker(self._populate_and_expand(self.root), exclusive=False)
|
|
149
|
+
|
|
150
|
+
async def _populate_and_expand(self, node: TreeNode[int]) -> None:
|
|
151
|
+
await self._populate(node)
|
|
152
|
+
node.expand()
|
|
153
|
+
|
|
154
|
+
async def _populate(self, node: TreeNode[int]) -> None:
|
|
155
|
+
inode = node.data
|
|
156
|
+
if inode is None or inode in self._populated:
|
|
157
|
+
return
|
|
158
|
+
self._populated.add(inode)
|
|
159
|
+
node.remove_children()
|
|
160
|
+
for entry in await self.reader.children(inode):
|
|
161
|
+
child = node.add(_label(entry), data=entry.inode, allow_expand=(entry.kind == "dir"))
|
|
162
|
+
if entry.kind == "dir":
|
|
163
|
+
# Placeholder so the disclosure arrow shows up; replaced
|
|
164
|
+
# on first expand.
|
|
165
|
+
child.add_leaf("…")
|
|
166
|
+
self._populated.discard(entry.inode)
|
|
167
|
+
|
|
168
|
+
def on_tree_node_expanded(self, event: Tree.NodeExpanded) -> None:
|
|
169
|
+
node = event.node
|
|
170
|
+
if node.data is not None and node.data not in self._populated:
|
|
171
|
+
self.run_worker(self._populate(node), exclusive=False)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class LineageList(OptionList):
|
|
175
|
+
"""Left-pane version chain, oldest ancestor first (top)."""
|
|
176
|
+
|
|
177
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
178
|
+
Binding("down,j", "cursor_down", "Cursor Down", show=False),
|
|
179
|
+
Binding("up,k", "cursor_up", "Cursor Up", show=False),
|
|
180
|
+
]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _version_text(handle: VersionHandle, *, loading: bool = False) -> str:
|
|
184
|
+
"""Render the Version card for one lineage entry."""
|
|
185
|
+
lines: list[str] = [f"{handle.label}: {handle.name}"]
|
|
186
|
+
if handle.message:
|
|
187
|
+
lines.append(f"message: {handle.message}")
|
|
188
|
+
pb = handle.produced_by or {}
|
|
189
|
+
if pb:
|
|
190
|
+
who = pb.get("action") or "?"
|
|
191
|
+
if pb.get("run"):
|
|
192
|
+
who = f"{pb['run']} / {who}"
|
|
193
|
+
lines.append(f"by: {who}")
|
|
194
|
+
if pb.get("op"):
|
|
195
|
+
lines.append(f"output: {pb['op']}")
|
|
196
|
+
if pb.get("project") or pb.get("domain"):
|
|
197
|
+
lines.append(f"scope: {pb.get('project') or '?'}/{pb.get('domain') or '?'}")
|
|
198
|
+
# Live summary (once opened) beats the stats stored on the Volume value.
|
|
199
|
+
inodes = handle.summary.total_inodes if handle.summary is not None else handle.inode_count
|
|
200
|
+
used = handle.summary.used_bytes if handle.summary is not None else handle.used_bytes
|
|
201
|
+
if inodes is not None:
|
|
202
|
+
lines.append(f"inodes: {inodes:,}")
|
|
203
|
+
if used is not None:
|
|
204
|
+
lines.append(f"size: {_fmt_size(used)} ({used:,} bytes)")
|
|
205
|
+
if handle.store_type:
|
|
206
|
+
lines.append(f"backend: {handle.store_type}")
|
|
207
|
+
if handle.index_path:
|
|
208
|
+
lines.append(f"index: {handle.index_path}")
|
|
209
|
+
if handle.error:
|
|
210
|
+
lines.append(f"error: {handle.error}")
|
|
211
|
+
elif loading:
|
|
212
|
+
lines.append("state: loading…")
|
|
213
|
+
elif handle.opened:
|
|
214
|
+
lines.append("state: loaded")
|
|
215
|
+
else:
|
|
216
|
+
lines.append("state: press Enter to load")
|
|
217
|
+
return "\n".join(lines)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _file_text(entry: IndexEntry, chunk_count: int) -> str:
|
|
221
|
+
"""Render the File card for the node under the tree cursor."""
|
|
222
|
+
lines: list[str] = []
|
|
223
|
+
lines.append(f"name: {entry.name or '/'}")
|
|
224
|
+
lines.append(f"inode: {entry.inode}")
|
|
225
|
+
lines.append(f"kind: {entry.kind}")
|
|
226
|
+
if entry.kind == "file":
|
|
227
|
+
lines.append(f"size: {_fmt_size(entry.size)} ({entry.size:,} bytes)")
|
|
228
|
+
if chunk_count >= 0:
|
|
229
|
+
lines.append(f"chunks: {chunk_count}")
|
|
230
|
+
else:
|
|
231
|
+
lines.append("chunks: — (not available for this backend)")
|
|
232
|
+
if entry.kind == "symlink" and entry.symlink_target:
|
|
233
|
+
lines.append(f"target: {entry.symlink_target}")
|
|
234
|
+
lines.append(f"mode: {_fmt_mode(entry.mode, entry.kind)} ({oct(entry.mode)})")
|
|
235
|
+
lines.append(f"owner: {entry.uid}:{entry.gid}")
|
|
236
|
+
lines.append(f"nlink: {entry.nlink}")
|
|
237
|
+
mtime = _fmt_mtime(entry.mtime_ns)
|
|
238
|
+
if mtime:
|
|
239
|
+
lines.append(f"mtime: {mtime}")
|
|
240
|
+
return "\n".join(lines)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class VolumeExploreApp(App[None]):
|
|
244
|
+
"""Standalone Textual app for browsing a Volume's lineage and indexes."""
|
|
245
|
+
|
|
246
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
247
|
+
Binding("q", "quit_app", "Quit"),
|
|
248
|
+
Binding("r", "refresh_tree", "Refresh"),
|
|
249
|
+
Binding("p", "open_parent", "Open parent"),
|
|
250
|
+
]
|
|
251
|
+
|
|
252
|
+
CSS = f"""
|
|
253
|
+
Screen {{
|
|
254
|
+
background: {_FLYTE_PURPLE_DARK};
|
|
255
|
+
}}
|
|
256
|
+
Header {{
|
|
257
|
+
background: {_FLYTE_PURPLE};
|
|
258
|
+
color: {_FLYTE_PURPLE_LIGHT};
|
|
259
|
+
}}
|
|
260
|
+
Footer {{
|
|
261
|
+
background: {_FLYTE_PURPLE};
|
|
262
|
+
color: {_FLYTE_PURPLE_LIGHT};
|
|
263
|
+
}}
|
|
264
|
+
Horizontal {{
|
|
265
|
+
height: 1fr;
|
|
266
|
+
}}
|
|
267
|
+
#lineage-pane {{
|
|
268
|
+
width: 1fr;
|
|
269
|
+
min-width: 26;
|
|
270
|
+
}}
|
|
271
|
+
LineageList {{
|
|
272
|
+
height: 1fr;
|
|
273
|
+
border: solid {_FLYTE_PURPLE};
|
|
274
|
+
border-title-color: {_FLYTE_PURPLE_LIGHT};
|
|
275
|
+
background: {_FLYTE_PURPLE_DARK};
|
|
276
|
+
color: {_FLYTE_PURPLE_LIGHT};
|
|
277
|
+
}}
|
|
278
|
+
LineageList > .option-list--option-highlighted {{
|
|
279
|
+
background: {_FLYTE_PURPLE};
|
|
280
|
+
}}
|
|
281
|
+
#tree-switcher {{
|
|
282
|
+
width: 2fr;
|
|
283
|
+
min-width: 32;
|
|
284
|
+
height: 1fr;
|
|
285
|
+
}}
|
|
286
|
+
VolumeFileTree {{
|
|
287
|
+
width: 100%;
|
|
288
|
+
height: 100%;
|
|
289
|
+
border: solid {_FLYTE_PURPLE};
|
|
290
|
+
border-title-color: {_FLYTE_PURPLE_LIGHT};
|
|
291
|
+
background: {_FLYTE_PURPLE_DARK};
|
|
292
|
+
color: {_FLYTE_PURPLE_LIGHT};
|
|
293
|
+
}}
|
|
294
|
+
#detail-pane {{
|
|
295
|
+
width: 1fr;
|
|
296
|
+
min-width: 28;
|
|
297
|
+
background: {_FLYTE_PURPLE_DARK};
|
|
298
|
+
}}
|
|
299
|
+
_DetailBox {{
|
|
300
|
+
border: solid {_FLYTE_PURPLE};
|
|
301
|
+
border-title-color: {_FLYTE_PURPLE_LIGHT};
|
|
302
|
+
padding: 0 1;
|
|
303
|
+
margin-bottom: 1;
|
|
304
|
+
height: auto;
|
|
305
|
+
color: {_FLYTE_PURPLE_LIGHT};
|
|
306
|
+
}}
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
def __init__(self, *, session: ExploreSession, title: str, subtitle: str = "") -> None:
|
|
310
|
+
super().__init__()
|
|
311
|
+
self._session = session
|
|
312
|
+
self._title = title
|
|
313
|
+
self._subtitle = subtitle
|
|
314
|
+
self._active = 0 # index into session.versions of the shown tree
|
|
315
|
+
self._opening: set[int] = set() # versions with an in-flight open
|
|
316
|
+
self._loading_more = False # a lineage batch fetch is in flight
|
|
317
|
+
|
|
318
|
+
# --- layout ---------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
def compose(self) -> ComposeResult:
|
|
321
|
+
first = self._session.versions[0]
|
|
322
|
+
yield Header()
|
|
323
|
+
with Horizontal():
|
|
324
|
+
with Vertical(id="lineage-pane"):
|
|
325
|
+
lineage = LineageList(id="lineage-list")
|
|
326
|
+
lineage.border_title = "Lineage (oldest ↑)"
|
|
327
|
+
yield lineage
|
|
328
|
+
yield _DetailBox(id="box-version")
|
|
329
|
+
with ContentSwitcher(id="tree-switcher", initial="tree-0"):
|
|
330
|
+
# Version 0's reader is pre-opened by the launcher, so the
|
|
331
|
+
# first tree mounts synchronously.
|
|
332
|
+
assert first.reader is not None and first.summary is not None
|
|
333
|
+
tree = VolumeFileTree(first.reader, root_inode=first.summary.root_inode, id="tree-0")
|
|
334
|
+
tree.border_title = first.name
|
|
335
|
+
yield tree
|
|
336
|
+
with VerticalScroll(id="detail-pane"):
|
|
337
|
+
yield _DetailBox(id="box-file")
|
|
338
|
+
yield Footer()
|
|
339
|
+
|
|
340
|
+
def on_mount(self) -> None:
|
|
341
|
+
self.title = self._title
|
|
342
|
+
if self._subtitle:
|
|
343
|
+
self.sub_title = self._subtitle
|
|
344
|
+
vbox = self.query_one("#box-version", _DetailBox)
|
|
345
|
+
vbox.border_title = "Version"
|
|
346
|
+
fbox = self.query_one("#box-file", _DetailBox)
|
|
347
|
+
fbox.border_title = "File"
|
|
348
|
+
fbox.update("Pick a file or directory in the tree.")
|
|
349
|
+
self._rebuild_lineage()
|
|
350
|
+
self._update_version_card(0)
|
|
351
|
+
self.query_one("#tree-0", VolumeFileTree).focus()
|
|
352
|
+
# Discover the first couple of ancestors in the background. Anything
|
|
353
|
+
# deeper stays behind the ``--More--`` row — each hop is a metadata
|
|
354
|
+
# download, so a deep chain must never be walked wholesale.
|
|
355
|
+
self._request_more_lineage()
|
|
356
|
+
|
|
357
|
+
# --- lineage pane -----------------------------------------------------
|
|
358
|
+
|
|
359
|
+
def _request_more_lineage(self) -> None:
|
|
360
|
+
"""Kick off one bounded batch of ancestor discovery (no-op when a
|
|
361
|
+
batch is already in flight or the chain is exhausted)."""
|
|
362
|
+
if self._loading_more or not self._session.has_more():
|
|
363
|
+
return
|
|
364
|
+
self._loading_more = True
|
|
365
|
+
self._rebuild_lineage()
|
|
366
|
+
self.run_worker(self._load_more_lineage(), exclusive=False, group="lineage")
|
|
367
|
+
|
|
368
|
+
async def _load_more_lineage(self) -> None:
|
|
369
|
+
try:
|
|
370
|
+
await self._session.extend(_LINEAGE_BATCH)
|
|
371
|
+
finally:
|
|
372
|
+
self._loading_more = False
|
|
373
|
+
self._rebuild_lineage()
|
|
374
|
+
self.refresh_bindings()
|
|
375
|
+
|
|
376
|
+
def _row_prompt(self, idx: int, handle: VersionHandle) -> Text:
|
|
377
|
+
marker = "●" if idx == self._active else " "
|
|
378
|
+
text = f"{marker} [{handle.label}] {handle.name}"
|
|
379
|
+
if handle.message:
|
|
380
|
+
text += f" — {handle.message}"
|
|
381
|
+
if handle.error:
|
|
382
|
+
text += " (unavailable)"
|
|
383
|
+
elif idx in self._opening:
|
|
384
|
+
text += " (loading…)"
|
|
385
|
+
return Text(text)
|
|
386
|
+
|
|
387
|
+
def _top_marker(self) -> Optional[Option]:
|
|
388
|
+
"""The terminal row above the oldest discovered version.
|
|
389
|
+
|
|
390
|
+
``--More--`` when the chain continues (select it to fetch the next
|
|
391
|
+
batch), ``--root--`` once the walk has bottomed out. No marker for
|
|
392
|
+
raw-index sessions (lineage unknowable) or after a failed-load tail
|
|
393
|
+
(that row is its own terminal indicator).
|
|
394
|
+
"""
|
|
395
|
+
if self._session.has_more() or self._loading_more:
|
|
396
|
+
text = " --More-- (loading…)" if self._loading_more else " --More--"
|
|
397
|
+
return Option(Text(text), id=_MORE_ID, disabled=self._loading_more)
|
|
398
|
+
tip = self._session.versions[-1]
|
|
399
|
+
if tip.ve is None and (tip.error or len(self._session.versions) == 1):
|
|
400
|
+
return None
|
|
401
|
+
return Option(Text(" --root--"), id=_ROOT_ID, disabled=True)
|
|
402
|
+
|
|
403
|
+
def _rebuild_lineage(self) -> None:
|
|
404
|
+
"""Re-render the version list (oldest at the top, under the marker
|
|
405
|
+
row), keeping the highlight on the same row across rebuilds."""
|
|
406
|
+
lineage = self.query_one("#lineage-list", LineageList)
|
|
407
|
+
kept: Optional[str] = None
|
|
408
|
+
if lineage.highlighted is not None:
|
|
409
|
+
kept = lineage.get_option_at_index(lineage.highlighted).id
|
|
410
|
+
lineage.clear_options()
|
|
411
|
+
marker = self._top_marker()
|
|
412
|
+
if marker is not None:
|
|
413
|
+
lineage.add_option(marker)
|
|
414
|
+
lineage.add_options(
|
|
415
|
+
Option(self._row_prompt(idx, handle), id=str(idx), disabled=bool(handle.error))
|
|
416
|
+
for idx, handle in reversed(list(enumerate(self._session.versions)))
|
|
417
|
+
)
|
|
418
|
+
target = kept if kept is not None else str(self._active)
|
|
419
|
+
try:
|
|
420
|
+
lineage.highlighted = lineage.get_option_index(target)
|
|
421
|
+
except Exception:
|
|
422
|
+
if target in (_MORE_ID, _ROOT_ID):
|
|
423
|
+
lineage.highlighted = 0 # the marker was replaced; stay at the top
|
|
424
|
+
else:
|
|
425
|
+
lineage.highlighted = lineage.option_count - 1 # bottom = current
|
|
426
|
+
|
|
427
|
+
def _update_version_card(self, idx: int) -> None:
|
|
428
|
+
handle = self._session.versions[idx]
|
|
429
|
+
self.query_one("#box-version", _DetailBox).update(
|
|
430
|
+
_version_text(handle, loading=idx in self._opening),
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None:
|
|
434
|
+
if event.option_list.id != "lineage-list" or event.option.id is None:
|
|
435
|
+
return
|
|
436
|
+
if event.option.id == _MORE_ID:
|
|
437
|
+
hint = "Earlier versions exist.\nPress Enter to load a couple more."
|
|
438
|
+
self.query_one("#box-version", _DetailBox).update(hint)
|
|
439
|
+
return
|
|
440
|
+
if event.option.id == _ROOT_ID:
|
|
441
|
+
self.query_one("#box-version", _DetailBox).update("The lineage ends here — no earlier versions.")
|
|
442
|
+
return
|
|
443
|
+
self._update_version_card(int(event.option.id))
|
|
444
|
+
|
|
445
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
446
|
+
if event.option_list.id != "lineage-list" or event.option.id is None:
|
|
447
|
+
return
|
|
448
|
+
if event.option.id == _MORE_ID:
|
|
449
|
+
self._request_more_lineage()
|
|
450
|
+
return
|
|
451
|
+
if event.option.id == _ROOT_ID:
|
|
452
|
+
return
|
|
453
|
+
self._activate(int(event.option.id))
|
|
454
|
+
|
|
455
|
+
# --- version switching --------------------------------------------------
|
|
456
|
+
|
|
457
|
+
def _activate(self, idx: int) -> None:
|
|
458
|
+
"""Show version ``idx``'s file system, opening its index on demand.
|
|
459
|
+
|
|
460
|
+
Everything happens inside the running app: a worker downloads/opens
|
|
461
|
+
the index while the lineage row shows ``(loading…)``; on success the
|
|
462
|
+
middle pane switches to that version's tree. Cached versions switch
|
|
463
|
+
instantly, with their previous expansion state intact.
|
|
464
|
+
"""
|
|
465
|
+
handle = self._session.versions[idx]
|
|
466
|
+
if handle.error:
|
|
467
|
+
self.notify(handle.error, severity="warning")
|
|
468
|
+
return
|
|
469
|
+
if idx in self._opening:
|
|
470
|
+
return
|
|
471
|
+
self._opening.add(idx)
|
|
472
|
+
self._rebuild_lineage()
|
|
473
|
+
self._update_version_card(idx)
|
|
474
|
+
self.run_worker(self._open_and_switch(idx), exclusive=False, group="open-version")
|
|
475
|
+
|
|
476
|
+
async def _open_and_switch(self, idx: int) -> None:
|
|
477
|
+
try:
|
|
478
|
+
handle = await self._session.open(idx)
|
|
479
|
+
if handle.error or handle.reader is None or handle.summary is None:
|
|
480
|
+
self.notify(
|
|
481
|
+
f"Could not open {handle.name}: {handle.error or 'no index'}",
|
|
482
|
+
severity="error",
|
|
483
|
+
timeout=10,
|
|
484
|
+
)
|
|
485
|
+
return
|
|
486
|
+
switcher = self.query_one("#tree-switcher", ContentSwitcher)
|
|
487
|
+
if not switcher.query(f"#tree-{idx}"):
|
|
488
|
+
tree = VolumeFileTree(handle.reader, root_inode=handle.summary.root_inode, id=f"tree-{idx}")
|
|
489
|
+
tree.border_title = handle.name
|
|
490
|
+
await switcher.mount(tree)
|
|
491
|
+
self._switch_to(idx)
|
|
492
|
+
finally:
|
|
493
|
+
self._opening.discard(idx)
|
|
494
|
+
self._rebuild_lineage()
|
|
495
|
+
self.refresh_bindings()
|
|
496
|
+
|
|
497
|
+
def _switch_to(self, idx: int) -> None:
|
|
498
|
+
handle = self._session.versions[idx]
|
|
499
|
+
self._active = idx
|
|
500
|
+
self.query_one("#tree-switcher", ContentSwitcher).current = f"tree-{idx}"
|
|
501
|
+
# Move the lineage highlight onto the activated version (matters for
|
|
502
|
+
# ``p``, which switches without touching the list) so the Version card
|
|
503
|
+
# and the row marker agree on what's shown.
|
|
504
|
+
lineage = self.query_one("#lineage-list", LineageList)
|
|
505
|
+
try:
|
|
506
|
+
lineage.highlighted = lineage.get_option_index(str(idx))
|
|
507
|
+
except Exception:
|
|
508
|
+
pass
|
|
509
|
+
if handle.label != "current":
|
|
510
|
+
self.sub_title = f"{handle.label}: {handle.name}"
|
|
511
|
+
else:
|
|
512
|
+
self.sub_title = self._subtitle or handle.name
|
|
513
|
+
self._update_version_card(idx)
|
|
514
|
+
tree = self.query_one(f"#tree-{idx}", VolumeFileTree)
|
|
515
|
+
tree.focus()
|
|
516
|
+
# Re-render the File card from this tree's cursor so the right pane
|
|
517
|
+
# matches the pane in the middle.
|
|
518
|
+
if tree.cursor_node is not None:
|
|
519
|
+
self.run_worker(self._render_selected(tree, tree.cursor_node), exclusive=True, group="file-detail")
|
|
520
|
+
else:
|
|
521
|
+
self.query_one("#box-file", _DetailBox).update("Pick a file or directory in the tree.")
|
|
522
|
+
|
|
523
|
+
# --- file pane ------------------------------------------------------
|
|
524
|
+
|
|
525
|
+
def on_tree_node_highlighted(self, event: Tree.NodeHighlighted) -> None:
|
|
526
|
+
self._maybe_render_selected(event.control, event.node)
|
|
527
|
+
|
|
528
|
+
def on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
|
|
529
|
+
self._maybe_render_selected(event.control, event.node)
|
|
530
|
+
|
|
531
|
+
def _maybe_render_selected(self, tree: Tree, node: TreeNode[int]) -> None:
|
|
532
|
+
# Hidden (cached) trees can still emit highlight events while they
|
|
533
|
+
# populate; only the active version drives the File card.
|
|
534
|
+
if tree.id == f"tree-{self._active}":
|
|
535
|
+
self.run_worker(self._render_selected(tree, node), exclusive=True, group="file-detail")
|
|
536
|
+
|
|
537
|
+
async def _render_selected(self, tree: Tree, node: TreeNode[int]) -> None:
|
|
538
|
+
inode = node.data
|
|
539
|
+
if inode is None or not isinstance(tree, VolumeFileTree):
|
|
540
|
+
return
|
|
541
|
+
entry = await tree.reader.get(inode)
|
|
542
|
+
if entry is None:
|
|
543
|
+
return
|
|
544
|
+
chunks = await tree.reader.chunk_count(inode) if entry.kind == "file" else 0
|
|
545
|
+
self.query_one("#box-file", _DetailBox).update(_file_text(entry, chunks))
|
|
546
|
+
|
|
547
|
+
# --- actions ----------------------------------------------------------
|
|
548
|
+
|
|
549
|
+
def check_action(self, action: str, parameters: "tuple[object, ...]") -> "bool | None":
|
|
550
|
+
"""Grey out ``p`` when there is no parent to walk to — discovered or
|
|
551
|
+
still parked behind ``--More--``."""
|
|
552
|
+
if action == "open_parent":
|
|
553
|
+
parent_idx = self._active + 1
|
|
554
|
+
if parent_idx < len(self._session.versions):
|
|
555
|
+
return not self._session.versions[parent_idx].error
|
|
556
|
+
return self._session.has_more()
|
|
557
|
+
return True
|
|
558
|
+
|
|
559
|
+
def action_quit_app(self) -> None:
|
|
560
|
+
self.exit()
|
|
561
|
+
|
|
562
|
+
def action_open_parent(self) -> None:
|
|
563
|
+
"""Jump to the active version's parent — same as selecting it in the
|
|
564
|
+
lineage pane; the tree swaps in place, no app restart. From the
|
|
565
|
+
topmost loaded version, fetch the next batch first, then open it."""
|
|
566
|
+
parent_idx = self._active + 1
|
|
567
|
+
if parent_idx < len(self._session.versions):
|
|
568
|
+
self._activate(parent_idx)
|
|
569
|
+
return
|
|
570
|
+
if self._session.has_more():
|
|
571
|
+
self.run_worker(self._load_parent_then_activate(parent_idx), exclusive=False, group="lineage")
|
|
572
|
+
return
|
|
573
|
+
self.notify("Already at the root version.", severity="warning")
|
|
574
|
+
|
|
575
|
+
async def _load_parent_then_activate(self, parent_idx: int) -> None:
|
|
576
|
+
"""The ``p``-at-the-top path: extend the chain by one batch, then
|
|
577
|
+
activate the freshly discovered parent."""
|
|
578
|
+
if self._loading_more:
|
|
579
|
+
return # a batch is already in flight; let it land first
|
|
580
|
+
self._loading_more = True
|
|
581
|
+
self._rebuild_lineage()
|
|
582
|
+
try:
|
|
583
|
+
await self._session.extend(_LINEAGE_BATCH)
|
|
584
|
+
finally:
|
|
585
|
+
self._loading_more = False
|
|
586
|
+
self._rebuild_lineage()
|
|
587
|
+
self.refresh_bindings()
|
|
588
|
+
if parent_idx < len(self._session.versions):
|
|
589
|
+
self._activate(parent_idx)
|
|
590
|
+
|
|
591
|
+
def action_refresh_tree(self) -> None:
|
|
592
|
+
# Re-populate the active tree against the same reader. The reader is
|
|
593
|
+
# snapshot-based (already-downloaded index), so this is purely a state
|
|
594
|
+
# reset — useful after an accidental collapse-all.
|
|
595
|
+
self.query_one(f"#tree-{self._active}", VolumeFileTree).reload()
|