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.
Files changed (133) hide show
  1. {flyteplugins_union-0.4.0/src/flyteplugins_union.egg-info → flyteplugins_union-0.4.2}/PKG-INFO +1 -1
  2. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/licenses/BSL.txt +1 -1
  3. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/pyproject.toml +1 -0
  4. flyteplugins_union-0.4.2/src/flyteplugins/union/cli/_tui/__init__.py +36 -0
  5. flyteplugins_union-0.4.2/src/flyteplugins/union/cli/_tui/_volume_explore.py +595 -0
  6. flyteplugins_union-0.4.2/src/flyteplugins/union/cli/_volume_session.py +224 -0
  7. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/cli/cluster_pool.py +2 -0
  8. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/cli/queue.py +16 -1
  9. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/cli/volume.py +25 -60
  10. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/errors.py +12 -0
  11. flyteplugins_union-0.4.2/src/flyteplugins/union/internal/cluster/definition_pb2.py +102 -0
  12. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/cluster/definition_pb2.pyi +8 -4
  13. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/clusterpool/clusterpool_connect.py +0 -130
  14. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/clusterpool/clusterpool_pb2.py +2 -6
  15. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/clusterpool/payload_pb2.py +13 -23
  16. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/clusterpool/payload_pb2.pyi +11 -36
  17. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/queue/queue_pb2.py +36 -38
  18. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/queue/queue_pb2.pyi +4 -2
  19. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/io/_base_volume.py +50 -5
  20. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/io/_internal/_juicefs/_backend.py +55 -15
  21. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/io/_volume_transformer.py +11 -0
  22. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/remote/_cluster_pool.py +1 -35
  23. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/remote/_queue.py +15 -1
  24. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/remote/_volume_explore.py +10 -5
  25. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2/src/flyteplugins_union.egg-info}/PKG-INFO +1 -1
  26. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins_union.egg-info/SOURCES.txt +1 -0
  27. flyteplugins_union-0.4.0/src/flyteplugins/union/cli/_tui/__init__.py +0 -67
  28. flyteplugins_union-0.4.0/src/flyteplugins/union/cli/_tui/_volume_explore.py +0 -484
  29. flyteplugins_union-0.4.0/src/flyteplugins/union/internal/cluster/definition_pb2.py +0 -100
  30. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/LICENSE +0 -0
  31. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/README.md +0 -0
  32. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/README_PYPI.md +0 -0
  33. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/licenses/APL.txt +0 -0
  34. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/setup.cfg +0 -0
  35. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/__init__.py +0 -0
  36. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/cli/__init__.py +0 -0
  37. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/cli/_volume_index.py +0 -0
  38. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/cli/api_key.py +0 -0
  39. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/cli/assignment.py +0 -0
  40. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/cli/cluster.py +0 -0
  41. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/cli/member.py +0 -0
  42. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/cli/policy.py +0 -0
  43. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/cli/role.py +0 -0
  44. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/cli/user.py +0 -0
  45. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/__init__.py +0 -0
  46. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/authorizer/authorizer_connect.py +0 -0
  47. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/authorizer/authorizer_pb2.py +0 -0
  48. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/authorizer/authorizer_pb2.pyi +0 -0
  49. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/authorizer/definition_pb2.py +0 -0
  50. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/authorizer/definition_pb2.pyi +0 -0
  51. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/authorizer/payload_pb2.py +0 -0
  52. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/authorizer/payload_pb2.pyi +0 -0
  53. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/cluster/cluster_connect.py +0 -0
  54. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/cluster/cluster_pb2.py +0 -0
  55. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/cluster/cluster_pb2.pyi +0 -0
  56. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/cluster/payload_pb2.py +0 -0
  57. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/cluster/payload_pb2.pyi +0 -0
  58. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/clusterpool/clusterpool_pb2.pyi +0 -0
  59. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/common/authorization_pb2.py +0 -0
  60. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/common/authorization_pb2.pyi +0 -0
  61. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/common/cluster_pb2.py +0 -0
  62. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/common/cluster_pb2.pyi +0 -0
  63. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/common/deployment_pb2.py +0 -0
  64. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/common/deployment_pb2.pyi +0 -0
  65. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/common/identifier_pb2.py +0 -0
  66. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/common/identifier_pb2.pyi +0 -0
  67. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/common/identity_pb2.py +0 -0
  68. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/common/identity_pb2.pyi +0 -0
  69. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/common/list_pb2.py +0 -0
  70. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/common/list_pb2.pyi +0 -0
  71. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/common/policy_pb2.py +0 -0
  72. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/common/policy_pb2.pyi +0 -0
  73. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/common/role_pb2.py +0 -0
  74. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/common/role_pb2.pyi +0 -0
  75. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/app_definition_pb2.py +0 -0
  76. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/app_definition_pb2.pyi +0 -0
  77. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/app_payload_pb2.py +0 -0
  78. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/app_payload_pb2.pyi +0 -0
  79. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/app_service_connect.py +0 -0
  80. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/app_service_pb2.py +0 -0
  81. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/app_service_pb2.pyi +0 -0
  82. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/enums_pb2.py +0 -0
  83. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/enums_pb2.pyi +0 -0
  84. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/member_payload_pb2.py +0 -0
  85. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/member_payload_pb2.pyi +0 -0
  86. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/member_service_connect.py +0 -0
  87. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/member_service_pb2.py +0 -0
  88. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/member_service_pb2.pyi +0 -0
  89. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/policy_payload_pb2.py +0 -0
  90. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/policy_payload_pb2.pyi +0 -0
  91. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/policy_service_connect.py +0 -0
  92. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/policy_service_pb2.py +0 -0
  93. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/policy_service_pb2.pyi +0 -0
  94. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/role_payload_pb2.py +0 -0
  95. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/role_payload_pb2.pyi +0 -0
  96. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/role_service_connect.py +0 -0
  97. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/role_service_pb2.py +0 -0
  98. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/role_service_pb2.pyi +0 -0
  99. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/user_payload_pb2.py +0 -0
  100. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/user_payload_pb2.pyi +0 -0
  101. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/user_service_connect.py +0 -0
  102. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/user_service_pb2.py +0 -0
  103. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/identity/user_service_pb2.pyi +0 -0
  104. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/queue/queue_connect.py +0 -0
  105. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/validate/__init__.py +0 -0
  106. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/validate/validate/__init__.py +0 -0
  107. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/internal/validate/validate/validate_pb2.py +0 -0
  108. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/io/__init__.py +0 -0
  109. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/io/_internal/__init__.py +0 -0
  110. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/io/_internal/_juicefs/__init__.py +0 -0
  111. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/io/_internal/_juicefs/_common.py +0 -0
  112. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/io/_internal/_juicefs/_metadata.py +0 -0
  113. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/io/_internal/_juicefs/_redis.py +0 -0
  114. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/io/_internal/_juicefs/_sqlite.py +0 -0
  115. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/io/_internal/_juicefs/bin/.gitkeep +0 -0
  116. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/io/_internal/backend.py +0 -0
  117. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/io/_ro_volume.py +0 -0
  118. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/io/_rw_volume.py +0 -0
  119. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/remote/__init__.py +0 -0
  120. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/remote/_api_key.py +0 -0
  121. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/remote/_assignment.py +0 -0
  122. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/remote/_cluster.py +0 -0
  123. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/remote/_member.py +0 -0
  124. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/remote/_policy.py +0 -0
  125. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/remote/_role.py +0 -0
  126. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/remote/_user.py +0 -0
  127. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/utils/__init__.py +0 -0
  128. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/utils/auth.py +0 -0
  129. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins/union/utils/image.py +0 -0
  130. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins_union.egg-info/dependency_links.txt +0 -0
  131. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins_union.egg-info/entry_points.txt +0 -0
  132. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins_union.egg-info/requires.txt +0 -0
  133. {flyteplugins_union-0.4.0 → flyteplugins_union-0.4.2}/src/flyteplugins_union.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flyteplugins-union
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Union SDK - Proprietary extensions for Flyte
5
5
  Author-email: unionai <info@union.ai>
6
6
  Project-URL: Homepage, https://www.union.ai/
@@ -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-03
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()