diff --git a/.github/workflows/native-platform.yml b/.github/workflows/native-platform.yml index 7f882fbd..5fd846aa 100644 --- a/.github/workflows/native-platform.yml +++ b/.github/workflows/native-platform.yml @@ -519,6 +519,34 @@ jobs: rpm=$(find "$bundle/rpm" -type f -name '*.rpm' -print -quit) test -n "$deb" test -n "$rpm" + deb_dependencies=$(dpkg-deb -f "$deb" Depends) + rpm_dependencies=$(rpm -qp --requires "$rpm") + printf 'DEB dependencies: %s\n' "$deb_dependencies" + printf 'RPM dependencies:\n%s\n' "$rpm_dependencies" + for dependency in \ + libwebkit2gtk-4.1-0 libgtk-3-0 libc++1-18 libc++abi1-18 libunwind-18; do + case "$deb_dependencies" in + *"$dependency"*) ;; + *) + echo "DEB package is missing dependency: $dependency" >&2 + exit 1 + ;; + esac + done + for dependency in libcxx llvm-libunwind; do + if ! grep -Fx "$dependency" <<< "$rpm_dependencies"; then + echo "RPM package is missing dependency: $dependency" >&2 + exit 1 + fi + done + # rpmbuild derives shared-library requirements from ELF metadata. + # DNF resolves these SONAME capabilities to the Fedora provider packages. + for capability in libwebkit2gtk-4.1.so.0 libgtk-3.so.0; do + if ! grep -F "${capability}()" <<< "$rpm_dependencies"; then + echo "RPM package is missing runtime capability: ${capability}()" >&2 + exit 1 + fi + done dpkg-deb -x "$deb" "$scan/deb" rpm -Kv "$rpm" bsdtar -xf "$GITHUB_WORKSPACE/$rpm" -C "$scan/rpm" diff --git a/README.md b/README.md index 62515f09..cda5dc86 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,14 @@ an insufficient runtime from Microsoft; silent `/S` installations perform that prerequisite step without a prompt. The installer does not bundle a fixed WebView2 runtime and does not replace a newer installed version. +Linux packages use the system WebKitGTK runtime and LLVM 18 C++ runtime. The +RPM declares `webkit2gtk4.1`, `gtk3`, `libcxx`, and `llvm-libunwind`. The DEB +declares `libwebkit2gtk-4.1-0`, `libgtk-3-0`, `libc++1-18`, `libc++abi1-18`, +and `libunwind-18`. Debian or Ubuntu releases that do not provide the LLVM 18 +runtime packages in their standard repositories require an appropriate LLVM +package source before the DEB can be installed. An older generic `libc++1` +package is not a compatible substitute. + ## Command Line The CLI exposes the same image and object operations for scripts and batch diff --git a/apps/application/include/axklib/application/image_sessions.hpp b/apps/application/include/axklib/application/image_sessions.hpp index bfca4c67..93385e4f 100644 --- a/apps/application/include/axklib/application/image_sessions.hpp +++ b/apps/application/include/axklib/application/image_sessions.hpp @@ -103,12 +103,20 @@ struct ImageSessionRead { std::shared_ptr lease; }; +struct ImagePartitionCapacity { + std::uint32_t allocated_clusters{}; + std::uint32_t free_clusters{}; + std::uint32_t cluster_size_bytes{}; +}; + struct ImageContentItem { std::string id; std::optional parent_id; std::size_t depth{}; std::optional partition_index; std::optional volume_directory_id; + std::optional partition_capacity; + std::optional size_bytes; std::string kind; std::string name; std::string display_name; @@ -166,6 +174,7 @@ struct ImageObjectItem { std::string category_name; std::string entry_name; std::uint64_t stored_size_bytes{}; + std::optional size_with_dependencies_bytes; std::optional waveform; std::optional sequence; }; @@ -243,6 +252,7 @@ struct ImageObjectDeletionImpact { std::string volume_name; std::string role; std::string status; + bool requested{}; bool selected{}; std::uint64_t stored_size_bytes{}; std::uint64_t freed_clusters{}; @@ -267,6 +277,8 @@ struct ImageObjectDeletionInspection { std::string image_id; std::uint64_t revision{}; std::vector target_object_ids; + std::vector referrer_object_ids; + std::vector cleanup_object_ids; std::vector selected_object_ids; std::vector impacts; std::vector references; @@ -427,6 +439,7 @@ class ImageSessionManager { [[nodiscard]] Result plan_deletion(std::string_view image_id, std::string_view owner_id, std::uint64_t expected_revision, const std::vector &target_object_ids, + const std::vector &referrer_object_ids, const std::vector &cleanup_object_ids); [[nodiscard]] Result inspect_wave_data_orphans(std::string_view image_id, std::string_view owner_id, std::uint64_t expected_revision, diff --git a/apps/application/src/image_session_inspection.cpp b/apps/application/src/image_session_inspection.cpp index 5f08bf19..b3eec4bd 100644 --- a/apps/application/src/image_session_inspection.cpp +++ b/apps/application/src/image_session_inspection.cpp @@ -64,7 +64,8 @@ axk::app::Result axk::app::ImageSessionManager::i axk::app::Result axk::app::ImageSessionManager::plan_deletion( std::string_view image_id, std::string_view owner_id, std::uint64_t expected_revision, - const std::vector &target_object_ids, const std::vector &cleanup_object_ids) { + const std::vector &target_object_ids, const std::vector &referrer_object_ids, + const std::vector &cleanup_object_ids) { const auto session = implementation_->owned(image_id, owner_id); if (!session) return std::unexpected(session.error()); @@ -82,6 +83,15 @@ axk::app::Result axk::app::ImageSessionManage return std::unexpected(session_error("object_not_found", "deletion target does not exist")); target_keys.push_back(found->second.key); } + std::vector referrer_keys; + referrer_keys.reserve(referrer_object_ids.size()); + for (const auto &object_id : referrer_object_ids) { + const auto found = (*session)->snapshots_by_id.find(object_id); + if (found == (*session)->snapshots_by_id.end()) { + return std::unexpected(session_error("object_not_found", "deletion referrer object does not exist")); + } + referrer_keys.push_back(found->second.key); + } std::vector cleanup_keys; cleanup_keys.reserve(cleanup_object_ids.size()); for (const auto &object_id : cleanup_object_ids) { @@ -104,8 +114,10 @@ axk::app::Result axk::app::ImageSessionManage if (container == nullptr) return std::unexpected( session_error("image_mutation_unsupported", "object deletion requires an SFS container")); - const auto inspected = inspect_object_deletion( - *container, catalog, graph, {.target_keys = std::move(target_keys), .cleanup_keys = std::move(cleanup_keys)}); + const auto inspected = inspect_object_deletion(*container, catalog, graph, + {.target_keys = std::move(target_keys), + .referrer_keys = std::move(referrer_keys), + .cleanup_keys = std::move(cleanup_keys)}); if (!inspected) return std::unexpected(session_error("deletion_invalid", inspected.error().message)); @@ -133,6 +145,8 @@ axk::app::Result axk::app::ImageSessionManage result.image_id = std::string{image_id}; result.revision = expected_revision; result.target_object_ids = target_object_ids; + result.referrer_object_ids = referrer_object_ids; + result.cleanup_object_ids = cleanup_object_ids; result.selected_object_ids = map_keys(inspected->selected_keys); result.estimated_freed_bytes = inspected->estimated_freed_bytes; result.estimated_freed_clusters = inspected->estimated_freed_clusters; @@ -149,6 +163,7 @@ axk::app::Result axk::app::ImageSessionManage .volume_name = impact.volume_name, .role = std::string{object_deletion_role_name(impact.role)}, .status = std::string{object_deletion_status_name(impact.status)}, + .requested = impact.requested, .selected = impact.selected, .stored_size_bytes = impact.stored_size_bytes, .freed_clusters = impact.freed_clusters, diff --git a/apps/application/src/image_session_open.cpp b/apps/application/src/image_session_open.cpp index 03093bb2..4cc5c87c 100644 --- a/apps/application/src/image_session_open.cpp +++ b/apps/application/src/image_session_open.cpp @@ -5,6 +5,64 @@ #include #include +#include +#include +#include + +#include "axklib/package_closure.hpp" + +namespace { + +std::optional +exact_dependency_size(const axk::ObjectSnapshot &root, const axk::RelationshipGraph &graph, + const std::map> &objects, + const std::map> &sizes) { + if (root.object.header.type != axk::ObjectType::prog && root.object.header.type != axk::ObjectType::sbac && + root.object.header.type != axk::ObjectType::sbnk) { + return std::nullopt; + } + std::set> visited; + std::set> active; + std::uint64_t total{}; + const auto visit = [&](const auto &self, const axk::ObjectSnapshot &object) -> bool { + const auto has_exact_closure_payload = [&]() { + switch (object.object.header.type) { + case axk::ObjectType::prog: + return std::holds_alternative(object.object.payload); + case axk::ObjectType::sbac: + return std::holds_alternative(object.object.payload); + case axk::ObjectType::sbnk: + return std::holds_alternative(object.object.payload); + default: + return true; + } + }; + if (!has_exact_closure_payload()) + return false; + if (active.contains(object.key)) + return false; + if (!visited.emplace(object.key).second) + return true; + const auto size = sizes.find(object.key); + if (size == sizes.end() || size->second > std::numeric_limits::max() - total) + return false; + total += size->second; + active.emplace(object.key); + auto required = axk::package_internal::required_relationships(object, graph, objects); + if (!required) + return false; + for (const auto *relationship : *required) { + const auto target = relationship->target_key ? objects.find(*relationship->target_key) : objects.end(); + if (target == objects.end() || !self(self, *target->second)) + return false; + } + active.erase(object.key); + return true; + }; + return visit(visit, root) ? std::optional{total} : std::nullopt; +} + +} // namespace axk::app::Result axk::app::ImageSessionManager::open(const ImageSourceRef &source, std::string owner_id, @@ -163,10 +221,21 @@ axk::app::ImageSessionManager::open_with_companion_sources(const ImageSourceRef auto graph = axk::build_relationship_graph(inventory->catalog); auto tree = axk::build_content_tree(*media, inventory->catalog, graph); std::unordered_map partition_names; + std::unordered_map partition_capacities; if (const auto *sfs = std::get_if(&media->storage())) { partition_names.reserve(sfs->partitions().size()); - for (const auto &partition : sfs->partitions()) + partition_capacities.reserve(sfs->partitions().size()); + for (const auto &partition : sfs->partitions()) { partition_names.emplace(partition.index.value, partition.name); + if (partition.allocation.free_space) { + const auto &free_space = *partition.allocation.free_space; + partition_capacities.emplace( + partition.index.value, + ImagePartitionCapacity{.allocated_clusters = free_space.allocated_cluster_count, + .free_clusters = free_space.free_cluster_count, + .cluster_size_bytes = free_space.cluster_size_bytes}); + } + } } auto session = std::make_shared(); @@ -193,6 +262,12 @@ axk::app::ImageSessionManager::open_with_companion_sources(const ImageSourceRef } for (const auto &descriptor : inventory->objects) session->descriptors_by_id.emplace(object_ids.at(descriptor.key), descriptor); + std::map> objects_by_key; + std::map> sizes_by_key; + for (const auto &object : inventory->catalog.objects) + objects_by_key.emplace(object.key, &object); + for (const auto &descriptor : inventory->objects) + sizes_by_key.emplace(descriptor.key, descriptor.size); session->catalog_issues = inventory->catalog.issues; if (const auto *sfs = std::get_if(&media->storage())) { const auto orphan_report = axk::analyze_waveform_orphans(*sfs, inventory->catalog, graph); @@ -216,6 +291,7 @@ axk::app::ImageSessionManager::open_with_companion_sources(const ImageSourceRef item.name = object.object.header.name; item.format = object_format_name(object.object.format); item.stored_size_bytes = session->descriptors_by_id.at(item.id).size; + item.size_with_dependencies_bytes = exact_dependency_size(object, graph, objects_by_key, sizes_by_key); if (object.placement) { item.partition_index = object.partition.value; item.partition_name = object.placement->partition_name; @@ -315,11 +391,20 @@ axk::app::ImageSessionManager::open_with_companion_sources(const ImageSourceRef const auto converted = std::from_chars(first, last, result); return converted.ec == std::errc{} && converted.ptr == last ? std::optional{result} : std::nullopt; }(); + const auto partition_capacity = [&]() -> std::optional { + if (node.node_type != "partition" || !partition_index) + return std::nullopt; + if (const auto found = partition_capacities.find(*partition_index); found != partition_capacities.end()) + return found->second; + return std::nullopt; + }(); ImageContentItem item{.id = id, .parent_id = parent_id, .depth = depth, .partition_index = partition_index, .volume_directory_id = volume_directory_id, + .partition_capacity = partition_capacity, + .size_bytes = std::nullopt, .kind = node.node_type, .name = canonical_name, .display_name = node.display_name, @@ -355,6 +440,18 @@ axk::app::ImageSessionManager::open_with_companion_sources(const ImageSourceRef std::ranges::sort(scoped_indices); const auto unique_end = std::ranges::unique(scoped_indices).begin(); scoped_indices.erase(unique_end, scoped_indices.end()); + if (session->content[item_index].kind == "volume") { + std::uint64_t size{}; + const auto complete = std::ranges::all_of(scoped_indices, [&](std::size_t object_index) { + const auto object_size = session->objects[object_index].stored_size_bytes; + if (object_size > std::numeric_limits::max() - size) + return false; + size += object_size; + return true; + }); + if (complete) + session->content[item_index].size_bytes = size; + } if (session->content[item_index].kind == "volume" && !session->content[item_index].partition_index && !scoped_indices.empty()) { const auto inferred_partition_index = session->objects[scoped_indices.front()].partition_index; diff --git a/apps/application/src/session_placement_operations.cpp b/apps/application/src/session_placement_operations.cpp index 5622c37c..502aea01 100644 --- a/apps/application/src/session_placement_operations.cpp +++ b/apps/application/src/session_placement_operations.cpp @@ -1,5 +1,6 @@ #include "axklib/application/session_placement_operations.hpp" +#include #include #include #include @@ -8,7 +9,6 @@ #include #include #include -#include #include #include #include @@ -33,23 +33,49 @@ struct PlacementRequest { std::optional recovery_volume_name; }; +struct VolumeDeletionTarget { + std::uint8_t partition_index{}; + std::string volume_name; + + auto operator<=>(const VolumeDeletionTarget &) const = default; +}; + +struct VolumeDeletionRequest { + std::string image_id; + std::uint64_t revision{}; + std::vector targets; +}; + axk::app::Error operation_error(std::string code, std::string message) { return {std::move(code), std::move(message)}; } -axk::app::Result> -parse_volume_deletion_request(const Json &input) { +axk::app::Result parse_volume_deletion_request(const Json &input) { try { - const auto image_id = input.at("imageId").get(); - const auto revision = input.at("expectedRevision").get(); - const auto partition_value = input.at("partitionIndex").get(); - const auto volume_name = input.at("volumeName").get(); - if (image_id.empty() || revision == 0U || partition_value > 7U || volume_name.empty()) { + VolumeDeletionRequest result; + result.image_id = input.at("imageId").get(); + result.revision = input.at("expectedRevision").get(); + const auto &targets = input.at("targets"); + if (result.image_id.empty() || result.revision == 0U || !targets.is_array() || targets.empty() || + targets.size() > 1024U) { return std::unexpected(operation_error( - "invalid_request", "imageId, expectedRevision, partitionIndex, and volumeName are required")); + "invalid_request", "imageId, expectedRevision, and one or more volume targets are required")); } - return std::tuple{image_id, revision, static_cast(partition_value), volume_name}; + std::set unique_targets; + for (const auto &target : targets) { + const auto partition_value = target.at("partitionIndex").get(); + const auto volume_name = target.at("volumeName").get(); + if (partition_value > 7U || volume_name.empty() || volume_name.size() > 16U) { + return std::unexpected(operation_error("invalid_request", "volume deletion target is invalid")); + } + const VolumeDeletionTarget parsed{static_cast(partition_value), volume_name}; + if (!unique_targets.insert(parsed).second) { + return std::unexpected(operation_error("invalid_request", "volume deletion targets must be unique")); + } + result.targets.push_back(parsed); + } + return result; } catch (const Json::exception &) { return std::unexpected(operation_error( - "invalid_request", "imageId, expectedRevision, partitionIndex, and volumeName are required")); + "invalid_request", "imageId, expectedRevision, and one or more volume targets are required")); } } @@ -160,22 +186,28 @@ axk::app::Result> resolve_volume_closure(const axk::app::Im axk::app::Result inspect_volume_deletion(axk::app::ImageSessionManager &images, std::string_view owner_id, std::string_view image_id, std::uint64_t revision, - std::uint8_t partition_index, std::string_view volume_name) { + const std::vector &targets) { auto session = images.begin_read(image_id, owner_id, revision); if (!session) return std::unexpected(session.error()); - auto closure = resolve_volume_closure(*session, partition_index, volume_name); - if (!closure) - return std::unexpected(closure.error()); + std::set> closure; + Json target_json = Json::array(); + for (const auto &target : targets) { + auto volume_closure = resolve_volume_closure(*session, target.partition_index, target.volume_name); + if (!volume_closure) + return std::unexpected(volume_closure.error()); + for (const auto sfs_id : *volume_closure) + closure.emplace(target.partition_index, sfs_id); + target_json.push_back({{"partitionIndex", target.partition_index}, {"volumeName", target.volume_name}}); + } axk::ObjectCatalog catalog; catalog.issues = session->catalog_issues; - std::map object_ids; + std::map> object_ids; for (const auto *object : session->catalog_objects) { catalog.objects.push_back(*object); - if (object->partition.value == partition_index) - object_ids.emplace(object->key, object->sfs_id); + object_ids.emplace(object->key, std::pair{object->partition.value, object->sfs_id}); } const auto graph = axk::build_relationship_graph(catalog); const auto crossing_count = std::ranges::count_if(graph.relationships, [&](const axk::Relationship &relationship) { @@ -184,7 +216,7 @@ axk::app::Result inspect_volume_deletion(axk::app::ImageSessionManager &im const auto source = object_ids.find(relationship.source_key); const auto target = object_ids.find(*relationship.target_key); return source != object_ids.end() && target != object_ids.end() && - closure->contains(source->second) != closure->contains(target->second); + closure.contains(source->second) != closure.contains(target->second); }); Json blockers = Json::array(); if (crossing_count != 0U) { @@ -194,8 +226,7 @@ axk::app::Result inspect_volume_deletion(axk::app::ImageSessionManager &im } return Json{{"imageId", image_id}, {"revision", revision}, - {"partitionIndex", partition_index}, - {"volumeName", volume_name}, + {"targets", std::move(target_json)}, {"canDelete", crossing_count == 0U}, {"crossingRelationshipCount", crossing_count}, {"blockers", std::move(blockers)}}; @@ -277,9 +308,8 @@ axk::app::Result axk::app::bind_session_placement_operations(OperationRegi auto request = parse_volume_deletion_request(input); if (!request) return std::unexpected(request.error()); - const auto &[image_id, revision, partition, volume] = *request; - return inspect_volume_deletion(images, context.owner_id, image_id, revision, - partition, volume); + return inspect_volume_deletion(images, context.owner_id, request->image_id, + request->revision, request->targets); }); if (!bound) return bound; diff --git a/apps/application/src/session_write_operations.cpp b/apps/application/src/session_write_operations.cpp index 8e623533..3b669047 100644 --- a/apps/application/src/session_write_operations.cpp +++ b/apps/application/src/session_write_operations.cpp @@ -215,15 +215,16 @@ axk::app::Result axk::app::bind_session_write_operations(OperationRegistry const auto image_id = input.at("imageId").get(); const auto revision = input.at("expectedRevision").get(); const auto targets = input.at("targetObjectIds").get>(); + const auto referrers = input.at("referrerObjectIds").get>(); const auto cleanup = input.at("cleanupObjectIds").get>(); - auto plan = images.plan_deletion(image_id, context.owner_id, revision, targets, cleanup); + auto plan = images.plan_deletion(image_id, context.owner_id, revision, targets, referrers, cleanup); if (!plan) return std::unexpected(plan.error()); return deletion_inspection_json(plan->inspection); } catch (const Json::exception &) { - return std::unexpected(operation_error( - "invalid_request", - "imageId, expectedRevision, targetObjectIds, and cleanupObjectIds are required")); + return std::unexpected(operation_error("invalid_request", + "imageId, expectedRevision, targetObjectIds, " + "referrerObjectIds, and cleanupObjectIds are required")); } }); if (!bound) @@ -331,18 +332,20 @@ axk::app::Result axk::app::bind_session_write_operations(OperationRegistry std::string image_id; std::uint64_t revision{}; std::vector targets; + std::vector referrers; std::vector cleanup; try { image_id = input.at("imageId").get(); revision = input.at("expectedRevision").get(); targets = input.at("targetObjectIds").get>(); + referrers = input.at("referrerObjectIds").get>(); cleanup = input.at("cleanupObjectIds").get>(); } catch (const Json::exception &) { - return std::unexpected(operation_error( - "invalid_request", - "imageId, expectedRevision, targetObjectIds, and cleanupObjectIds are required")); + return std::unexpected(operation_error("invalid_request", + "imageId, expectedRevision, targetObjectIds, " + "referrerObjectIds, and cleanupObjectIds are required")); } - auto plan = images.plan_deletion(image_id, context.owner_id, revision, targets, cleanup); + auto plan = images.plan_deletion(image_id, context.owner_id, revision, targets, referrers, cleanup); if (!plan) return std::unexpected(plan.error()); if (!plan->inspection.can_apply) { diff --git a/apps/application/src/write_manifest_support.cpp b/apps/application/src/write_manifest_support.cpp index 02f10a1f..7cef08f7 100644 --- a/apps/application/src/write_manifest_support.cpp +++ b/apps/application/src/write_manifest_support.cpp @@ -161,6 +161,7 @@ Json deletion_inspection_json(const axk::app::ImageObjectDeletionInspection &ins {"volumeName", impact.volume_name}, {"role", impact.role}, {"status", impact.status}, + {"requested", impact.requested}, {"selected", impact.selected}, {"storedSizeBytes", impact.stored_size_bytes}, {"freedClusters", impact.freed_clusters}, @@ -191,6 +192,8 @@ Json deletion_inspection_json(const axk::app::ImageObjectDeletionInspection &ins {"imageId", inspection.image_id}, {"revision", inspection.revision}, {"targetObjectIds", inspection.target_object_ids}, + {"referrerObjectIds", inspection.referrer_object_ids}, + {"cleanupObjectIds", inspection.cleanup_object_ids}, {"selectedObjectIds", inspection.selected_object_ids}, {"impacts", std::move(impacts)}, {"references", std::move(references)}, diff --git a/apps/application/tests/image_sessions_test.cpp b/apps/application/tests/image_sessions_test.cpp index 2cb73b7e..cafb3cb9 100644 --- a/apps/application/tests/image_sessions_test.cpp +++ b/apps/application/tests/image_sessions_test.cpp @@ -226,6 +226,14 @@ TEST_F(ImageSessionTest, OpensMetadataOnlySessionAndNeverExposesEngineKeysOrPath ASSERT_FALSE(sfs->partitions().empty()); EXPECT_EQ(content->items.front().name, sfs->partitions().front().name); EXPECT_NE(content->items.front().display_name, content->items.front().name); + ASSERT_TRUE(sfs->partitions().front().allocation.free_space); + ASSERT_TRUE(content->items.front().partition_capacity); + EXPECT_EQ(content->items.front().partition_capacity->allocated_clusters, + sfs->partitions().front().allocation.free_space->allocated_cluster_count); + EXPECT_EQ(content->items.front().partition_capacity->free_clusters, + sfs->partitions().front().allocation.free_space->free_cluster_count); + EXPECT_EQ(content->items.front().partition_capacity->cluster_size_bytes, + sfs->partitions().front().allocation.free_space->cluster_size_bytes); for (const auto &item : content->items) { EXPECT_TRUE(item.id.starts_with("content-")); if (item.object_id) { @@ -665,11 +673,13 @@ TEST_F(ImageSessionTest, PlansOpaqueIdDeletionWithOptionalWaveDataCleanup) { const auto sample = std::ranges::find(samples->items, "sine wave", &axk::app::ImageObjectItem::name); ASSERT_NE(sample, samples->items.end()); - const auto inspected = sessions.plan_deletion(opened->image_id, "owner-a", opened->revision, {sample->id}, {}); + const auto inspected = sessions.plan_deletion(opened->image_id, "owner-a", opened->revision, {sample->id}, {}, {}); ASSERT_TRUE(inspected) << inspected.error().message; EXPECT_TRUE(inspected->inspection.can_apply); EXPECT_EQ(inspected->inspection.target_object_ids, std::vector{sample->id}); + EXPECT_TRUE(inspected->inspection.referrer_object_ids.empty()); + EXPECT_TRUE(inspected->inspection.cleanup_object_ids.empty()); ASSERT_EQ(inspected->manifest.operations.size(), 1U); EXPECT_TRUE(std::holds_alternative(inspected->manifest.operations.front().data)); const auto optional_wave = std::ranges::find(inspected->inspection.impacts, std::string{"SMPL"}, @@ -678,13 +688,13 @@ TEST_F(ImageSessionTest, PlansOpaqueIdDeletionWithOptionalWaveDataCleanup) { EXPECT_EQ(optional_wave->status, "OPTIONAL"); EXPECT_FALSE(optional_wave->selected); - const auto selected = - sessions.plan_deletion(opened->image_id, "owner-a", opened->revision, {sample->id}, {optional_wave->object_id}); + const auto selected = sessions.plan_deletion(opened->image_id, "owner-a", opened->revision, {sample->id}, {}, + {optional_wave->object_id}); ASSERT_TRUE(selected) << selected.error().message; ASSERT_EQ(selected->manifest.operations.size(), 2U); EXPECT_GT(selected->inspection.estimated_freed_bytes, 0U); - const auto stale = sessions.plan_deletion(opened->image_id, "owner-a", opened->revision + 1U, {sample->id}, {}); + const auto stale = sessions.plan_deletion(opened->image_id, "owner-a", opened->revision + 1U, {sample->id}, {}, {}); ASSERT_FALSE(stale); EXPECT_EQ(stale.error().code, "image_revision_stale"); } @@ -698,7 +708,7 @@ TEST_F(ImageSessionTest, DiscoversOnlyConfirmedUnreferencedWaveDataWithinTheRequ const auto sample = std::ranges::find(samples->items, "sine wave", &axk::app::ImageObjectItem::name); ASSERT_NE(sample, samples->items.end()); const auto deletion = - source_sessions.plan_deletion(source->image_id, "owner-a", source->revision, {sample->id}, {}); + source_sessions.plan_deletion(source->image_id, "owner-a", source->revision, {sample->id}, {}, {}); ASSERT_TRUE(deletion) << deletion.error().message; ASSERT_TRUE(deletion->inspection.can_apply); ASSERT_TRUE(source_sessions.close(source->image_id, "owner-a")); @@ -938,6 +948,31 @@ TEST_F(ImageSessionTest, ExcludesProgramReferencesFromContainingContentScopes) { ASSERT_TRUE(reference_scope) << reference_scope.error().message; ASSERT_EQ(reference_scope->items.size(), 1U); EXPECT_NE(reference_scope->items.front().type, "PROG"); + + const auto volume_objects = + sessions.objects(opened->image_id, "owner-a", 100U, std::nullopt, std::nullopt, volume->id); + ASSERT_TRUE(volume_objects) << volume_objects.error().message; + std::uint64_t stored_volume_size{}; + for (const auto &object : volume_objects->items) + stored_volume_size += object.stored_size_bytes; + ASSERT_TRUE(volume->size_bytes); + EXPECT_EQ(*volume->size_bytes, stored_volume_size); + + const auto dependency_size = [&](std::string_view type, std::string_view name) -> std::uint64_t { + const auto object = std::ranges::find_if(volume_objects->items, [&](const auto &candidate) { + return candidate.type == type && candidate.name == name; + }); + EXPECT_NE(object, volume_objects->items.end()); + if (object == volume_objects->items.end()) + return 0U; + EXPECT_TRUE(object->size_with_dependencies_bytes); + return object->size_with_dependencies_bytes.value_or(0U); + }; + EXPECT_EQ(program_scope->items.front().size_with_dependencies_bytes, volume->size_bytes); + EXPECT_GT(dependency_size("SBAC", "Bank"), dependency_size("SBNK", "Sample")); + EXPECT_GT(dependency_size("SBNK", "Sample"), + std::ranges::find(volume_objects->items, "Sample", &axk::app::ImageObjectItem::name)->stored_size_bytes); + EXPECT_GT(dependency_size("SBNK", "Direct Sample"), 0U); } TEST_F(ImageSessionTest, PlansProgramsForDisjointUnreferencedSampleBanksAndSamples) { diff --git a/apps/application/tests/write_operations_test.cpp b/apps/application/tests/write_operations_test.cpp index 68d0e92b..c3333d77 100644 --- a/apps/application/tests/write_operations_test.cpp +++ b/apps/application/tests/write_operations_test.cpp @@ -891,6 +891,64 @@ TEST_F(WriteOperationsTest, SessionAlterationCommitsInPlaceAndRefreshesTheExisti EXPECT_EQ(stale.error().code, "image_revision_stale"); } +TEST_F(WriteOperationsTest, SessionVolumeDeletionInspectionAcceptsMultipleVolumeTargets) { + write_tone(root_ / "batch-delete.wav"); + ASSERT_TRUE( + axk::write_hds_image(all_action_source_manifest(root_ / "batch-delete.wav"), root_ / "batch-delete.hds")); + const auto opened = images_->open({"workspace", "batch-delete.hds"}, "owner"); + ASSERT_TRUE(opened) << opened.error().message; + const nlohmann::json request = { + {"imageId", opened->image_id}, + {"expectedRevision", opened->revision}, + {"targets", nlohmann::json::array({{{"partitionIndex", 0U}, {"volumeName", "Volume"}}, + {{"partitionIndex", 0U}, {"volumeName", "Delete Volume"}}})}}; + + const auto inspected = registry_.invoke("images.volume_deletion.inspect", request, context()); + + ASSERT_TRUE(inspected) << inspected.error().message; + EXPECT_EQ(inspected->at("targets"), request.at("targets")); + EXPECT_TRUE(inspected->at("canDelete").get()); + EXPECT_EQ(inspected->at("crossingRelationshipCount"), 0U); + EXPECT_TRUE(inspected->at("blockers").empty()); +} + +TEST_F(WriteOperationsTest, SessionAlterationDeletesMultipleVolumesAtomically) { + axk::VolumeSpec first; + first.name = "First"; + axk::VolumeSpec second; + second.name = "Second"; + axk::VolumeSpec survivor; + survivor.name = "Survivor"; + const axk::HdsBuildManifest build{ + "1.0", 4U * 1024U * 1024U, {{"hd1", {std::move(first), std::move(second), std::move(survivor)}}}}; + ASSERT_TRUE(axk::write_hds_image(build, root_ / "batch-delete-session.hds")); + const auto opened = images_->open({"workspace", "batch-delete-session.hds"}, "owner"); + ASSERT_TRUE(opened) << opened.error().message; + const nlohmann::json manifest = { + {"schema_version", "1.0"}, + {"operations", + nlohmann::json::array( + {{{"id", "first"}, {"type", "delete_volume"}, {"partition_index", 0U}, {"volume_name", "First"}}, + {{"id", "second"}, {"type", "delete_volume"}, {"partition_index", 0U}, {"volume_name", "Second"}}})}, + }; + + const auto altered = registry_.invoke( + "images.alter", + {{"imageId", opened->image_id}, {"expectedRevision", opened->revision}, {"manifest", {{"inline", manifest}}}}, + context()); + + ASSERT_TRUE(altered) << altered.error().message; + EXPECT_EQ(altered->at("revision"), 2U); + const auto roots = images_->content(opened->image_id, "owner", 100U); + ASSERT_TRUE(roots) << roots.error().message; + ASSERT_EQ(roots->items.size(), 1U); + const auto volumes = images_->content(opened->image_id, "owner", 100U, std::nullopt, roots->items.front().id); + ASSERT_TRUE(volumes) << volumes.error().message; + EXPECT_TRUE(std::ranges::contains(volumes->items, "Survivor", &axk::app::ImageContentItem::name)); + EXPECT_FALSE(std::ranges::contains(volumes->items, "First", &axk::app::ImageContentItem::name)); + EXPECT_FALSE(std::ranges::contains(volumes->items, "Second", &axk::app::ImageContentItem::name)); +} + TEST_F(WriteOperationsTest, SessionVolumePlacementRepairMakesDeletionSafe) { write_tone(root_ / "repair.wav"); const auto source = root_ / "placement-repair.hds"; @@ -923,10 +981,10 @@ TEST_F(WriteOperationsTest, SessionVolumePlacementRepairMakesDeletionSafe) { const auto opened = images_->open({"workspace", "placement-repair.hds"}, "owner"); ASSERT_TRUE(opened) << opened.error().message; - const nlohmann::json request = {{"imageId", opened->image_id}, - {"expectedRevision", opened->revision}, - {"partitionIndex", 0U}, - {"volumeName", "Samples"}}; + const nlohmann::json request = { + {"imageId", opened->image_id}, + {"expectedRevision", opened->revision}, + {"targets", nlohmann::json::array({{{"partitionIndex", 0U}, {"volumeName", "Samples"}}})}}; const auto inspected = registry_.invoke("images.volume_deletion.inspect", request, context()); ASSERT_TRUE(inspected) << inspected.error().message; EXPECT_FALSE(inspected->at("canDelete").get()); @@ -934,11 +992,18 @@ TEST_F(WriteOperationsTest, SessionVolumePlacementRepairMakesDeletionSafe) { ASSERT_EQ(inspected->at("blockers").size(), 1U); auto absent_volume_request = request; - absent_volume_request["volumeName"] = "Absent"; + absent_volume_request["targets"][0]["volumeName"] = "Absent"; const auto absent_volume = registry_.invoke("images.volume_deletion.inspect", absent_volume_request, context()); ASSERT_FALSE(absent_volume); EXPECT_EQ(absent_volume.error().code, "volume_scope_invalid"); + auto duplicate_targets_request = request; + duplicate_targets_request["targets"].push_back(duplicate_targets_request["targets"].front()); + const auto duplicate_targets = + registry_.invoke("images.volume_deletion.inspect", duplicate_targets_request, context()); + ASSERT_FALSE(duplicate_targets); + EXPECT_EQ(duplicate_targets.error().code, "invalid_request"); + const nlohmann::json placement_request = { {"imageId", opened->image_id}, {"expectedRevision", opened->revision}, @@ -1070,6 +1135,7 @@ TEST_F(WriteOperationsTest, SessionObjectDeletionInspectsAndCommitsTheReviewedCl {{"imageId", opened->image_id}, {"expectedRevision", opened->revision}, {"targetObjectIds", nlohmann::json::array({sample->id})}, + {"referrerObjectIds", nlohmann::json::array()}, {"cleanupObjectIds", nlohmann::json::array()}}, context()); ASSERT_TRUE(inspection) << inspection.error().message; @@ -1091,6 +1157,7 @@ TEST_F(WriteOperationsTest, SessionObjectDeletionInspectsAndCommitsTheReviewedCl {{"imageId", opened->image_id}, {"expectedRevision", opened->revision}, {"targetObjectIds", nlohmann::json::array({sample->id})}, + {"referrerObjectIds", nlohmann::json::array()}, {"cleanupObjectIds", included}}, context()); ASSERT_TRUE(deleted) << deleted.error().message; diff --git a/apps/axkdeck/README.md b/apps/axkdeck/README.md index 80f1abe5..b3d23014 100644 --- a/apps/axkdeck/README.md +++ b/apps/axkdeck/README.md @@ -55,6 +55,10 @@ build directory. The same Git tag versions axklib and axkdeck. `desktop:dev` and `desktop:build` read `version_metadata.json` and `package_basename.txt` from the native build, inject that identity into Tauri, and embed it in the Rust binary. +Recognized bare, `release/`, `feature/`, `features/`, `bugfix/`, and `bugfixes/` +version branches accept `0.3.6` or `v0.3.6`, use semantic version `0.3.6-pre`, +and use source identity `0.3.6-pre-` or +`0.3.6-pre-dirty-`. The tracked npm and Cargo versions are `0.0.0` placeholders and are not release version sources. A locally launched desktop refuses a bundled sidecar whose semantic version or source identity differs, so rebuild the native server after @@ -341,6 +345,45 @@ and the normal deletion inspection immediately before starting the job; a changed or newly referenced candidate returns to review instead of being deleted. Discovery is capped at 1,024 candidates per pass. +### Settings + +Axkdeck stores its per-user settings under one vendor-scoped configuration +directory: + +- `%APPDATA%\tphzz\axkdeck` on Windows; +- `$XDG_CONFIG_HOME/tphzz/axkdeck` on Linux, or + `~/.config/tphzz/axkdeck` when `XDG_CONFIG_HOME` is unset; and +- `~/Library/Application Support/tphzz/axkdeck` on macOS. + +`settings.json` is the single versioned axkdeck settings document. It groups UI +appearance settings, including interface scale, separately from last-used +directories so future settings remain organized. The local sidecar's configured +storage roots remain operational server data and are stored separately at +`axklib-server/workspaces.json` below the same axkdeck directory. Axkdeck passes +that path explicitly when it starts the sidecar. + +```json +{ + "schemaVersion": 1, + "appearance": { + "interfaceScaleMode": "auto" + }, + "lastUsedDirectories": { + "packageExport": null, + "directoryExport": null, + "mediaExport": null, + "allocationExport": null + } +} +``` + +An independently launched `axklib-server` does not share the sidecar registry. +It uses its own `tphzz/axklib-server/workspaces.json` configuration path unless +`--workspace-store` or `workspaceStore` explicitly selects another file. The +earlier unreleased filenames and locations are not read or migrated. Logs, +WebView profile/cache data, protected credentials, and sidecar operational +state remain in their platform-specific application locations. + ### Diagnostics Development builds open the web developer tools with `F12`. This shortcut is diff --git a/apps/axkdeck/src-tauri/build_support.rs b/apps/axkdeck/src-tauri/build_support.rs index 359cca0b..68cece2c 100644 --- a/apps/axkdeck/src-tauri/build_support.rs +++ b/apps/axkdeck/src-tauri/build_support.rs @@ -73,14 +73,22 @@ pub fn read_build_identity( return Err("native project version does not match its numeric components".to_owned()); } if metadata.is_release { - if metadata.release_tag != format!("v{}", metadata.semantic_version) { + if metadata + .release_tag + .strip_prefix('v') + .unwrap_or(&metadata.release_tag) + != metadata.semantic_version + { return Err("native release tag does not match its semantic version".to_owned()); } - } else if metadata.semantic_version != "0.0.0" - || !metadata.release_tag.is_empty() - || metadata.is_prerelease - { - return Err("native development version metadata is inconsistent".to_owned()); + } else { + let fallback_development = metadata.semantic_version == "0.0.0" && !metadata.is_prerelease; + let version_branch = metadata.semantic_version + == format!("{}-pre", metadata.project_version) + && metadata.is_prerelease; + if !metadata.release_tag.is_empty() || (!fallback_development && !version_branch) { + return Err("native development version metadata is inconsistent".to_owned()); + } } let package_text = std::fs::read_to_string(package_basename_path).map_err(|error| { diff --git a/apps/axkdeck/src-tauri/resources/axkdeck.spdx.json b/apps/axkdeck/src-tauri/resources/axkdeck.spdx.json index b9c30db2..7cc071b8 100644 --- a/apps/axkdeck/src-tauri/resources/axkdeck.spdx.json +++ b/apps/axkdeck/src-tauri/resources/axkdeck.spdx.json @@ -3,7 +3,7 @@ "dataLicense": "CC0-1.0", "SPDXID": "SPDXRef-DOCUMENT", "name": "axkdeck-release", - "comment": "axklib source identity: feature-axkserver-finetuning-00ca159-mod", + "comment": "axklib source identity: feature-axkserver-finetuning-dirty-00ca159", "documentNamespace": "https://github.com/tphzz/axklib/spdx/b3d5ae714dfe90ddc5b0c0a1e171be8a8ab8fb9a9400cce5b91c6f2c0cf14102", "creationInfo": { "creators": [ @@ -21,7 +21,7 @@ "filesAnalyzed": false, "licenseConcluded": "NOASSERTION", "licenseDeclared": "MIT OR Apache-2.0", - "comment": "monorepo source identity: feature-axkserver-finetuning-00ca159-mod" + "comment": "monorepo source identity: feature-axkserver-finetuning-dirty-00ca159" }, { "SPDXID": "SPDXRef-axklib-axklib-0.0.0", diff --git a/apps/axkdeck/src-tauri/src/allocation_inspector.rs b/apps/axkdeck/src-tauri/src/allocation_inspector.rs index b1e26d87..918803b1 100644 --- a/apps/axkdeck/src-tauri/src/allocation_inspector.rs +++ b/apps/axkdeck/src-tauri/src/allocation_inspector.rs @@ -148,7 +148,7 @@ pub(crate) async fn save_allocation_map_json( let starting_directory = match preferences.lock() { Ok(preferences) => preferences.allocation_export_directory(), Err(_) => { - log::warn!("desktop preference state is unavailable; using the platform save location"); + log::warn!("axkdeck settings state is unavailable; using the platform save location"); None } }; @@ -186,7 +186,7 @@ pub(crate) async fn save_allocation_map_json( } Err(_) => { log::warn!( - "desktop preference state is unavailable; the allocation export directory was not retained" + "axkdeck settings state is unavailable; the allocation export directory was not retained" ); } } diff --git a/apps/axkdeck/src-tauri/src/desktop_preferences.rs b/apps/axkdeck/src-tauri/src/desktop_preferences.rs index 775b5c9d..24e194f3 100644 --- a/apps/axkdeck/src-tauri/src/desktop_preferences.rs +++ b/apps/axkdeck/src-tauri/src/desktop_preferences.rs @@ -5,13 +5,54 @@ use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; +const SETTINGS_SCHEMA_VERSION: u32 = 1; + +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub enum InterfaceScaleMode { + #[default] + #[serde(rename = "auto")] + Auto, + #[serde(rename = "1")] + One, + #[serde(rename = "1.15")] + OnePointFifteen, + #[serde(rename = "1.25")] + OnePointTwentyFive, + #[serde(rename = "1.5")] + OnePointFive, +} + #[derive(Default, Deserialize, Serialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] +struct AppearanceSettings { + interface_scale_mode: InterfaceScaleMode, +} + +#[derive(Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct LastUsedDirectories { + package_export: Option, + directory_export: Option, + media_export: Option, + allocation_export: Option, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] struct DesktopPreferences { - last_package_export_directory: Option, - last_directory_export_directory: Option, - last_media_export_directory: Option, - last_allocation_export_directory: Option, + schema_version: u32, + appearance: AppearanceSettings, + last_used_directories: LastUsedDirectories, +} + +impl Default for DesktopPreferences { + fn default() -> Self { + Self { + schema_version: SETTINGS_SCHEMA_VERSION, + appearance: AppearanceSettings::default(), + last_used_directories: LastUsedDirectories::default(), + } + } } pub struct DesktopPreferencesStore { @@ -21,14 +62,20 @@ pub struct DesktopPreferencesStore { impl DesktopPreferencesStore { pub fn load(document_path: PathBuf) -> Result { - let preferences = match std::fs::read(&document_path) { + let preferences: DesktopPreferences = match std::fs::read(&document_path) { Ok(bytes) => serde_json::from_slice(&bytes) - .map_err(|error| format!("parse desktop preferences: {error}"))?, + .map_err(|error| format!("parse axkdeck settings: {error}"))?, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { DesktopPreferences::default() } - Err(error) => return Err(format!("read desktop preferences: {error}")), + Err(error) => return Err(format!("read axkdeck settings: {error}")), }; + if preferences.schema_version != SETTINGS_SCHEMA_VERSION { + return Err(format!( + "unsupported axkdeck settings schema version: {}", + preferences.schema_version + )); + } Ok(Self { document_path, preferences, @@ -42,9 +89,19 @@ impl DesktopPreferencesStore { } } + pub fn interface_scale_mode(&self) -> InterfaceScaleMode { + self.preferences.appearance.interface_scale_mode + } + + pub fn set_interface_scale_mode(&mut self, mode: InterfaceScaleMode) -> Result<(), String> { + self.preferences.appearance.interface_scale_mode = mode; + self.persist() + } + pub fn package_export_directory(&self) -> Option { self.preferences - .last_package_export_directory + .last_used_directories + .package_export .as_ref() .filter(|directory| directory.is_dir()) .cloned() @@ -57,13 +114,14 @@ impl DesktopPreferencesStore { if !directory.is_dir() { return Err("the package export location is not a directory".to_owned()); } - self.preferences.last_package_export_directory = Some(directory); + self.preferences.last_used_directories.package_export = Some(directory); self.persist() } pub fn directory_export_directory(&self) -> Option { self.preferences - .last_directory_export_directory + .last_used_directories + .directory_export .as_ref() .filter(|directory| directory.is_dir()) .cloned() @@ -76,13 +134,14 @@ impl DesktopPreferencesStore { if !directory.is_dir() { return Err("the directory export location is not a directory".to_owned()); } - self.preferences.last_directory_export_directory = Some(directory); + self.preferences.last_used_directories.directory_export = Some(directory); self.persist() } pub fn media_export_directory(&self) -> Option { self.preferences - .last_media_export_directory + .last_used_directories + .media_export .as_ref() .filter(|directory| directory.is_dir()) .cloned() @@ -95,13 +154,14 @@ impl DesktopPreferencesStore { if !directory.is_dir() { return Err("the media export location is not a directory".to_owned()); } - self.preferences.last_media_export_directory = Some(directory); + self.preferences.last_used_directories.media_export = Some(directory); self.persist() } pub fn allocation_export_directory(&self) -> Option { self.preferences - .last_allocation_export_directory + .last_used_directories + .allocation_export .as_ref() .filter(|directory| directory.is_dir()) .cloned() @@ -114,13 +174,13 @@ impl DesktopPreferencesStore { if !directory.is_dir() { return Err("the allocation export location is not a directory".to_owned()); } - self.preferences.last_allocation_export_directory = Some(directory); + self.preferences.last_used_directories.allocation_export = Some(directory); self.persist() } fn persist(&self) -> Result<(), String> { let mut encoded = serde_json::to_vec_pretty(&self.preferences) - .map_err(|error| format!("encode desktop preferences: {error}"))?; + .map_err(|error| format!("encode axkdeck settings: {error}"))?; encoded.push(b'\n'); write_atomically(&self.document_path, &encoded) } @@ -130,11 +190,11 @@ fn temporary_sibling(destination: &Path) -> Result<(PathBuf, File), String> { let parent = destination .parent() .filter(|path| !path.as_os_str().is_empty()) - .ok_or_else(|| "desktop preferences path has no parent directory".to_owned())?; + .ok_or_else(|| "axkdeck settings path has no parent directory".to_owned())?; let name = destination .file_name() .and_then(|value| value.to_str()) - .unwrap_or("desktop-preferences"); + .unwrap_or("settings"); for attempt in 0_u8..16 { let nonce = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -147,27 +207,27 @@ fn temporary_sibling(destination: &Path) -> Result<(PathBuf, File), String> { match OpenOptions::new().create_new(true).write(true).open(&path) { Ok(file) => return Ok((path, file)), Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, - Err(error) => return Err(format!("create desktop preferences staging file: {error}")), + Err(error) => return Err(format!("create axkdeck settings staging file: {error}")), } } - Err("could not reserve a desktop preferences staging file".to_owned()) + Err("could not reserve an axkdeck settings staging file".to_owned()) } fn write_atomically(destination: &Path, bytes: &[u8]) -> Result<(), String> { let parent = destination .parent() .filter(|path| !path.as_os_str().is_empty()) - .ok_or_else(|| "desktop preferences path has no parent directory".to_owned())?; + .ok_or_else(|| "axkdeck settings path has no parent directory".to_owned())?; std::fs::create_dir_all(parent) - .map_err(|error| format!("create desktop preferences directory: {error}"))?; + .map_err(|error| format!("create axkdeck settings directory: {error}"))?; let (temporary, mut output) = temporary_sibling(destination)?; let result = (|| { output .write_all(bytes) - .map_err(|error| format!("write desktop preferences: {error}"))?; + .map_err(|error| format!("write axkdeck settings: {error}"))?; output .sync_all() - .map_err(|error| format!("flush desktop preferences: {error}"))?; + .map_err(|error| format!("flush axkdeck settings: {error}"))?; drop(output); let outcome = crate::file_publication::publish_file(&temporary, destination)?; if let Some(warning) = outcome.warning { @@ -187,7 +247,7 @@ mod tests { use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; - use super::DesktopPreferencesStore; + use super::{DesktopPreferencesStore, InterfaceScaleMode}; fn temporary_directory(name: &str) -> PathBuf { let nonce = SystemTime::now() @@ -207,7 +267,7 @@ mod tests { let root = temporary_directory("reload"); let export_directory = root.join("exports"); fs::create_dir(&export_directory).expect("create export directory"); - let document = root.join("desktop-preferences.json"); + let document = root.join("settings.json"); let mut store = DesktopPreferencesStore::load(document.clone()).expect("load empty preferences"); @@ -227,6 +287,93 @@ mod tests { fs::remove_dir_all(root).expect("remove temporary directory"); } + #[test] + fn interface_scale_mode_defaults_to_auto_and_survives_reload() { + let root = temporary_directory("interface-scale"); + let document = root.join("settings.json"); + let mut store = + DesktopPreferencesStore::load(document.clone()).expect("load empty preferences"); + + assert_eq!(store.interface_scale_mode(), InterfaceScaleMode::Auto); + store + .set_interface_scale_mode(InterfaceScaleMode::OnePointFifteen) + .expect("persist interface scale"); + + let reloaded = DesktopPreferencesStore::load(document).expect("reload preferences"); + assert_eq!( + reloaded.interface_scale_mode(), + InterfaceScaleMode::OnePointFifteen + ); + let persisted: serde_json::Value = serde_json::from_slice( + &fs::read(root.join("settings.json")).expect("read persisted settings"), + ) + .expect("parse persisted settings"); + assert_eq!(persisted["schemaVersion"], 1); + assert_eq!(persisted["appearance"]["interfaceScaleMode"], "1.15"); + assert!(persisted["lastUsedDirectories"]["packageExport"].is_null()); + assert!(persisted["lastUsedDirectories"]["directoryExport"].is_null()); + assert!(persisted["lastUsedDirectories"]["mediaExport"].is_null()); + assert!(persisted["lastUsedDirectories"]["allocationExport"].is_null()); + fs::remove_dir_all(root).expect("remove temporary directory"); + } + + #[test] + fn obsolete_unreleased_preferences_file_is_not_read() { + let root = temporary_directory("obsolete-preferences"); + fs::write( + root.join("desktop-preferences.json"), + br#"{"interfaceScaleMode":"1.5"}"#, + ) + .expect("write obsolete preferences"); + + let store = DesktopPreferencesStore::load(root.join("settings.json")) + .expect("load current settings"); + assert_eq!(store.interface_scale_mode(), InterfaceScaleMode::Auto); + fs::remove_dir_all(root).expect("remove temporary directory"); + } + + #[test] + fn unsupported_interface_scale_modes_are_rejected() { + let root = temporary_directory("invalid-interface-scale"); + let document = root.join("settings.json"); + fs::write( + &document, + br#"{"schemaVersion":1,"appearance":{"interfaceScaleMode":"2"},"lastUsedDirectories":{}}"#, + ) + .expect("write invalid preferences"); + + assert!(DesktopPreferencesStore::load(document).is_err()); + fs::remove_dir_all(root).expect("remove temporary directory"); + } + + #[test] + fn unsupported_settings_schema_versions_are_rejected() { + let root = temporary_directory("unsupported-schema"); + let document = root.join("settings.json"); + fs::write( + &document, + br#"{"schemaVersion":2,"appearance":{"interfaceScaleMode":"auto"},"lastUsedDirectories":{}}"#, + ) + .expect("write unsupported settings"); + + assert!(DesktopPreferencesStore::load(document).is_err()); + fs::remove_dir_all(root).expect("remove temporary directory"); + } + + #[test] + fn unknown_settings_fields_are_rejected() { + let root = temporary_directory("unknown-field"); + let document = root.join("settings.json"); + fs::write( + &document, + br#"{"schemaVersion":1,"appearance":{"interfaceScaleMode":"auto"},"lastUsedDirectories":{},"legacy":true}"#, + ) + .expect("write settings with unknown field"); + + assert!(DesktopPreferencesStore::load(document).is_err()); + fs::remove_dir_all(root).expect("remove temporary directory"); + } + #[test] fn directory_export_location_is_remembered_separately() { let root = temporary_directory("directory-reload"); @@ -234,7 +381,7 @@ mod tests { let directory_export = root.join("directory-exports"); fs::create_dir(&package_directory).expect("create package directory"); fs::create_dir(&directory_export).expect("create directory export location"); - let document = root.join("desktop-preferences.json"); + let document = root.join("settings.json"); let mut store = DesktopPreferencesStore::load(document.clone()).expect("load empty preferences"); @@ -272,7 +419,7 @@ mod tests { let allocation_export = root.join("allocation-exports"); fs::create_dir(&package_directory).expect("create package directory"); fs::create_dir(&allocation_export).expect("create allocation export location"); - let document = root.join("desktop-preferences.json"); + let document = root.join("settings.json"); let mut store = DesktopPreferencesStore::load(document.clone()).expect("load empty preferences"); @@ -302,7 +449,7 @@ mod tests { let second_directory = root.join("second"); fs::create_dir(&first_directory).expect("create first export directory"); fs::create_dir(&second_directory).expect("create second export directory"); - let document = root.join("desktop-preferences.json"); + let document = root.join("settings.json"); let mut store = DesktopPreferencesStore::load(document.clone()).expect("load empty preferences"); @@ -330,7 +477,7 @@ mod tests { let root = temporary_directory("missing"); let export_directory = root.join("exports"); fs::create_dir(&export_directory).expect("create export directory"); - let document = root.join("desktop-preferences.json"); + let document = root.join("settings.json"); let mut store = DesktopPreferencesStore::load(document.clone()).expect("load empty preferences"); @@ -349,7 +496,7 @@ mod tests { let root = temporary_directory("not-directory"); let export_directory = root.join("exports"); fs::create_dir(&export_directory).expect("create export directory"); - let document = root.join("desktop-preferences.json"); + let document = root.join("settings.json"); let mut store = DesktopPreferencesStore::load(document.clone()).expect("load empty preferences"); @@ -367,7 +514,7 @@ mod tests { #[test] fn malformed_preferences_are_rejected_for_startup_to_reset() { let root = temporary_directory("malformed"); - let document = root.join("desktop-preferences.json"); + let document = root.join("settings.json"); fs::write(&document, b"{not-json").expect("write malformed preferences"); assert!(DesktopPreferencesStore::load(document).is_err()); diff --git a/apps/axkdeck/src-tauri/src/lib.rs b/apps/axkdeck/src-tauri/src/lib.rs index f4c330ef..44891e82 100644 --- a/apps/axkdeck/src-tauri/src/lib.rs +++ b/apps/axkdeck/src-tauri/src/lib.rs @@ -7,6 +7,7 @@ mod local_workspaces; mod remote_settings; mod retained_download; mod server_sidecar; +mod settings_paths; mod startup_diagnostics; mod webview_runtime; @@ -18,7 +19,7 @@ use tauri::{Manager, State, WebviewWindow}; use tauri_plugin_log::{RotationStrategy, Target, TargetKind}; use allocation_inspector::{open_allocation_inspector, save_allocation_map_json}; -use desktop_preferences::DesktopPreferencesStore; +use desktop_preferences::{DesktopPreferencesStore, InterfaceScaleMode}; use local_directory_exports::{ DirectorySaveCandidateStore, save_retained_directory_export, select_local_directory_export_destination, @@ -29,6 +30,7 @@ use local_packages::{ select_local_packages, }; use local_workspaces::{WorkspaceCandidateStore, commit_local_workspace, select_local_workspace}; +use settings_paths::SettingsPaths; use startup_diagnostics::{StartupDiagnostics, StartupMilestone, complete_startup}; const LOG_FILE_SIZE: u128 = 5 * 1024 * 1024; @@ -60,6 +62,27 @@ fn current_build_info() -> DesktopBuildInfo { } } +#[tauri::command] +fn desktop_interface_scale_mode( + preferences: State<'_, Mutex>, +) -> InterfaceScaleMode { + preferences + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .interface_scale_mode() +} + +#[tauri::command] +fn set_desktop_interface_scale_mode( + mode: InterfaceScaleMode, + preferences: State<'_, Mutex>, +) -> Result<(), String> { + preferences + .lock() + .map_err(|_| "axkdeck settings state is unavailable".to_owned())? + .set_interface_scale_mode(mode) +} + fn parse_log_level(value: Option<&str>) -> log::LevelFilter { match value.map(str::trim).map(str::to_ascii_lowercase).as_deref() { Some("trace") => log::LevelFilter::Trace, @@ -441,12 +464,17 @@ pub fn run() { .path() .app_local_data_dir() .map_err(|error| format!("resolve application state directory: {error}"))?; + let config_directory = app + .path() + .config_dir() + .map_err(|error| format!("resolve application config directory: {error}"))?; + let settings_paths = SettingsPaths::from_config_directory(&config_directory); setup_startup.record(StartupMilestone::PathsResolved); - let preferences_path = application_data_directory.join("desktop-preferences.json"); + let settings_path = settings_paths.axkdeck_settings; setup_startup.record(StartupMilestone::PreferencesLoadStarted); - let preferences = DesktopPreferencesStore::load(preferences_path.clone()).unwrap_or_else(|error| { - log::warn!("desktop preferences are unavailable and will be reset on the next update: {error}"); - DesktopPreferencesStore::empty(preferences_path) + let preferences = DesktopPreferencesStore::load(settings_path.clone()).unwrap_or_else(|error| { + log::warn!("axkdeck settings are unavailable and will be reset on the next update: {error}"); + DesktopPreferencesStore::empty(settings_path) }); setup_startup.record(StartupMilestone::PreferencesLoadCompleted); app.manage(Mutex::new(preferences)); @@ -455,6 +483,7 @@ pub fn run() { connections.initialize_in_background( log_directory, state_directory, + settings_paths.sidecar_workspace_store, setup_startup.clone(), ); app.manage(connections); @@ -500,6 +529,8 @@ pub fn run() { open_developer_tools, diagnostic_log_level, desktop_build_info, + desktop_interface_scale_mode, + set_desktop_interface_scale_mode, open_allocation_inspector, save_allocation_map_json ]); diff --git a/apps/axkdeck/src-tauri/src/local_directory_exports.rs b/apps/axkdeck/src-tauri/src/local_directory_exports.rs index f212af8a..cdc5c88b 100644 --- a/apps/axkdeck/src-tauri/src/local_directory_exports.rs +++ b/apps/axkdeck/src-tauri/src/local_directory_exports.rs @@ -78,7 +78,7 @@ pub(crate) async fn select_local_directory_export_destination( let starting_directory = match preferences.lock() { Ok(preferences) => preferences.directory_export_directory(), Err(_) => { - log::warn!("desktop preference state is unavailable; using the platform save location"); + log::warn!("axkdeck settings state is unavailable; using the platform save location"); None } }; @@ -118,7 +118,7 @@ pub(crate) async fn select_local_directory_export_destination( } Err(_) => { log::warn!( - "desktop preference state is unavailable; the directory export location was not retained" + "axkdeck settings state is unavailable; the directory export location was not retained" ); } } diff --git a/apps/axkdeck/src-tauri/src/local_packages.rs b/apps/axkdeck/src-tauri/src/local_packages.rs index c0517943..8edbb6a2 100644 --- a/apps/axkdeck/src-tauri/src/local_packages.rs +++ b/apps/axkdeck/src-tauri/src/local_packages.rs @@ -230,7 +230,7 @@ pub(crate) async fn select_local_package_destination( let starting_directory = match preferences.lock() { Ok(preferences) => preferences.package_export_directory(), Err(_) => { - log::warn!("desktop preference state is unavailable; using the platform save location"); + log::warn!("axkdeck settings state is unavailable; using the platform save location"); None } }; @@ -270,7 +270,7 @@ pub(crate) async fn select_local_package_destination( } Err(_) => { log::warn!( - "desktop preference state is unavailable; the package export directory was not retained" + "axkdeck settings state is unavailable; the package export directory was not retained" ); } } @@ -306,7 +306,7 @@ pub(crate) async fn select_local_media_destination( let starting_directory = match preferences.lock() { Ok(preferences) => preferences.media_export_directory(), Err(_) => { - log::warn!("desktop preference state is unavailable; using the platform save location"); + log::warn!("axkdeck settings state is unavailable; using the platform save location"); None } }; @@ -346,7 +346,7 @@ pub(crate) async fn select_local_media_destination( } Err(_) => { log::warn!( - "desktop preference state is unavailable; the media export directory was not retained" + "axkdeck settings state is unavailable; the media export directory was not retained" ); } } diff --git a/apps/axkdeck/src-tauri/src/remote_settings.rs b/apps/axkdeck/src-tauri/src/remote_settings.rs index 0efcf9a0..c9fdd93d 100644 --- a/apps/axkdeck/src-tauri/src/remote_settings.rs +++ b/apps/axkdeck/src-tauri/src/remote_settings.rs @@ -42,6 +42,7 @@ pub struct ServerConnectionManager { secure_storage_error: Option, log_directory: PathBuf, state_directory: PathBuf, + workspace_store: PathBuf, } struct ServerConnectionStateInner { @@ -68,11 +69,13 @@ impl ServerConnectionState { &self, log_directory: PathBuf, state_directory: PathBuf, + workspace_store: PathBuf, startup: StartupDiagnostics, ) { let worker_state = self.clone(); let worker_log_directory = log_directory.clone(); let worker_state_directory = state_directory.clone(); + let worker_workspace_store = workspace_store.clone(); let worker = std::thread::Builder::new() .name("axkdeck-server-startup".to_owned()) .spawn(move || { @@ -80,6 +83,7 @@ impl ServerConnectionState { ServerConnectionManager::initialize( worker_log_directory.clone(), worker_state_directory.clone(), + worker_workspace_store.clone(), &startup, ) })) @@ -91,6 +95,7 @@ impl ServerConnectionState { error, worker_log_directory, worker_state_directory, + worker_workspace_store, ) }); worker_state.complete(manager); @@ -102,6 +107,7 @@ impl ServerConnectionState { message, log_directory, state_directory, + workspace_store, )); } } @@ -158,6 +164,7 @@ impl ServerConnectionManager { pub fn initialize( log_directory: PathBuf, state_directory: PathBuf, + workspace_store: PathBuf, startup: &StartupDiagnostics, ) -> Result { startup.record(StartupMilestone::CredentialLookupStarted); @@ -177,24 +184,45 @@ impl ServerConnectionManager { secure_storage_error: None, log_directory, state_directory, + workspace_store, }) } - Ok(None) => Self::local(log_directory, state_directory, None, Some(startup)), - Err(error) => Self::local(log_directory, state_directory, Some(error), Some(startup)), + Ok(None) => Self::local( + log_directory, + state_directory, + workspace_store, + None, + Some(startup), + ), + Err(error) => Self::local( + log_directory, + state_directory, + workspace_store, + Some(error), + Some(startup), + ), } } fn local( log_directory: PathBuf, state_directory: PathBuf, + workspace_store: PathBuf, secure_storage_error: Option, startup: Option<&StartupDiagnostics>, ) -> Result { let sidecar = match startup { - Some(startup) => { - ServerSidecar::launch_at_startup(&log_directory, &state_directory, startup)? - } - None => ServerSidecar::launch_if_available(&log_directory, &state_directory)?, + Some(startup) => ServerSidecar::launch_at_startup( + &log_directory, + &state_directory, + &workspace_store, + startup, + )?, + None => ServerSidecar::launch_if_available( + &log_directory, + &state_directory, + &workspace_store, + )?, }; let connection = sidecar.as_ref().map(|server| server.connection().clone()); Ok(Self { @@ -203,16 +231,23 @@ impl ServerConnectionManager { secure_storage_error, log_directory, state_directory, + workspace_store, }) } - pub fn unavailable(error: String, log_directory: PathBuf, state_directory: PathBuf) -> Self { + pub fn unavailable( + error: String, + log_directory: PathBuf, + state_directory: PathBuf, + workspace_store: PathBuf, + ) -> Self { Self { sidecar: None, connection: None, secure_storage_error: Some(error), log_directory, state_directory, + workspace_store, } } @@ -256,8 +291,11 @@ impl ServerConnectionManager { } pub fn use_local(&mut self) -> Result { - let sidecar = - ServerSidecar::launch_if_available(&self.log_directory, &self.state_directory)?; + let sidecar = ServerSidecar::launch_if_available( + &self.log_directory, + &self.state_directory, + &self.workspace_store, + )?; let connection = sidecar.as_ref().map(|server| server.connection().clone()); delete_remote_settings()?; self.connection = connection; @@ -444,6 +482,7 @@ mod tests { "sidecar unavailable".to_owned(), PathBuf::from("logs"), PathBuf::from("state"), + PathBuf::from("config/workspaces.json"), )); let settings = result_receiver .recv_timeout(Duration::from_secs(1)) diff --git a/apps/axkdeck/src-tauri/src/server_sidecar.rs b/apps/axkdeck/src-tauri/src/server_sidecar.rs index e479ebe0..b101160a 100644 --- a/apps/axkdeck/src-tauri/src/server_sidecar.rs +++ b/apps/axkdeck/src-tauri/src/server_sidecar.rs @@ -115,21 +115,29 @@ impl ServerSidecar { pub fn launch_if_available( log_directory: &Path, state_directory: &Path, + workspace_store: &Path, ) -> Result, String> { - Self::launch_if_available_inner(log_directory, state_directory, None) + Self::launch_if_available_inner(log_directory, state_directory, workspace_store, None) } pub fn launch_at_startup( log_directory: &Path, state_directory: &Path, + workspace_store: &Path, startup: &StartupDiagnostics, ) -> Result, String> { - Self::launch_if_available_inner(log_directory, state_directory, Some(startup)) + Self::launch_if_available_inner( + log_directory, + state_directory, + workspace_store, + Some(startup), + ) } fn launch_if_available_inner( log_directory: &Path, state_directory: &Path, + workspace_store: &Path, startup: Option<&StartupDiagnostics>, ) -> Result, String> { if let Some(startup) = startup { @@ -148,7 +156,13 @@ impl ServerSidecar { complete_sidecar_startup(startup, ServerOutcome::BinaryUnavailable); return Ok(None); }; - match Self::launch(&binary, log_directory, state_directory, startup) { + match Self::launch( + &binary, + log_directory, + state_directory, + workspace_store, + startup, + ) { Ok(sidecar) => { complete_sidecar_startup(startup, ServerOutcome::LocalReady); Ok(Some(sidecar)) @@ -164,13 +178,14 @@ impl ServerSidecar { binary: &Path, log_directory: &Path, state_directory: &Path, + workspace_store: &Path, startup: Option<&StartupDiagnostics>, ) -> Result { prepare_persistent_state_directory(state_directory)?; let runtime_directory = PrivateRuntimeDirectory::create(&std::env::temp_dir())?; record_startup(startup, StartupMilestone::SidecarStatePrepared); let connection_path = runtime_directory.connection_path(); - let arguments = sidecar_arguments(state_directory, &connection_path); + let arguments = sidecar_arguments(state_directory, workspace_store, &connection_path); let mut command = Command::new(binary); command .args(arguments) @@ -521,12 +536,18 @@ fn request_shutdown(connection: &FrontendConnection) -> Result<(), String> { Ok(()) } -fn sidecar_arguments(state_directory: &Path, connection_path: &Path) -> Vec { +fn sidecar_arguments( + state_directory: &Path, + workspace_store: &Path, + connection_path: &Path, +) -> Vec { let mut arguments = vec![ "--port".into(), "0".into(), "--state-directory".into(), state_directory.as_os_str().into(), + "--workspace-store".into(), + workspace_store.as_os_str().into(), "--connection-file".into(), connection_path.as_os_str().into(), "--parent-pid".into(), diff --git a/apps/axkdeck/src-tauri/src/server_sidecar_tests.rs b/apps/axkdeck/src-tauri/src/server_sidecar_tests.rs index 12323a4b..f4a85f1c 100644 --- a/apps/axkdeck/src-tauri/src/server_sidecar_tests.rs +++ b/apps/axkdeck/src-tauri/src/server_sidecar_tests.rs @@ -76,6 +76,7 @@ fn connection(pid: u32) -> ConnectionFile { fn sidecar_arguments_never_expose_the_bearer_token() { let arguments = sidecar_arguments( std::path::Path::new("state"), + std::path::Path::new("config/tphzz/axkdeck/axklib-server/workspaces.json"), std::path::Path::new("state/connection.json"), ); let rendered = arguments @@ -86,6 +87,9 @@ fn sidecar_arguments_never_expose_the_bearer_token() { assert!(!rendered.contains("--token")); assert!(!rendered.contains("--config")); assert!(rendered.contains("--connection-file")); + assert!( + rendered.contains("--workspace-store config/tphzz/axkdeck/axklib-server/workspaces.json") + ); assert!(rendered.contains("--port 0")); assert!(rendered.contains(&format!("--parent-pid {}", std::process::id()))); } @@ -94,6 +98,7 @@ fn sidecar_arguments_never_expose_the_bearer_token() { fn sidecar_arguments_allow_tauri_development_and_packaged_origins() { let arguments = sidecar_arguments( std::path::Path::new("state"), + std::path::Path::new("config/workspaces.json"), std::path::Path::new("state/connection.json"), ); let rendered = arguments diff --git a/apps/axkdeck/src-tauri/src/settings_paths.rs b/apps/axkdeck/src-tauri/src/settings_paths.rs new file mode 100644 index 00000000..5c21a082 --- /dev/null +++ b/apps/axkdeck/src-tauri/src/settings_paths.rs @@ -0,0 +1,42 @@ +use std::path::{Path, PathBuf}; + +const VENDOR_DIRECTORY: &str = "tphzz"; +const PRODUCT_DIRECTORY: &str = "axkdeck"; + +pub struct SettingsPaths { + pub axkdeck_settings: PathBuf, + pub sidecar_workspace_store: PathBuf, +} + +impl SettingsPaths { + pub fn from_config_directory(config_directory: &Path) -> Self { + let root = config_directory + .join(VENDOR_DIRECTORY) + .join(PRODUCT_DIRECTORY); + Self { + axkdeck_settings: root.join("settings.json"), + sidecar_workspace_store: root.join("axklib-server").join("workspaces.json"), + } + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::SettingsPaths; + + #[test] + fn settings_files_separate_axkdeck_from_its_sidecar_store() { + let paths = SettingsPaths::from_config_directory(Path::new("config")); + + assert_eq!( + paths.axkdeck_settings, + Path::new("config/tphzz/axkdeck/settings.json") + ); + assert_eq!( + paths.sidecar_workspace_store, + Path::new("config/tphzz/axkdeck/axklib-server/workspaces.json") + ); + } +} diff --git a/apps/axkdeck/src-tauri/tauri.linux.conf.json b/apps/axkdeck/src-tauri/tauri.linux.conf.json index a18f9b62..dca4de4c 100644 --- a/apps/axkdeck/src-tauri/tauri.linux.conf.json +++ b/apps/axkdeck/src-tauri/tauri.linux.conf.json @@ -1,5 +1,17 @@ { "bundle": { + "linux": { + "deb": { + "depends": [ + "libc++1-18", + "libc++abi1-18", + "libunwind-18" + ] + }, + "rpm": { + "depends": ["libcxx", "llvm-libunwind"] + } + }, "targets": ["deb", "rpm"] } } diff --git a/apps/axkdeck/src-tauri/tests/build_support_test.rs b/apps/axkdeck/src-tauri/tests/build_support_test.rs index d94457fe..15ae5d01 100644 --- a/apps/axkdeck/src-tauri/tests/build_support_test.rs +++ b/apps/axkdeck/src-tauri/tests/build_support_test.rs @@ -131,6 +131,50 @@ fn build_identity_accepts_a_windows_line_ending() { std::fs::remove_dir_all(directory).expect("remove test directory"); } +#[test] +fn build_identity_accepts_version_branch_prerelease_metadata() { + let directory = temporary_directory("version-branch"); + std::fs::create_dir_all(&directory).expect("create test directory"); + let metadata = directory.join("version_metadata.json"); + let package = directory.join("package_basename.txt"); + std::fs::write( + &metadata, + r#"{"schema_version":1,"semantic_version":"0.3.6-pre","project_version":"0.3.6","major":0,"minor":3,"patch":6,"release_tag":"","is_release":false,"is_prerelease":true}"#, + ) + .expect("write version metadata"); + std::fs::write(&package, "axklib-0.3.6-pre-a1b2c3d\n").expect("write package basename"); + + let identity = + build_support::read_build_identity(&metadata, &package).expect("read build identity"); + assert_eq!(identity.semantic_version, "0.3.6-pre"); + assert_eq!(identity.project_version, "0.3.6"); + assert_eq!(identity.source_identity, "0.3.6-pre-a1b2c3d"); + assert!(!identity.is_release); + + std::fs::remove_dir_all(directory).expect("remove test directory"); +} + +#[test] +fn build_identity_accepts_a_release_tag_without_v_prefix() { + let directory = temporary_directory("unprefixed-tag"); + std::fs::create_dir_all(&directory).expect("create test directory"); + let metadata = directory.join("version_metadata.json"); + let package = directory.join("package_basename.txt"); + std::fs::write( + &metadata, + r#"{"schema_version":1,"semantic_version":"1.2.3","project_version":"1.2.3","major":1,"minor":2,"patch":3,"release_tag":"1.2.3","is_release":true,"is_prerelease":false}"#, + ) + .expect("write version metadata"); + std::fs::write(&package, "axklib-1.2.3-a1b2c3d\n").expect("write package basename"); + + let identity = + build_support::read_build_identity(&metadata, &package).expect("read build identity"); + assert_eq!(identity.release_tag, "1.2.3"); + assert!(identity.is_release); + + std::fs::remove_dir_all(directory).expect("remove test directory"); +} + #[test] fn inconsistent_development_identity_is_rejected() { let directory = temporary_directory("invalid"); diff --git a/apps/axkdeck/src/AllocationInspector.svelte b/apps/axkdeck/src/AllocationInspector.svelte index ffa0c161..26d94a33 100644 --- a/apps/axkdeck/src/AllocationInspector.svelte +++ b/apps/axkdeck/src/AllocationInspector.svelte @@ -8,6 +8,7 @@ allocationExportFilename, allocationSpaceStatistic, formatAllocationBytes, + resolveAllocationServerConnection, saveAllocationMap, } from './lib/allocationInspector'; @@ -72,12 +73,12 @@ error = 'This allocation inspector link is incomplete.'; return; } - const connection = window.__AXKLIB_SERVER__; - if (!connection) { - error = 'No axklib-server connection is available.'; - return; - } try { + const connection = await resolveAllocationServerConnection(); + if (!connection) { + error = 'No axklib-server connection is available.'; + return; + } const client = new AxklibHttpApiClient(connection); const parameters = new URLSearchParams({ partitionIndex: String(partitionIndex), diff --git a/apps/axkdeck/src/App.svelte b/apps/axkdeck/src/App.svelte index 8bd9db0e..ffaefe45 100644 --- a/apps/axkdeck/src/App.svelte +++ b/apps/axkdeck/src/App.svelte @@ -22,6 +22,7 @@ import { SequenceImportWorkflow } from './features/import/sequenceWorkflow.svelte'; import { Tx16wImportWorkflow } from './features/import/tx16wWorkflow.svelte'; import { ImageSessionWorkflow } from './features/image-session/workflow.svelte'; + import { createImageTreeActionHandler } from './features/image-session/treeActions'; import { ExtentLayoutRepairWorkflow } from './features/image-session/extentLayoutRepairWorkflow.svelte'; import { JobController } from './features/jobs/actions'; import { MutationWorkflow } from './features/mutation/workflow.svelte'; @@ -30,8 +31,8 @@ import { workspaceTabs } from './features/workspace/tabs'; import ExperimentalWarningDialog from './lib/components/ExperimentalWarningDialog.svelte'; import ImageIntegrityDialog from './lib/components/ImageIntegrityDialog.svelte'; + import WorkspaceGuard from './lib/components/WorkspaceGuard.svelte'; import { createTransport } from './lib/createTransport'; - import { openAllocationInspector } from './lib/allocationInspector'; import type { RemoteServerSettingsInput, RemoteServerSettingsView } from './lib/serverSettings'; import { reportMutationTiming } from './lib/diagnostics'; import { @@ -40,14 +41,7 @@ type PackageExportSelectionState, } from './lib/objectSelection'; import { userFacingMessage } from './lib/userFacingMessage'; - import type { - DiskTreeItem, - InspectorSelection, - PackageExportObject, - PackageExportSelection, - ImageTreeAction, - WorkspaceView, - } from './lib/types'; + import type { InspectorSelection, PackageExportObject, PackageExportSelection, WorkspaceView } from './lib/types'; let { interfaceScaling = null, @@ -163,7 +157,7 @@ setStatus: (status) => imageSessionWorkflow.setStatus(status), pickerHistory: packagePickerHistory, mutationsAvailable: () => imageSessionWorkflow.packageImportAvailable, - selectedSource: () => imageSessionWorkflow.selectedSource, + selectedSource: () => imageSessionWorkflow.importDestinationSource(), sourceItems: () => imageSessionWorkflow.sourceItems, }); const packageBatchImportWorkflow = new PackageBatchImportWorkflow({ @@ -219,7 +213,7 @@ imageFormat: () => imageSessionWorkflow.imageFormat, mutationsAvailable: () => mutationWorkflow.volumeAvailable, selectedSource: () => imageSessionWorkflow.selectedSource, - setSelectedSource: (item) => (imageSessionWorkflow.selectedSource = item), + setSelectedSource: (item) => imageSessionWorkflow.selectSource(item), sourceItems: () => imageSessionWorkflow.sourceItems, activeVolumeId: () => catalog.activeVolumeId, sampleBanks: () => catalog.sampleBanks, @@ -241,7 +235,7 @@ imageLocation: () => imageSessionWorkflow.location, mutationsAvailable: () => mutationWorkflow.objectRenameAvailable, selectedSource: () => imageSessionWorkflow.selectedSource, - setSelectedSource: (item) => (imageSessionWorkflow.selectedSource = item), + setSelectedSource: (item) => imageSessionWorkflow.selectSource(item), sourceItems: () => imageSessionWorkflow.sourceItems, activeVolumeId: () => catalog.activeVolumeId, sequences: () => catalog.sequences, @@ -284,9 +278,22 @@ sessionId: () => imageSessionWorkflow.sessionId, imageFormat: () => imageSessionWorkflow.imageFormat, mutationsAvailable: () => mutationWorkflow.volumeAvailable, - selectedSource: () => imageSessionWorkflow.selectedSource, + selectedSource: () => imageSessionWorkflow.importDestinationSource(), setStatus: (status) => imageSessionWorkflow.setStatus(status), }); + const requestImageAction = createImageTreeActionHandler({ + transport, + imageSession: imageSessionWorkflow, + mutation: mutationWorkflow, + directComputer: directComputerWorkflow, + packageBatchImport: packageBatchImportWorkflow, + exports: exportWorkflow, + volumePackages: volumePackageExportWorkflow, + volumeFloppies: volumeFloppyExportWorkflow, + mediaExports: mediaExportWorkflow, + isDesktop, + exportAudio: requestAudioExport, + }); imageSessionWorkflow.connect({ catalog, audition: auditionWorkflow, @@ -333,6 +340,7 @@ }); const selectedProgram = $derived(programs.find((item) => item.objectId === catalog.selectedProgramId)); + const activeWorkspaceId = $derived(imageSessionWorkflow.location?.reference.rootId ?? null); const selectedBank = $derived(sampleBanks.find((item) => item.objectId === catalog.selectedBankId)); const selectedSample = $derived(samples.find((item) => item.objectId === catalog.selectedSampleId)); const auditionableSampleObjectIds = $derived(auditionWorkflow.auditionableSampleObjectIds); @@ -341,7 +349,6 @@ hasOpenAppDialog({ pickerRequest, imageSession: imageSessionWorkflow, - workspaceManagerOpen, connectionSettings, mutation: mutationWorkflow, packageImport: packageImportWorkflow, @@ -408,92 +415,6 @@ } }); - function requestImageAction(item: DiskTreeItem, action: ImageTreeAction): void { - if (item.partitionIndex === undefined) return; - if (action === 'inspect-allocation') { - const sessionId = imageSessionWorkflow.sessionId; - if ( - !isDesktop || - sessionId === null || - !imageSessionWorkflow.allocationInspectionAvailable || - item.kind !== 'partition' - ) - return; - imageSessionWorkflow.selectedSource = item; - void transport - .allocationMapReference(sessionId) - .then((reference) => - openAllocationInspector({ - ...reference, - partitionIndex: item.partitionIndex!, - partitionName: item.name, - }), - ) - .catch((error) => imageSessionWorkflow.setStatus(userFacingMessage(error))); - return; - } - if (action === 'import-package') { - if (!imageSessionWorkflow.packageImportAvailable || item.kind !== 'volume') return; - imageSessionWorkflow.selectedSource = item; - directComputerWorkflow.importPackage(packageImportWorkflow, item); - return; - } - if (action === 'import-packages') { - if (!imageSessionWorkflow.packageImportAvailable || item.kind !== 'partition') return; - imageSessionWorkflow.selectedSource = item; - directComputerWorkflow.importVolumePackages(packageBatchImportWorkflow, item); - return; - } - if (action === 'export-package') { - if (!imageSessionWorkflow.packageExportAvailable || item.kind !== 'volume') return; - imageSessionWorkflow.selectedSource = item; - directComputerWorkflow.exportPackage(exportWorkflow, [ - { - kind: 'VOLUME', - contentId: item.id, - partitionIndex: item.partitionIndex!, - volumeName: item.name, - name: item.name, - typeLabel: 'Volume', - }, - ]); - return; - } - if (action === 'export-volume-packages') { - if (!imageSessionWorkflow.volumePackageExportAvailable || item.kind !== 'partition') return; - imageSessionWorkflow.selectedSource = item; - void directComputerWorkflow.exportVolumePackages(volumePackageExportWorkflow, item); - return; - } - if (action === 'export-volume-floppies') { - if (!imageSessionWorkflow.volumeFloppyExportAvailable || item.kind !== 'partition') return; - imageSessionWorkflow.selectedSource = item; - void directComputerWorkflow.exportVolumeFloppies(volumeFloppyExportWorkflow, item); - return; - } - if (action === 'export-sfz') { - if (!imageSessionWorkflow.audioExportAvailable || item.kind !== 'volume') return; - imageSessionWorkflow.selectedSource = item; - void requestAudioExport([ - { - kind: 'VOLUME', - contentId: item.id, - partitionIndex: item.partitionIndex, - volumeName: item.name, - name: item.name, - typeLabel: 'Volume', - }, - ]); - return; - } - if (action === 'export-cdrom' || action === 'export-floppy') { - if (!imageSessionWorkflow.mediaConversionAvailable) return; - imageSessionWorkflow.selectedSource = item; - void directComputerWorkflow.exportMedia(mediaExportWorkflow, item); - return; - } - if (mutationWorkflow.requestVolumeAction(item, action)) imageSessionWorkflow.selectedSource = item; - } function requestObjectPackageExport(items: PackageExportObject[]): void { if ( !imageSessionWorkflow.packageExportAvailable || @@ -546,7 +467,6 @@ async function saveRemoteConnection(input: RemoteServerSettingsInput): Promise { await connectionActions.saveRemote(input); } - async function switchToLocalConnection(): Promise { await connectionActions.useLocal(); } @@ -580,6 +500,7 @@ imageLocation={imageSessionWorkflow.location} sourceItems={imageSessionWorkflow.sourceItems} selectedSource={imageSessionWorkflow.selectedSource} + selectedVolumeIds={imageSessionWorkflow.volumeSelection.items.map((item) => item.id)} imageOpening={imageSessionWorkflow.opening} sessionId={imageSessionWorkflow.sessionId} {catalog} @@ -613,13 +534,15 @@ sequenceExportAvailable={imageSessionWorkflow.sequenceExportAvailable} mediaConversionAvailable={imageSessionWorkflow.mediaConversionAvailable} allocationInspectionAvailable={imageSessionWorkflow.allocationInspectionAvailable} + samplerOrderingEnabled={imageSessionWorkflow.imageFormat === 'sfs'} openConnectionSettings={() => void openConnectionSettings()} openImage={() => void imageSessionWorkflow.chooseAndOpen()} createImage={() => void imageSessionWorkflow.chooseHardDiskDirectory()} closeImage={() => void imageSessionWorkflow.close().catch(() => undefined)} showImageIntegrity={() => void imageSessionWorkflow.showIntegrity()} manageLocations={() => (workspaceManagerOpen = true)} - selectSource={(item) => imageSessionWorkflow.selectSource(item)} + selectSource={(item, mode, visibleVolumes) => imageSessionWorkflow.selectTreeSource(item, mode, visibleVolumes)} + selectSourceForContext={(item, visibleVolumes) => imageSessionWorkflow.selectSourceForContext(item, visibleVolumes)} imageAction={requestImageAction} selectWorkspace={(view) => auditionWorkflow.selectWorkspaceView(view)} exportPackage={requestObjectPackageExport} @@ -637,7 +560,7 @@ (experimentalWarningAcknowledged = true)} /> {/if} -{#if imageSessionWorkflow.integrityDialogOpen} +{#if !experimentalWarningOpen && imageSessionWorkflow.integrityDialogOpen} {/if} -{#if appDialogsOpen} + + +{#if !experimentalWarningOpen && appDialogsOpen} {#await import('./features/dialogs/AppDialogs.svelte') then dialogs} {@const AppDialogs = dialogs.default} imageSessionWorkflow.finishHardDiskCreation(file)} cancelHardDisk={() => imageSessionWorkflow.cancelHardDiskCreation()} - {workspaceManagerOpen} - activeWorkspaceId={imageSessionWorkflow.location?.reference.rootId ?? null} - closeWorkspaceManager={() => (workspaceManagerOpen = false)} {connectionSettings} {saveRemoteConnection} {switchToLocalConnection} diff --git a/apps/axkdeck/src/App.test.ts b/apps/axkdeck/src/App.test.ts index 59308057..536b3355 100644 --- a/apps/axkdeck/src/App.test.ts +++ b/apps/axkdeck/src/App.test.ts @@ -230,6 +230,68 @@ describe('App panel layout', () => { expect(screen.getByRole('dialog', { name: 'Experimental software' })).toBeTruthy(); }); + it('opens storage setup when the local sidecar has no configured workspace', async () => { + window.__AXKLIB_SERVER__ = { + baseUrl: 'http://127.0.0.1:7331/api/v1', + bearerToken: 'test-token', + mode: 'local', + }; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + data: { + state: 'NO_AVAILABLE_WORKSPACE', + revision: 0, + workspaces: [], + configurationIssue: null, + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ); + const rendered = render(App, { props: { initialExperimentalWarningOpen: false } }); + + expect(await screen.findByRole('dialog', { name: 'Storage locations' })).toBeTruthy(); + expect(screen.getByText('Choose a directory before opening or creating sampler images.')).toBeTruthy(); + + rendered.unmount(); + fetchSpy.mockRestore(); + }); + + it('defers storage setup until the experimental warning is acknowledged', async () => { + window.__AXKLIB_SERVER__ = { + baseUrl: 'http://127.0.0.1:7331/api/v1', + bearerToken: 'test-token', + mode: 'local', + }; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + data: { + state: 'NO_AVAILABLE_WORKSPACE', + revision: 0, + workspaces: [], + configurationIssue: null, + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ); + const rendered = render(App); + + await vi.dynamicImportSettled(); + expect(screen.getByRole('dialog', { name: 'Experimental software' })).toBeTruthy(); + expect(screen.queryByRole('dialog', { name: 'Storage locations' })).toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + + await fireEvent.click(screen.getByRole('button', { name: 'I understand' })); + expect(await screen.findByRole('dialog', { name: 'Storage locations' })).toBeTruthy(); + expect(fetchSpy).toHaveBeenCalledOnce(); + + rendered.unmount(); + fetchSpy.mockRestore(); + }); + it('opens About from the brand and reuses the authoritative build information', async () => { renderAcknowledgedApp(); const brand = screen.getByRole('button', { name: 'About axkdeck' }); @@ -239,7 +301,8 @@ describe('App panel layout', () => { await fireEvent.click(brand); const firstDialog = await screen.findByRole('dialog', { name: 'About axkdeck' }); expect(within(firstDialog).getByText('0.4.0')).toBeTruthy(); - expect(within(firstDialog).getByText('v0.4.0-1234567')).toBeTruthy(); + expect(within(firstDialog).queryByText('v0.4.0-1234567')).toBeNull(); + expect(within(firstDialog).queryByText('Build')).toBeNull(); expect(mocks.desktopBuildInfo).toHaveBeenCalledOnce(); await fireEvent.click(within(firstDialog).getByRole('button', { name: 'Close' })); @@ -861,6 +924,8 @@ describe('App panel layout', () => { imageId: 'image-1', revision: 1, targetObjectIds: [sample.key], + referrerObjectIds: [], + cleanupObjectIds: [], selectedObjectIds: [sample.key], impacts: [ { @@ -872,6 +937,7 @@ describe('App panel layout', () => { volumeName: 'Piano', role: 'TARGET', status: 'REQUIRED', + requested: true, selected: true, storedSizeBytes: 512, freedClusters: 1, @@ -887,6 +953,7 @@ describe('App panel layout', () => { volumeName: 'Piano', role: 'DEPENDENCY', status: 'OPTIONAL', + requested: false, selected: false, storedSizeBytes: 4096, freedClusters: 4, @@ -902,9 +969,10 @@ describe('App panel layout', () => { }; const selectedInspection = { ...inspection, + cleanupObjectIds: ['wave-1'], selectedObjectIds: [sample.key, 'wave-1'], impacts: inspection.impacts.map((impact) => - impact.objectId === 'wave-1' ? { ...impact, selected: true } : impact, + impact.objectId === 'wave-1' ? { ...impact, requested: true, selected: true } : impact, ), estimatedFreedBytes: 5120, estimatedFreedClusters: 5, @@ -938,12 +1006,14 @@ describe('App panel layout', () => { await fireEvent.click(screen.getByRole('menuitem', { name: 'Delete' })); await vi.waitFor(() => expect(screen.getByRole('dialog', { name: 'Delete Sample' })).toBeTruthy()); await fireEvent.click(screen.getByRole('checkbox', { name: 'Also delete all (1)' })); - await vi.waitFor(() => expect(mocks.inspectObjectDeletion).toHaveBeenCalledWith(17, [sample.key], ['wave-1'])); + await vi.waitFor(() => + expect(mocks.inspectObjectDeletion).toHaveBeenCalledWith(17, [sample.key], [], ['wave-1']), + ); await fireEvent.click(screen.getByRole('button', { name: 'Delete 2 objects' })); await vi.waitFor(() => expect(mocks.startObjectDeletion).toHaveBeenCalledOnce()); expect(mocks.inspectObjectDeletion).toHaveBeenCalledTimes(3); - expect(mocks.startObjectDeletion).toHaveBeenCalledWith(17, [sample.key], ['wave-1']); + expect(mocks.startObjectDeletion).toHaveBeenCalledWith(17, [sample.key], [], ['wave-1']); await vi.waitFor(() => expect(mocks.waitForJob).toHaveBeenCalledWith(55, expect.any(Function))); await vi.waitFor(() => expect(mocks.refreshImage).toHaveBeenCalledWith(17)); expect(screen.getByRole('dialog', { name: 'Delete Sample' })).toBeTruthy(); @@ -1020,6 +1090,8 @@ describe('App panel layout', () => { imageId: 'image-1', revision: 1, targetObjectIds: ['wave-unused-a'], + referrerObjectIds: [], + cleanupObjectIds: [], selectedObjectIds: ['wave-unused-a'], impacts: [ { @@ -1031,6 +1103,7 @@ describe('App panel layout', () => { volumeName: volume.name, role: 'TARGET', status: 'REQUIRED', + requested: true, selected: true, storedSizeBytes: 4096, freedClusters: 4, @@ -1071,9 +1144,9 @@ describe('App panel layout', () => { await vi.waitFor(() => expect(mocks.inspectWaveDataOrphans).toHaveBeenCalledTimes(2)); expect(mocks.inspectWaveDataOrphans).toHaveBeenNthCalledWith(1, 17, volume.id); expect(mocks.inspectWaveDataOrphans).toHaveBeenNthCalledWith(2, 17, volume.id); - expect(mocks.inspectObjectDeletion).toHaveBeenCalledWith(17, ['wave-unused-a'], []); + expect(mocks.inspectObjectDeletion).toHaveBeenCalledWith(17, ['wave-unused-a'], [], []); await vi.waitFor(() => expect(mocks.startObjectDeletion).toHaveBeenCalledOnce()); - expect(mocks.startObjectDeletion).toHaveBeenCalledWith(17, ['wave-unused-a'], []); + expect(mocks.startObjectDeletion).toHaveBeenCalledWith(17, ['wave-unused-a'], [], []); await vi.waitFor(() => expect(mocks.refreshImage).toHaveBeenCalledWith(17)); await vi.waitFor(() => expect(screen.queryByRole('dialog', { name: 'Clean up Wave Data' })).toBeNull()); }); @@ -1212,7 +1285,7 @@ describe('App panel layout', () => { expect(screen.queryByRole('button', { name: 'New folder' })).toBeNull(); }); - it('continues from package verification into import planning without proxy identity checks', async () => { + it('opens batch package import on a volume with that existing volume selected', async () => { const volume = { id: 'volume-1', name: 'My Volume', @@ -1248,6 +1321,7 @@ describe('App panel layout', () => { sourceMediaKind: 'SFS', valid: true, payloadsVerified: true, + totalPayloadBytes: 0, roots: [{ kind: 'VOLUME', displayName: 'Grand Piano', nodeIds: [] }], objects: [], relationships: [], @@ -1282,6 +1356,7 @@ describe('App panel layout', () => { programAssignmentAdjustments: [], programSlotPlacements: [], allocation: [], + sfsIndexCapacity: [], }); renderAcknowledgedApp(); @@ -1301,14 +1376,34 @@ describe('App panel layout', () => { nextCursor: null, }); await fireEvent.contextMenu(screen.getByRole('button', { name: /My Volume/ })); - await fireEvent.click(screen.getByRole('menuitem', { name: 'Import package…' })); - const importDialog = await screen.findByRole('dialog', { name: 'Import axklib package' }); + await fireEvent.click(screen.getByRole('menuitem', { name: 'Import' })); + await fireEvent.click(screen.getByRole('menuitem', { name: 'Import packages…' })); + const importDialog = await screen.findByRole('dialog', { name: 'Import packages' }); await fireEvent.click(within(importDialog).getByRole('button', { name: /Storage location/ })); - const picker = await screen.findByRole('dialog', { name: 'Choose axklib package' }); - expect(screen.queryByRole('dialog', { name: 'Import axklib package' })).toBeNull(); + const picker = await screen.findByRole('dialog', { name: 'Choose axklib packages' }); + expect(screen.queryByRole('dialog', { name: 'Import packages' })).toBeNull(); expect(screen.getAllByRole('dialog')).toHaveLength(1); await fireEvent.click(await within(picker).findByText('Yamaha')); await fireEvent.click(await within(picker).findByText('GrPiano Fazioli.axkvol')); + await fireEvent.click(within(picker).getByRole('button', { name: 'Select 1 file' })); + + const plannedDialog = await screen.findByRole('dialog', { name: 'Import packages' }); + expect( + (await within(plannedDialog).findByRole('button', { name: 'One volume' })).getAttribute('aria-pressed'), + ).toBe('true'); + expect( + (await within(plannedDialog).findByRole('button', { name: 'Existing' })).getAttribute('aria-pressed'), + ).toBe('true'); + expect( + ( + (await within(plannedDialog).findByRole('combobox', { + name: 'Destination volume', + })) as HTMLInputElement + ).value, + ).toBe('My Volume'); + expect(mocks.planImagePackageImport).not.toHaveBeenCalled(); + + await fireEvent.click(within(plannedDialog).getByRole('button', { name: 'Check conflicts' })); await vi.waitFor(() => expect(mocks.planImagePackageImport).toHaveBeenCalledWith( @@ -1588,8 +1683,9 @@ describe('App panel layout', () => { await chooseNestedImage(); await fireEvent.contextMenu(await screen.findByRole('button', { name: /Object directory/ })); - expect(screen.queryByRole('menuitem', { name: 'Import package…' })).toBeNull(); - await fireEvent.click(screen.getByRole('menuitem', { name: 'Export package…' })); + expect(screen.queryByRole('menuitem', { name: 'Import' })).toBeNull(); + await fireEvent.click(screen.getByRole('menuitem', { name: 'Export' })); + await fireEvent.click(screen.getByRole('menuitem', { name: 'Export volume package…' })); const dialog = await screen.findByRole('dialog', { name: 'Export axklib package' }); expect(within(dialog).getByText('Export “Object directory”')).toBeTruthy(); diff --git a/apps/axkdeck/src/app.css b/apps/axkdeck/src/app.css index c71db098..60ac2419 100644 --- a/apps/axkdeck/src/app.css +++ b/apps/axkdeck/src/app.css @@ -29,8 +29,13 @@ --density-table-header: 20px; --density-metadata-row: 22px; --density-gap: 4px; + --dialog-title-font-size: 13px; + --dialog-body-font-size: 11px; + --dialog-section-font-size: 11px; --dialog-control-font-size: 11px; --dialog-label-font-size: 10px; + --dialog-table-header-font-size: 10px; + --dialog-metadata-font-size: 10px; --dialog-option-metadata-font-size: 10px; --overlay-scrollbar-clearance: 16px; --contained-playback-clearance: 10px; @@ -741,7 +746,7 @@ input:focus-visible { width: 100%; min-width: 0; min-height: var(--density-row); - grid-template-columns: 46px minmax(0, 1fr); + grid-template-columns: 46px minmax(0, 1fr) minmax(110px, 180px); align-items: center; gap: 8px; padding: 2px 7px; @@ -787,6 +792,16 @@ input:focus-visible { white-space: nowrap; } +.object-size-summary { + min-width: 0; + overflow: hidden; + color: var(--color-text-muted); + font-size: 9px; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + .program-system-info { position: relative; display: flex; @@ -1309,6 +1324,8 @@ input:focus-visible { border-radius: 8px; background: var(--color-panel); box-shadow: 0 24px 80px rgb(0 0 0 / 55%); + font-size: var(--dialog-body-font-size); + line-height: 1.35; } .dialog-shell-wide { @@ -1352,7 +1369,102 @@ input:focus-visible { .dialog-header h2 { margin: 0; color: var(--color-text-strong); - font-size: 13px; + font-size: var(--dialog-title-font-size); +} + +.dialog-checkbox { + display: grid; + width: 14px; + height: 14px; + flex: none; + margin: 0; + padding: 0; + overflow: hidden; + appearance: none; + place-content: center; + color: #fff; + border: 1px solid var(--color-border); + border-radius: 2px; + background: var(--color-bg-deep); + cursor: pointer; +} + +.dialog-checkbox::before { + width: 4px; + height: 7px; + content: ''; + border: solid currentcolor; + border-width: 0 1.5px 1.5px 0; + transform: translateY(-0.75px) rotate(45deg) scale(0); +} + +.dialog-checkbox:checked, +.dialog-checkbox:indeterminate { + border-color: var(--color-accent); + background: var(--color-accent); +} + +.dialog-checkbox:checked::before { + transform: translateY(-0.75px) rotate(45deg) scale(1); +} + +.dialog-checkbox:indeterminate::before { + width: 7px; + height: 0; + border: 0; + border-top: 1.5px solid currentcolor; + transform: none; +} + +.dialog-checkbox:disabled { + cursor: default; + opacity: 0.5; +} + +.dialog-segmented-control { + display: grid; + grid-auto-flow: column; + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: 4px; + background: var(--color-bg-deep); +} + +.dialog-segmented-control > button { + min-width: 0; + min-height: 27px; + padding: 0 10px; + overflow: hidden; + color: var(--color-text-muted); + border: 0; + border-left: 1px solid var(--color-border); + border-radius: 0; + background: transparent; + cursor: pointer; + font-size: var(--dialog-table-header-font-size); + text-overflow: ellipsis; + white-space: nowrap; +} + +.dialog-segmented-control > button:first-child { + border-left: 0; +} + +.dialog-segmented-control > button:focus-visible { + position: relative; + z-index: 1; + outline: 1px solid var(--color-accent); + outline-offset: -2px; +} + +.dialog-segmented-control > button[aria-pressed='true'] { + color: #fff; + background: var(--color-accent-strong); +} + +.dialog-segmented-control > button:disabled { + cursor: default; + opacity: 0.45; } .package-dialog { @@ -1360,7 +1472,9 @@ input:focus-visible { } .package-dialog-content { + display: grid; min-height: 0; + gap: 12px; padding: 14px; overflow-y: auto; } @@ -1377,14 +1491,14 @@ input:focus-visible { .package-plan-section h3 { margin: 0; color: var(--color-text-strong); - font-size: 12px; + font-size: var(--dialog-section-font-size); } .import-source-choice > p, .package-source-choice > p { margin: 0 0 4px; color: var(--color-text-muted); - font-size: 11px; + font-size: var(--dialog-body-font-size); } .package-export-items { @@ -1406,7 +1520,7 @@ input:focus-visible { .package-export-group h4 { margin: 0 0 2px; color: var(--color-text-muted); - font-size: 10px; + font-size: var(--dialog-metadata-font-size); font-weight: 600; } @@ -1424,7 +1538,7 @@ input:focus-visible { .package-export-items strong { min-width: 0; overflow: hidden; - font-size: 11px; + font-size: var(--dialog-body-font-size); text-overflow: ellipsis; white-space: nowrap; } @@ -1432,7 +1546,7 @@ input:focus-visible { .package-export-items span { flex: none; color: var(--color-text-muted); - font-size: 10px; + font-size: var(--dialog-metadata-font-size); } .source-choice-button { @@ -1454,36 +1568,7 @@ input:focus-visible { } .audio-export-format { - display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - overflow: hidden; - border: 1px solid var(--color-border); - border-radius: 5px; -} - -.audio-export-format button { - min-height: 32px; - padding: 0 10px; - color: var(--color-text-muted); - border: 0; - border-right: 1px solid var(--color-border); - background: transparent; - cursor: pointer; - font-size: 11px; -} - -.audio-export-format button:last-child { - border-right: 0; -} - -.audio-export-format button.active { - color: var(--color-text-strong); - background: var(--color-selection); -} - -.audio-export-format button:disabled { - cursor: default; - opacity: 0.45; } .audio-export-guidance, @@ -1505,12 +1590,12 @@ input:focus-visible { .source-choice-button strong { color: var(--color-text-strong); - font-size: 12px; + font-size: var(--dialog-section-font-size); } .source-choice-button small { color: var(--color-text-muted); - font-size: 10px; + font-size: var(--dialog-metadata-font-size); } .package-source-summary { @@ -1518,7 +1603,6 @@ input:focus-visible { align-items: center; justify-content: space-between; gap: 12px; - margin-bottom: 12px; padding-bottom: 10px; border-bottom: 1px solid var(--color-border); } @@ -1531,14 +1615,14 @@ input:focus-visible { .package-source-summary small { color: var(--color-text-muted); - font-size: 9px; + font-size: var(--dialog-label-font-size); text-transform: uppercase; } .package-source-summary strong { overflow: hidden; color: var(--color-text-strong); - font-size: 12px; + font-size: var(--dialog-section-font-size); text-overflow: ellipsis; white-space: nowrap; } @@ -1564,7 +1648,7 @@ input:focus-visible { .package-section-heading small { color: var(--color-text-muted); - font-size: 10px; + font-size: var(--dialog-metadata-font-size); } .package-tree { @@ -1592,14 +1676,14 @@ input:focus-visible { .package-tree-row strong { overflow: hidden; color: var(--color-text-strong); - font-size: 11px; + font-size: var(--dialog-body-font-size); text-overflow: ellipsis; white-space: nowrap; } .package-tree-row small { color: var(--color-text-muted); - font-size: 9px; + font-size: var(--dialog-metadata-font-size); white-space: nowrap; } @@ -1619,7 +1703,7 @@ input:focus-visible { .package-plan-summary dt, .package-plan-summary dd { margin: 0; - font-size: 10px; + font-size: var(--dialog-label-font-size); } .package-plan-summary dt { @@ -1632,7 +1716,7 @@ input:focus-visible { gap: 6px; margin: 12px 0 0; color: #85d8ad; - font-size: 11px; + font-size: var(--dialog-body-font-size); } .opaque-sequence-choices { @@ -1656,7 +1740,7 @@ input:focus-visible { max-width: 100%; padding: 0 4px; color: #e4c17c; - font-size: 11px; + font-size: var(--dialog-section-font-size); font-weight: 600; overflow-wrap: anywhere; } @@ -1664,7 +1748,7 @@ input:focus-visible { .opaque-sequence-choices fieldset > p { margin: 0; color: var(--color-text-muted); - font-size: 10px; + font-size: var(--dialog-metadata-font-size); line-height: 1.4; } @@ -1691,12 +1775,12 @@ input:focus-visible { .opaque-sequence-choices strong { color: var(--color-text-strong); - font-size: 10px; + font-size: var(--dialog-label-font-size); } .opaque-sequence-choices small { color: var(--color-text-muted); - font-size: 9px; + font-size: var(--dialog-metadata-font-size); line-height: 1.35; } @@ -1729,7 +1813,7 @@ input:focus-visible { .program-slot-placement-heading strong { color: var(--color-text-strong); - font-size: 11px; + font-size: var(--dialog-section-font-size); } .program-slot-placement-heading small, @@ -1737,7 +1821,7 @@ input:focus-visible { .program-slot-placements section > p { margin: 0; color: var(--color-text-muted); - font-size: 10px; + font-size: var(--dialog-metadata-font-size); } .program-slot-placements dl { @@ -1755,7 +1839,7 @@ input:focus-visible { .program-slot-placements dt, .program-slot-placements dd { margin: 0; - font-size: 10px; + font-size: var(--dialog-label-font-size); } .program-slot-placements dt { @@ -1778,7 +1862,7 @@ input:focus-visible { .program-slot-start span, .program-slot-exception span { color: var(--color-text-muted); - font-size: 10px; + font-size: var(--dialog-label-font-size); } .program-slot-start input, @@ -1790,7 +1874,7 @@ input:focus-visible { border-radius: 4px; background: var(--color-surface); color: var(--color-text-strong); - font-size: 11px; + font-size: var(--dialog-control-font-size); } .package-conflicts { @@ -1805,7 +1889,7 @@ input:focus-visible { .package-conflicts > strong { color: #e4c17c; - font-size: 11px; + font-size: var(--dialog-section-font-size); } .package-conflicts label { @@ -1815,7 +1899,7 @@ input:focus-visible { .package-conflicts label span { color: var(--color-text-muted); - font-size: 10px; + font-size: var(--dialog-label-font-size); } .package-conflicts input { @@ -1826,7 +1910,7 @@ input:focus-visible { border-radius: 4px; background: var(--color-surface); color: var(--color-text-strong); - font-size: 11px; + font-size: var(--dialog-control-font-size); } .package-conflict-actions { @@ -1839,7 +1923,7 @@ input:focus-visible { .package-conflict-actions small { color: #e4c17c; - font-size: 10px; + font-size: var(--dialog-metadata-font-size); } .package-conflict-actions .secondary-button { @@ -1856,7 +1940,7 @@ input:focus-visible { .package-adjustments > strong { color: var(--color-text-strong); - font-size: 11px; + font-size: var(--dialog-section-font-size); } .package-adjustments p { @@ -1867,19 +1951,19 @@ input:focus-visible { .package-adjustments span { color: var(--color-text); - font-size: 10px; + font-size: var(--dialog-body-font-size); } .package-adjustments small { overflow-wrap: anywhere; color: var(--color-text-muted); - font-size: 9px; + font-size: var(--dialog-metadata-font-size); } .package-warning { margin: 9px 0 0; color: #dfbd7b; - font-size: 10px; + font-size: var(--dialog-metadata-font-size); } .volume-action-dialog { @@ -1907,14 +1991,14 @@ input:focus-visible { .destructive-summary strong { color: var(--color-text-strong); - font-size: 13px; + font-size: var(--dialog-section-font-size); } .destructive-summary p, .deletion-notices p { margin: 0; color: var(--color-text-muted); - font-size: 11px; + font-size: var(--dialog-body-font-size); line-height: 1.45; } @@ -1928,13 +2012,13 @@ input:focus-visible { .deletion-impact-section h3 { margin: 0; color: var(--color-text-strong); - font-size: 11px; + font-size: var(--dialog-section-font-size); } .deletion-section-help { margin: -2px 0 0; color: var(--color-text-muted); - font-size: 10px; + font-size: var(--dialog-metadata-font-size); line-height: 1.4; } @@ -1948,7 +2032,7 @@ input:focus-visible { border-radius: 4px; background: rgb(255 255 255 / 3%); cursor: pointer; - font-size: 10px; + font-size: var(--dialog-label-font-size); } .deletion-blockers { @@ -1995,53 +2079,6 @@ input:focus-visible { color: var(--color-text-muted); } -.deletion-checkbox { - width: 16px; - height: 16px; - display: grid; - flex: none; - margin: 1px; - padding: 0; - appearance: none; - place-content: center; - color: #fff; - border: 1px solid var(--color-border); - border-radius: 3px; - background: var(--color-bg-deep); - cursor: pointer; -} - -.deletion-checkbox::before { - width: 4px; - height: 7px; - content: ""; - border: solid currentcolor; - border-width: 0 2px 2px 0; - transform: translateY(-1px) rotate(45deg) scale(0); -} - -.deletion-checkbox:checked { - border-color: var(--color-accent); - background: var(--color-accent); -} - -.deletion-checkbox:checked::before { - transform: translateY(-1px) rotate(45deg) scale(1); -} - -.deletion-checkbox:indeterminate::before { - width: 8px; - height: 2px; - border: 0; - background: currentcolor; - transform: none; -} - -.deletion-checkbox:disabled { - cursor: default; - opacity: 0.5; -} - .deletion-impact-icon { display: grid; width: 18px; @@ -2070,7 +2107,7 @@ input:focus-visible { .deletion-impact-heading strong { overflow: hidden; color: var(--color-text-strong); - font-size: 11px; + font-size: var(--dialog-body-font-size); text-overflow: ellipsis; white-space: nowrap; } @@ -2078,13 +2115,13 @@ input:focus-visible { .deletion-impact-heading small, .deletion-impact-copy > small { color: var(--color-text-muted); - font-size: 10px; + font-size: var(--dialog-metadata-font-size); } .deletion-outcome { margin-left: auto; color: var(--color-text-muted); - font-size: 9px; + font-size: var(--dialog-label-font-size); text-transform: uppercase; } @@ -2106,14 +2143,14 @@ input:focus-visible { .deletion-footer-summary strong { overflow: hidden; color: var(--color-text-strong); - font-size: 10px; + font-size: var(--dialog-section-font-size); text-overflow: ellipsis; white-space: nowrap; } .deletion-footer-summary span { color: var(--color-text-muted); - font-size: 9px; + font-size: var(--dialog-metadata-font-size); } .object-deletion-actions { @@ -2137,7 +2174,7 @@ input:focus-visible { display: grid; gap: 5px; color: var(--color-text-muted); - font-size: 10px; + font-size: var(--dialog-label-font-size); } .dialog-field-control { @@ -2209,12 +2246,12 @@ input:focus-visible { .volume-action-content strong { color: var(--color-text-strong); - font-size: 12px; + font-size: var(--dialog-section-font-size); } .volume-action-content p { margin: 0; - font-size: 11px; + font-size: var(--dialog-body-font-size); } .field-help-error, diff --git a/apps/axkdeck/src/features/catalog/workflow.test.ts b/apps/axkdeck/src/features/catalog/workflow.test.ts index 36b23718..dc0ea14d 100644 --- a/apps/axkdeck/src/features/catalog/workflow.test.ts +++ b/apps/axkdeck/src/features/catalog/workflow.test.ts @@ -44,6 +44,7 @@ function programObject(key: string, name: string): SamplerObject { categoryName: '', sfsId: 1, storedSizeBytes: 1, + sizeWithDependenciesBytes: null, sampleRate: 0, rootKey: 0, frameCount: 0, diff --git a/apps/axkdeck/src/features/deletion/workflow.svelte.ts b/apps/axkdeck/src/features/deletion/workflow.svelte.ts index 575276ba..117d8c96 100644 --- a/apps/axkdeck/src/features/deletion/workflow.svelte.ts +++ b/apps/axkdeck/src/features/deletion/workflow.svelte.ts @@ -6,6 +6,7 @@ import type { JobController } from '../jobs/actions'; export interface ObjectDeletionRequest { targets: PackageExportObject[]; + referrerObjectIds: string[]; cleanupObjectIds: string[]; inspection: ObjectDeletionInspection | null; loading: boolean; @@ -58,6 +59,7 @@ export class DeletionWorkflow { const generation = ++this.objectGeneration; this.objectRequest = { targets, + referrerObjectIds: [], cleanupObjectIds: [], inspection: null, loading: true, @@ -74,19 +76,17 @@ export class DeletionWorkflow { this.objectRequest = null; } - updateObjectSelection(objectId: string, selected: boolean): void { + updateObjectSelection(role: 'REFERRER' | 'DEPENDENCY', objectId: string, selected: boolean): void { const request = this.objectRequest; const inspection = request?.inspection; if (!request || !inspection || request.busy) return; - const included = new Set( - inspection.impacts - .filter((impact) => impact.role === 'DEPENDENCY' && impact.selected) - .map((impact) => impact.objectId), - ); + const referrers = new Set(request.referrerObjectIds); + const cleanup = new Set(request.cleanupObjectIds); + const included = role === 'REFERRER' ? referrers : cleanup; if (selected) { - if (request.targets.length + included.size >= maximumPackageExportRoots) { + if (request.targets.length + referrers.size + cleanup.size >= maximumPackageExportRoots) { this.dependencies.setStatus( - `Deletion is limited to ${maximumPackageExportRoots} targets and cleanup objects`, + `Deletion is limited to ${maximumPackageExportRoots} targets and related objects`, ); return; } @@ -97,45 +97,55 @@ export class DeletionWorkflow { while (changed) { changed = false; for (const impact of inspection.impacts) { + const requested = + (impact.role === 'REFERRER' && referrers.has(impact.objectId)) || + (impact.role === 'DEPENDENCY' && cleanup.has(impact.objectId)); if ( - included.has(impact.objectId) && + requested && impact.prerequisiteObjectIds.some((prerequisite) => inspection.impacts.some( (candidate) => candidate.objectId === prerequisite && - candidate.role === 'DEPENDENCY' && - !included.has(prerequisite), + ((candidate.role === 'REFERRER' && !referrers.has(prerequisite)) || + (candidate.role === 'DEPENDENCY' && !cleanup.has(prerequisite))), ), ) ) { - included.delete(impact.objectId); + if (impact.role === 'REFERRER') referrers.delete(impact.objectId); + if (impact.role === 'DEPENDENCY') cleanup.delete(impact.objectId); changed = true; } } } } const generation = ++this.objectGeneration; - this.objectRequest = { ...request, cleanupObjectIds: [...included], loading: true, error: '' }; + this.objectRequest = { + ...request, + referrerObjectIds: [...referrers], + cleanupObjectIds: [...cleanup], + loading: true, + error: '', + }; void this.inspectObjects(generation); } - updateAllObjectDependencies(selected: boolean): void { + updateAllObjectSelections(role: 'REFERRER' | 'DEPENDENCY', selected: boolean): void { const request = this.objectRequest; const inspection = request?.inspection; if (!request || !inspection || request.busy) return; - const cleanupCapacity = Math.max(0, maximumPackageExportRoots - request.targets.length); - const optional = inspection.impacts.filter( - (impact) => impact.role === 'DEPENDENCY' && impact.status === 'OPTIONAL', - ); - if (selected && optional.length > cleanupCapacity) { + const otherCount = role === 'REFERRER' ? request.cleanupObjectIds.length : request.referrerObjectIds.length; + const capacity = Math.max(0, maximumPackageExportRoots - request.targets.length - otherCount); + const optional = inspection.impacts.filter((impact) => impact.role === role && impact.status === 'OPTIONAL'); + if (selected && optional.length > capacity) { this.dependencies.setStatus( - `Deletion is limited to ${maximumPackageExportRoots} targets and cleanup objects`, + `Deletion is limited to ${maximumPackageExportRoots} targets and related objects`, ); } const generation = ++this.objectGeneration; + const objectIds = selected ? optional.map((impact) => impact.objectId).slice(0, capacity) : []; this.objectRequest = { ...request, - cleanupObjectIds: selected ? optional.map((impact) => impact.objectId).slice(0, cleanupCapacity) : [], + ...(role === 'REFERRER' ? { referrerObjectIds: objectIds } : { cleanupObjectIds: objectIds }), loading: true, error: '', }; @@ -160,6 +170,7 @@ export class DeletionWorkflow { const finalInspection = await this.dependencies.transport.inspectObjectDeletion( sessionId, request.targets.map((target) => target.objectId), + request.referrerObjectIds, request.cleanupObjectIds, ); if (!finalInspection.canApply) { @@ -171,9 +182,8 @@ export class DeletionWorkflow { this.objectRequest = { ...request, inspection: finalInspection, - cleanupObjectIds: finalInspection.impacts - .filter((impact) => impact.role === 'DEPENDENCY' && impact.selected) - .map((impact) => impact.objectId), + referrerObjectIds: requestedImpactIds(finalInspection, 'REFERRER'), + cleanupObjectIds: requestedImpactIds(finalInspection, 'DEPENDENCY'), busy: false, error: 'The deletion impact changed. Review the affected objects before confirming again.', }; @@ -185,6 +195,7 @@ export class DeletionWorkflow { this.dependencies.transport.startObjectDeletion( sessionId, request.targets.map((target) => target.objectId), + request.referrerObjectIds, request.cleanupObjectIds, ), (update) => { @@ -282,7 +293,7 @@ export class DeletionWorkflow { this.dependencies.setStatus('Cleanup candidates changed; review before confirming again'); return; } - const deletion = await this.dependencies.transport.inspectObjectDeletion(sessionId, selectedIds, []); + const deletion = await this.dependencies.transport.inspectObjectDeletion(sessionId, selectedIds, [], []); const targetImpacts = deletion.impacts.filter((impact) => impact.role === 'TARGET'); const eligibleIds = deletion.selectedObjectIds.toSorted(); if ( @@ -303,7 +314,7 @@ export class DeletionWorkflow { return; } const completed = await this.dependencies.jobs.run( - () => this.dependencies.transport.startObjectDeletion(sessionId, selectedIds, []), + () => this.dependencies.transport.startObjectDeletion(sessionId, selectedIds, [], []), (update) => { if (update.progress?.label) this.dependencies.setStatus(update.progress.label); }, @@ -335,6 +346,7 @@ export class DeletionWorkflow { const inspection = await this.dependencies.transport.inspectObjectDeletion( sessionId, request.targets.map((target) => target.objectId), + request.referrerObjectIds, request.cleanupObjectIds, ); if ( @@ -343,7 +355,14 @@ export class DeletionWorkflow { this.dependencies.sessionId() !== sessionId ) return; - this.objectRequest = { ...request, inspection, loading: false, error: '' }; + this.objectRequest = { + ...request, + referrerObjectIds: requestedImpactIds(inspection, 'REFERRER'), + cleanupObjectIds: requestedImpactIds(inspection, 'DEPENDENCY'), + inspection, + loading: false, + error: '', + }; } catch (error) { if ( generation !== this.objectGeneration || @@ -426,10 +445,12 @@ export class DeletionWorkflow { nextGeneration === this.objectGeneration && deletionRequestKey(this.objectRequest?.targets ?? []) === deletionRequestKey(request.targets) ) { - this.objectRequest = { - ...request, - error: `${message} The image has been refreshed; review the deletion again.`, - }; + const current = this.objectRequest; + if (current) + this.objectRequest = { + ...current, + error: `${message} The image has been refreshed; review the deletion again.`, + }; } } @@ -460,13 +481,38 @@ function deletionRequestKey(targets: PackageExportObject[]): string { function deletionInspectionFingerprint(inspection: ObjectDeletionInspection): string { return JSON.stringify({ selected: inspection.selectedObjectIds.toSorted(), - targets: inspection.impacts - .filter((impact) => impact.role === 'TARGET') - .map((impact) => [impact.objectId, impact.status, impact.reason]) - .toSorted(([left], [right]) => left.localeCompare(right)), + impacts: inspection.impacts + .map((impact) => [ + impact.objectId, + impact.role, + impact.status, + impact.requested, + impact.selected, + impact.prerequisiteObjectIds.toSorted(), + impact.reason, + ]) + .toSorted(([left], [right]) => String(left).localeCompare(String(right))), + references: inspection.references + .map((reference) => [ + reference.sourceObjectId, + reference.targetObjectId, + reference.type, + reference.quality, + reference.effect, + ]) + .toSorted(([left], [right]) => String(left).localeCompare(String(right))), + blockers: inspection.blockers + .map((blocker) => [blocker.code, blocker.objectIds.toSorted(), blocker.message]) + .toSorted(([left], [right]) => String(left).localeCompare(String(right))), }); } +function requestedImpactIds(inspection: ObjectDeletionInspection, role: 'REFERRER' | 'DEPENDENCY'): string[] { + return inspection.impacts + .filter((impact) => impact.role === role && impact.requested) + .map((impact) => impact.objectId); +} + function waveDataCleanupFingerprint(inspection: WaveDataOrphanInspection): string { return JSON.stringify( inspection.candidates diff --git a/apps/axkdeck/src/features/dialogs/AppDialogs.svelte b/apps/axkdeck/src/features/dialogs/AppDialogs.svelte index 78488f8d..e5d4df44 100644 --- a/apps/axkdeck/src/features/dialogs/AppDialogs.svelte +++ b/apps/axkdeck/src/features/dialogs/AppDialogs.svelte @@ -40,7 +40,6 @@ import VolumePackageExportDialog from '../../lib/components/VolumePackageExportDialog.svelte'; import VolumeFloppyExportDialog from '../../lib/components/VolumeFloppyExportDialog.svelte'; import WaveDataCleanupDialog from '../../lib/components/WaveDataCleanupDialog.svelte'; - import WorkspaceManager from '../../lib/components/WorkspaceManager.svelte'; import type { RemoteServerSettingsInput, RemoteServerSettingsView } from '../../lib/serverSettings'; import type { DirectoryLocation, FileLocation, ImageLocation } from '../../lib/storageLocations'; import type { CompanionSelection, ImageTransport } from '../../lib/transport'; @@ -74,9 +73,6 @@ hardDiskDirectory: DirectoryLocation | null; finishHardDisk: (file: FileLocation) => void; cancelHardDisk: () => void; - workspaceManagerOpen: boolean; - activeWorkspaceId: string | null; - closeWorkspaceManager: () => void; connectionSettings: RemoteServerSettingsView | null; saveRemoteConnection: (input: RemoteServerSettingsInput) => Promise; switchToLocalConnection: () => Promise; @@ -117,9 +113,6 @@ hardDiskDirectory, finishHardDisk, cancelHardDisk, - workspaceManagerOpen, - activeWorkspaceId, - closeWorkspaceManager, connectionSettings, saveRemoteConnection, switchToLocalConnection, @@ -209,7 +202,6 @@ oncancel={cancelHardDisk} /> {/if} - {#if connectionSettings} {/if} {#if mutation.volumeAction} - {#key `${mutation.volumeAction.action}:${mutation.volumeAction.item.id}`} + {#key `${mutation.volumeAction.action}:${mutation.volumeAction.items.map((item) => item.id).join(':')}`} deletion.updateObjectSelection(objectId, selected)} - onselectall={(selected) => deletion.updateAllObjectDependencies(selected)} + onselectionchange={(role, objectId, selected) => deletion.updateObjectSelection(role, objectId, selected)} + onselectall={(role, selected) => deletion.updateAllObjectSelections(role, selected)} oncancel={() => deletion.cancelObjects()} onconfirm={() => void deletion.submitObjects()} /> diff --git a/apps/axkdeck/src/features/dialogs/visibility.ts b/apps/axkdeck/src/features/dialogs/visibility.ts index 7e470e16..2dd271e7 100644 --- a/apps/axkdeck/src/features/dialogs/visibility.ts +++ b/apps/axkdeck/src/features/dialogs/visibility.ts @@ -34,7 +34,6 @@ interface ExportDialogState { interface AppDialogState { pickerRequest: unknown; imageSession: ImageSessionDialogState; - workspaceManagerOpen: boolean; connectionSettings: unknown; mutation: MutationDialogState; packageImport: RequestDialogState; @@ -56,7 +55,6 @@ export function hasOpenAppDialog(state: AppDialogState): boolean { state.pickerRequest, state.imageSession.companionRequest, state.imageSession.hardDiskDirectory, - state.workspaceManagerOpen, state.connectionSettings, state.mutation.volumeAction, state.mutation.placementRepairRequest, diff --git a/apps/axkdeck/src/features/file-operations/directComputerWorkflow.test.ts b/apps/axkdeck/src/features/file-operations/directComputerWorkflow.test.ts index daa918b7..e453999e 100644 --- a/apps/axkdeck/src/features/file-operations/directComputerWorkflow.test.ts +++ b/apps/axkdeck/src/features/file-operations/directComputerWorkflow.test.ts @@ -1,51 +1,53 @@ import { describe, expect, it, vi } from 'vitest'; import { get } from 'svelte/store'; -import type { PackageImportWorkflow } from '../import/packageWorkflow.svelte'; +import type { PackageBatchImportWorkflow } from '../import/packageBatchWorkflow.svelte'; import type { DiskTreeItem } from '../../lib/types'; import { directComputerDialogVisible, DirectComputerWorkflow } from './directComputerWorkflow'; const target = { kind: 'volume', id: 'volume-1', name: 'Volume 1' } as DiskTreeItem; -function packageImportWorkflow(): PackageImportWorkflow { +function packageBatchImportWorkflow(): PackageBatchImportWorkflow { return { open: vi.fn(), chooseLocal: vi.fn(), - } as unknown as PackageImportWorkflow; + } as unknown as PackageBatchImportWorkflow; } describe('DirectComputerWorkflow', () => { it('opens the native package picker immediately for the managed local sidecar', () => { - const workflow = packageImportWorkflow(); + const workflow = packageBatchImportWorkflow(); const coordinator = new DirectComputerWorkflow(true, 'local'); - coordinator.importPackage(workflow, target); + coordinator.importPackages(workflow, target); expect(workflow.open).toHaveBeenCalledWith(target); expect(workflow.chooseLocal).toHaveBeenCalledWith(true); - expect(get(coordinator.pendingOperation)).toBe('package-import'); - expect(directComputerDialogVisible(get(coordinator.pendingOperation), 'package-import', false)).toBe(false); + expect(get(coordinator.pendingOperation)).toBe('package-batch-import'); + expect(directComputerDialogVisible(get(coordinator.pendingOperation), 'package-batch-import', false)).toBe( + false, + ); }); it('retains the destination chooser for a configured remote connection', () => { - const workflow = packageImportWorkflow(); + const workflow = packageBatchImportWorkflow(); - new DirectComputerWorkflow(true, 'remote').importPackage(workflow, target); + new DirectComputerWorkflow(true, 'remote').importPackages(workflow, target); expect(workflow.open).toHaveBeenCalledWith(target); expect(workflow.chooseLocal).not.toHaveBeenCalled(); }); it('reveals package loading and validation after a native source is selected', () => { - const workflow = packageImportWorkflow(); + const workflow = packageBatchImportWorkflow(); const coordinator = new DirectComputerWorkflow(true, 'local'); - coordinator.importPackage(workflow, target); + coordinator.importPackages(workflow, target); - expect(directComputerDialogVisible(get(coordinator.pendingOperation), 'package-import', true)).toBe(true); + expect(directComputerDialogVisible(get(coordinator.pendingOperation), 'package-batch-import', true)).toBe(true); }); it('never suppresses dialogs for configured remote connections', () => { - expect(directComputerDialogVisible(null, 'package-import', false)).toBe(true); + expect(directComputerDialogVisible(null, 'package-batch-import', false)).toBe(true); }); }); diff --git a/apps/axkdeck/src/features/file-operations/directComputerWorkflow.ts b/apps/axkdeck/src/features/file-operations/directComputerWorkflow.ts index efe02fdd..c8555657 100644 --- a/apps/axkdeck/src/features/file-operations/directComputerWorkflow.ts +++ b/apps/axkdeck/src/features/file-operations/directComputerWorkflow.ts @@ -4,7 +4,6 @@ import type { VolumePackageExportWorkflow } from '../export/volumePackageWorkflo import type { ExportWorkflow } from '../export/workflow.svelte'; import type { AudioImportWorkflow } from '../import/audioWorkflow.svelte'; import type { PackageBatchImportWorkflow } from '../import/packageBatchWorkflow.svelte'; -import type { PackageImportWorkflow } from '../import/packageWorkflow.svelte'; import type { SequenceImportWorkflow } from '../import/sequenceWorkflow.svelte'; import { shouldUseDirectComputerFileOperations } from '../../lib/fileOperationRouting'; import type { ConnectionMode } from '../../lib/transport'; @@ -60,18 +59,7 @@ export class DirectComputerWorkflow { } } - importPackage(workflow: PackageImportWorkflow, target: DiskTreeItem): void { - if (!this.enabled) { - workflow.open(target); - return; - } - void this.run('package-import', async () => { - workflow.open(target); - await workflow.chooseLocal(true); - }); - } - - importVolumePackages(workflow: PackageBatchImportWorkflow, target: DiskTreeItem): void { + importPackages(workflow: PackageBatchImportWorkflow, target: DiskTreeItem): void { if (!this.enabled) { workflow.open(target); return; diff --git a/apps/axkdeck/src/features/image-session/treeActions.test.ts b/apps/axkdeck/src/features/image-session/treeActions.test.ts new file mode 100644 index 00000000..37dfae86 --- /dev/null +++ b/apps/axkdeck/src/features/image-session/treeActions.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { DiskTreeItem } from '../../lib/types'; +import { createImageTreeActionHandler } from './treeActions'; + +const volume = (id: string, partitionIndex: number): DiskTreeItem => ({ + id, + name: id, + kind: 'volume', + childCount: 0, + partitionIndex, +}); + +describe('image tree action routing', () => { + it('deletes the complete selection when the context target is one of its volumes', () => { + const first = volume('first', 0); + const second = volume('second', 1); + const requestVolumeDeletion = vi.fn(); + const handler = createImageTreeActionHandler({ + imageSession: { volumeSelection: { items: [first, second] } }, + mutation: { requestVolumeDeletion }, + } as never); + + handler(second, 'delete-volume'); + + expect(requestVolumeDeletion).toHaveBeenCalledWith([first, second]); + }); + + it('replaces the selection when deletion is requested from an unselected volume', () => { + const selected = volume('selected', 0); + const target = volume('target', 0); + const requestVolumeDeletion = vi.fn(); + const handler = createImageTreeActionHandler({ + imageSession: { volumeSelection: { items: [selected] } }, + mutation: { requestVolumeDeletion }, + } as never); + + handler(target, 'delete-volume'); + + expect(requestVolumeDeletion).toHaveBeenCalledWith([target]); + }); +}); diff --git a/apps/axkdeck/src/features/image-session/treeActions.ts b/apps/axkdeck/src/features/image-session/treeActions.ts new file mode 100644 index 00000000..236cd672 --- /dev/null +++ b/apps/axkdeck/src/features/image-session/treeActions.ts @@ -0,0 +1,117 @@ +import type { ImageTransport } from '../../lib/transport'; +import type { DiskTreeItem, ImageTreeAction, PackageExportSelection } from '../../lib/types'; +import { openAllocationInspector } from '../../lib/allocationInspector'; +import { userFacingMessage } from '../../lib/userFacingMessage'; +import type { DirectComputerWorkflow } from '../file-operations/directComputerWorkflow'; +import type { ExportWorkflow } from '../export/workflow.svelte'; +import type { MediaExportWorkflow } from '../export/mediaWorkflow.svelte'; +import type { VolumeFloppyExportWorkflow } from '../export/volumeFloppyWorkflow.svelte'; +import type { VolumePackageExportWorkflow } from '../export/volumePackageWorkflow.svelte'; +import type { PackageBatchImportWorkflow } from '../import/packageBatchWorkflow.svelte'; +import type { MutationWorkflow } from '../mutation/workflow.svelte'; +import type { ImageSessionWorkflow } from './workflow.svelte'; + +interface ImageTreeActionDependencies { + transport: ImageTransport; + imageSession: ImageSessionWorkflow; + mutation: MutationWorkflow; + directComputer: DirectComputerWorkflow; + packageBatchImport: PackageBatchImportWorkflow; + exports: ExportWorkflow; + volumePackages: VolumePackageExportWorkflow; + volumeFloppies: VolumeFloppyExportWorkflow; + mediaExports: MediaExportWorkflow; + isDesktop: boolean; + exportAudio: (items: PackageExportSelection[]) => Promise; +} + +export function createImageTreeActionHandler(dependencies: ImageTreeActionDependencies) { + const { imageSession } = dependencies; + return (item: DiskTreeItem, action: ImageTreeAction): void => { + if (action === 'delete-volume' && item.kind === 'volume') { + const selected = imageSession.volumeSelection.items; + dependencies.mutation.requestVolumeDeletion( + selected.some((candidate) => candidate.id === item.id) ? selected : [item], + ); + return; + } + if (item.partitionIndex === undefined) return; + if (action === 'inspect-allocation') { + const sessionId = imageSession.sessionId; + if ( + !dependencies.isDesktop || + sessionId === null || + !imageSession.allocationInspectionAvailable || + item.kind !== 'partition' + ) + return; + imageSession.selectSource(item); + void dependencies.transport + .allocationMapReference(sessionId) + .then((reference) => + openAllocationInspector({ + ...reference, + partitionIndex: item.partitionIndex!, + partitionName: item.name, + }), + ) + .catch((error) => imageSession.setStatus(userFacingMessage(error))); + return; + } + if (action === 'import-packages') { + if (!imageSession.packageImportAvailable || (item.kind !== 'partition' && item.kind !== 'volume')) return; + imageSession.selectSource(item); + dependencies.directComputer.importPackages(dependencies.packageBatchImport, item); + return; + } + if (action === 'export-package') { + if (!imageSession.packageExportAvailable || item.kind !== 'volume') return; + imageSession.selectSource(item); + dependencies.directComputer.exportPackage(dependencies.exports, [ + { + kind: 'VOLUME', + contentId: item.id, + partitionIndex: item.partitionIndex, + volumeName: item.name, + name: item.name, + typeLabel: 'Volume', + }, + ]); + return; + } + if (action === 'export-volume-packages') { + if (!imageSession.volumePackageExportAvailable || item.kind !== 'partition') return; + imageSession.selectSource(item); + void dependencies.directComputer.exportVolumePackages(dependencies.volumePackages, item); + return; + } + if (action === 'export-volume-floppies') { + if (!imageSession.volumeFloppyExportAvailable || item.kind !== 'partition') return; + imageSession.selectSource(item); + void dependencies.directComputer.exportVolumeFloppies(dependencies.volumeFloppies, item); + return; + } + if (action === 'export-sfz') { + if (!imageSession.audioExportAvailable || item.kind !== 'volume') return; + imageSession.selectSource(item); + void dependencies.exportAudio([ + { + kind: 'VOLUME', + contentId: item.id, + partitionIndex: item.partitionIndex, + volumeName: item.name, + name: item.name, + typeLabel: 'Volume', + }, + ]); + return; + } + if (action === 'export-cdrom' || action === 'export-floppy') { + if (!imageSession.mediaConversionAvailable) return; + imageSession.selectSource(item); + void dependencies.directComputer.exportMedia(dependencies.mediaExports, item); + return; + } + if (dependencies.mutation.requestVolumeAction(item, action)) imageSession.selectSource(item); + }; +} diff --git a/apps/axkdeck/src/features/image-session/workflow.svelte.ts b/apps/axkdeck/src/features/image-session/workflow.svelte.ts index 84316938..178044b0 100644 --- a/apps/axkdeck/src/features/image-session/workflow.svelte.ts +++ b/apps/axkdeck/src/features/image-session/workflow.svelte.ts @@ -8,6 +8,8 @@ import type { OpenedImage, } from '../../lib/transport'; import type { DiskTreeItem } from '../../lib/types'; +import type { ObjectSelectionMode } from '../../lib/objectSelection'; +import { emptyVolumeSelection, updateVolumeSelection, type VolumeSelectionState } from '../../lib/volumeSelection'; import { AxklibApiError } from '../../lib/httpErrors'; import { userFacingMessage } from '../../lib/userFacingMessage'; import type { AuditionWorkflow } from '../audition/workflow.svelte'; @@ -55,6 +57,7 @@ interface SessionCollaborators { export class ImageSessionWorkflow { sourceItems = $state([]); + volumeSelection = $state(emptyVolumeSelection()); selectedSource = $state({ id: 'none', name: 'No image', @@ -145,6 +148,45 @@ export class ImageSessionWorkflow { } selectSource(item: DiskTreeItem): void { + this.selectTreeSource(item, 'replace', collectVolumes(this.sourceItems)); + } + + selectTreeSource(item: DiskTreeItem, mode: ObjectSelectionMode, visibleVolumes: readonly DiskTreeItem[]): void { + const update = updateVolumeSelection(this.volumeSelection, visibleVolumes, item, mode); + this.volumeSelection = update.selection; + this.activateSource(update.active); + } + + selectSourceForContext(item: DiskTreeItem, visibleVolumes: readonly DiskTreeItem[]): void { + if (item.kind === 'volume' && this.volumeSelection.items.some((candidate) => candidate.id === item.id)) { + this.activateSource(item); + return; + } + this.selectTreeSource(item, 'replace', visibleVolumes); + } + + importDestinationSource(): DiskTreeItem { + const selected = this.volumeSelection.items; + if (selected.length === 1) return selected[0]!; + if (selected.length > 1) { + const partitionIndex = selected[0]?.partitionIndex; + if (partitionIndex !== undefined && selected.every((item) => item.partitionIndex === partitionIndex)) { + return ( + findPartition(this.sourceItems, partitionIndex) ?? { + id: `selected-partition-${partitionIndex}`, + name: `Partition ${partitionIndex + 1}`, + kind: 'partition', + childCount: 0, + partitionIndex, + } + ); + } + return noImageSource(); + } + return this.selectedSource.kind === 'volume' ? noImageSource() : this.selectedSource; + } + + private activateSource(item: DiskTreeItem): void { const { catalog } = this.requireCollaborators(); this.selectedSource = item; if (item.kind !== 'volume') { @@ -307,7 +349,8 @@ export class ImageSessionWorkflow { try { await this.closeOpenSession(); this.sourceItems = []; - this.selectedSource = { id: 'none', name: 'No image', kind: 'disk', childCount: 0 }; + this.volumeSelection = emptyVolumeSelection(); + this.selectedSource = noImageSource(); this.requireCollaborators().catalog.clear(); this.status = 'Ready'; } catch (error) { @@ -401,6 +444,10 @@ export class ImageSessionWorkflow { ? findSourceItem(opened.tree, preferred.partitionIndex, preferred.volumeName) : null; this.selectedSource = preferredItem ?? opened.initialVolume ?? opened.tree[0] ?? this.selectedSource; + this.volumeSelection = + this.selectedSource.kind === 'volume' + ? { items: [this.selectedSource], anchorId: this.selectedSource.id } + : emptyVolumeSelection(); if (this.selectedSource.kind === 'volume') await catalog.loadVolume(this.selectedSource.id, this.selectedSource.partitionIndex ?? null); else catalog.clear(); @@ -499,6 +546,28 @@ function sameImageSource(left: ImageLocation, right: ImageLocation): boolean { ); } +function noImageSource(): DiskTreeItem { + return { id: 'none', name: 'No image', kind: 'disk', childCount: 0 }; +} + +function collectVolumes(items: readonly DiskTreeItem[]): DiskTreeItem[] { + const result: DiskTreeItem[] = []; + for (const item of items) { + if (item.kind === 'volume') result.push(item); + result.push(...collectVolumes(item.children ?? [])); + } + return result; +} + +function findPartition(items: readonly DiskTreeItem[], partitionIndex: number): DiskTreeItem | null { + for (const item of items) { + if (item.kind === 'partition' && item.partitionIndex === partitionIndex) return item; + const nested = findPartition(item.children ?? [], partitionIndex); + if (nested) return nested; + } + return null; +} + function findSourceItem(items: DiskTreeItem[], partitionIndex: number, volumeName?: string): DiskTreeItem | null { for (const item of items) { if ( diff --git a/apps/axkdeck/src/features/image-session/workflow.test.ts b/apps/axkdeck/src/features/image-session/workflow.test.ts index 5b52f98c..05add244 100644 --- a/apps/axkdeck/src/features/image-session/workflow.test.ts +++ b/apps/axkdeck/src/features/image-session/workflow.test.ts @@ -92,3 +92,62 @@ describe('ImageSessionWorkflow lease maintenance', () => { await workflow.dispose(); }); }); + +describe('ImageSessionWorkflow volume selection', () => { + it('uses one volume as an import target and only a shared partition for a multi-selection', async () => { + const transport = { closeImage: vi.fn(async () => undefined) } as unknown as ImageTransport; + const workflow = new ImageSessionWorkflow(transport, {} as PickerController); + workflow.connect({ + catalog: { activeVolumeId: '', loadVolume: vi.fn(async () => undefined), clear: vi.fn() }, + } as never); + const volumeA = { + id: 'volume-a', + name: 'A', + kind: 'volume' as const, + childCount: 0, + partitionIndex: 0, + }; + const volumeB = { + id: 'volume-b', + name: 'B', + kind: 'volume' as const, + childCount: 0, + partitionIndex: 0, + }; + const volumeC = { + id: 'volume-c', + name: 'C', + kind: 'volume' as const, + childCount: 0, + partitionIndex: 1, + }; + const partitionA = { + id: 'partition-a', + name: 'PARTITION 1', + kind: 'partition' as const, + childCount: 2, + partitionIndex: 0, + children: [volumeA, volumeB], + }; + const partitionB = { + id: 'partition-b', + name: 'PARTITION 2', + kind: 'partition' as const, + childCount: 1, + partitionIndex: 1, + children: [volumeC], + }; + workflow.sourceItems = [partitionA, partitionB]; + const visible = [volumeA, volumeB, volumeC]; + + workflow.selectTreeSource(volumeA, 'replace', visible); + expect(workflow.importDestinationSource()).toMatchObject({ id: 'volume-a', kind: 'volume' }); + + workflow.selectTreeSource(volumeB, 'toggle', visible); + expect(workflow.importDestinationSource()).toMatchObject({ id: 'partition-a', kind: 'partition' }); + + workflow.selectTreeSource(volumeC, 'toggle', visible); + expect(workflow.importDestinationSource()).toMatchObject({ id: 'none', kind: 'disk' }); + await workflow.dispose(); + }); +}); diff --git a/apps/axkdeck/src/features/import/audioWorkflow.svelte.ts b/apps/axkdeck/src/features/import/audioWorkflow.svelte.ts index 834fdf08..132c06ac 100644 --- a/apps/axkdeck/src/features/import/audioWorkflow.svelte.ts +++ b/apps/axkdeck/src/features/import/audioWorkflow.svelte.ts @@ -199,9 +199,7 @@ export class AudioImportWorkflow { request.destinationMode = mode; request.destinationVolumeName = ''; request.destinationPartitionIndex = - mode === 'create' - ? (request.destinationPartitionIndex ?? destinations.partitions[0]?.partitionIndex ?? null) - : null; + request.destinationPartitionIndex ?? destinations.partitions[0]?.partitionIndex ?? null; } async setExistingVolume(partitionIndex: number | null, volumeName: string): Promise { @@ -221,7 +219,9 @@ export class AudioImportWorkflow { } setDestinationPartition(partitionIndex: number): void { - if (this.request) this.request.destinationPartitionIndex = partitionIndex; + if (!this.request) return; + this.request.destinationPartitionIndex = partitionIndex; + if (this.request.destinationMode === 'existing') this.request.destinationVolumeName = ''; } setDestinationVolumeName(volumeName: string): void { @@ -233,10 +233,11 @@ export class AudioImportWorkflow { selected: DiskTreeItem | null, ): AudioImportRequest { const initial = selected ? initialImportDestination(selected) : null; + const firstPartition = collectImportDestinations(this.dependencies.sourceItems()).partitions[0]; return { files, destinationMode: initial?.mode ?? 'existing', - destinationPartitionIndex: initial?.partitionIndex ?? null, + destinationPartitionIndex: initial?.partitionIndex ?? firstPartition?.partitionIndex ?? null, destinationVolumeName: initial?.volumeName ?? '', }; } diff --git a/apps/axkdeck/src/features/import/audioWorkflow.test.ts b/apps/axkdeck/src/features/import/audioWorkflow.test.ts new file mode 100644 index 00000000..fe86c601 --- /dev/null +++ b/apps/axkdeck/src/features/import/audioWorkflow.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from 'vitest'; +import { serverFileLocation } from '../../lib/storageLocations'; +import type { ImageTransport } from '../../lib/transport'; +import type { DiskTreeItem } from '../../lib/types'; +import { PickerController } from '../dialogs/picker'; +import type { JobController } from '../jobs/actions'; +import { AudioImportWorkflow } from './audioWorkflow.svelte'; + +describe('AudioImportWorkflow', () => { + it('clears an existing volume without loading another volume when its partition changes', () => { + const firstVolume: DiskTreeItem = { + id: 'volume-0', + name: 'First', + kind: 'volume', + childCount: 0, + partitionIndex: 0, + }; + const secondVolume: DiskTreeItem = { + id: 'volume-1', + name: 'Second', + kind: 'volume', + childCount: 0, + partitionIndex: 1, + }; + const loadVolume = vi.fn(); + const workflow = new AudioImportWorkflow({ + transport: {} as ImageTransport, + jobs: {} as JobController, + picker: new PickerController(() => undefined), + sessionId: () => 17, + imageLocation: () => serverFileLocation({ rootId: 'root', relativePath: 'image.hds' }), + imageFormat: () => 'sfs', + mutationsAvailable: () => true, + selectedSource: () => firstVolume, + setSelectedSource: vi.fn(), + sourceItems: () => [firstVolume, secondVolume], + activeVolumeId: () => firstVolume.id, + sampleBanks: () => [], + samples: () => [], + loadVolume, + refreshSession: vi.fn(), + invalidateSession: vi.fn(), + selectWorkspace: vi.fn(), + selectSampleBank: vi.fn(), + selectSample: vi.fn(), + setStatus: vi.fn(), + reportTiming: vi.fn(), + }); + + workflow.chooseFiles(); + workflow.setDestinationPartition(1); + + expect(workflow.request).toMatchObject({ + destinationMode: 'existing', + destinationPartitionIndex: 1, + destinationVolumeName: '', + }); + expect(loadVolume).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/axkdeck/src/features/import/packageBatchPlanning.ts b/apps/axkdeck/src/features/import/packageBatchPlanning.ts index e99ae752..8875f119 100644 --- a/apps/axkdeck/src/features/import/packageBatchPlanning.ts +++ b/apps/axkdeck/src/features/import/packageBatchPlanning.ts @@ -64,15 +64,13 @@ export function normalizedBatchDestination( volume.volumeName === request.destinationVolumeName, ); const mode = selectedVolume ? 'existing' : request.item?.kind === 'partition' ? 'create' : 'existing'; - const firstVolume = selectedVolume ?? destinations.volumes[0]; + const partitionIndex = request.destinationPartitionIndex ?? destinations.partitions[0]?.partitionIndex ?? null; return { destinationStrategy: 'shared', destinationMode: mode, - destinationPartitionIndex: - mode === 'existing' - ? (firstVolume?.partitionIndex ?? null) - : (request.destinationPartitionIndex ?? destinations.partitions[0]?.partitionIndex ?? null), - destinationVolumeName: mode === 'existing' ? (firstVolume?.volumeName ?? '') : suggestedSharedVolumeName(items), + destinationPartitionIndex: selectedVolume?.partitionIndex ?? partitionIndex, + destinationVolumeName: + mode === 'existing' ? (selectedVolume?.volumeName ?? '') : suggestedSharedVolumeName(items), }; } diff --git a/apps/axkdeck/src/features/import/packageBatchWorkflow.svelte.ts b/apps/axkdeck/src/features/import/packageBatchWorkflow.svelte.ts index 6e34f07c..e7855669 100644 --- a/apps/axkdeck/src/features/import/packageBatchWorkflow.svelte.ts +++ b/apps/axkdeck/src/features/import/packageBatchWorkflow.svelte.ts @@ -8,7 +8,6 @@ import { reportError } from '../../lib/diagnostics'; import { userFacingMessage } from '../../lib/userFacingMessage'; import { collectImportDestinations, - importDestination, initialImportDestination, type ImportDestinationMode, type ImportPartitionOption, @@ -64,7 +63,7 @@ export class PackageBatchImportWorkflow { this.plannedItemIds = []; const destination = initialImportDestination(item) ?? { mode: 'existing' as const, - partitionIndex: null, + partitionIndex: this.destinations().partitions[0]?.partitionIndex ?? null, volumeName: '', }; this.request = { @@ -93,58 +92,48 @@ export class PackageBatchImportWorkflow { const request = this.request; if (!request || request.status === 'applying' || request.destinationStrategy === strategy) return; if (strategy === 'separate' && !separateVolumesAvailable(request.items.filter((item) => item.selected))) return; - this.updateDestination({ destinationStrategy: strategy }, true); + this.updateDestination({ destinationStrategy: strategy }); } setDestinationMode(mode: ImportDestinationMode): void { const request = this.request; if (!request || request.status === 'applying' || request.destinationMode === mode) return; const destinations = this.destinations(); - const currentVolume = destinations.volumes.find( - (option) => - option.partitionIndex === request.destinationPartitionIndex && - option.volumeName === request.destinationVolumeName, - ); - const existingVolume = currentVolume ?? destinations.volumes[0]; - this.updateDestination( - { - destinationStrategy: 'shared', - destinationMode: mode, - destinationPartitionIndex: - mode === 'existing' - ? (existingVolume?.partitionIndex ?? null) - : (request.destinationPartitionIndex ?? destinations.partitions[0]?.partitionIndex ?? null), - destinationVolumeName: - mode === 'existing' ? (existingVolume?.volumeName ?? '') : suggestedSharedVolumeName(request.items), - }, - true, - ); + this.updateDestination({ + destinationStrategy: 'shared', + destinationMode: mode, + destinationPartitionIndex: + request.destinationPartitionIndex ?? destinations.partitions[0]?.partitionIndex ?? null, + destinationVolumeName: mode === 'existing' ? '' : suggestedSharedVolumeName(request.items), + }); } setExistingVolume(partitionIndex: number | null, volumeName: string): void { const request = this.request; if (!request || request.status === 'applying') return; - this.updateDestination( - { - destinationStrategy: 'shared', - destinationMode: 'existing', - destinationPartitionIndex: partitionIndex, - destinationVolumeName: volumeName, - }, - partitionIndex !== null, - ); + this.updateDestination({ + destinationStrategy: 'shared', + destinationMode: 'existing', + destinationPartitionIndex: partitionIndex, + destinationVolumeName: volumeName, + }); } setDestinationPartition(partitionIndex: number): void { const request = this.request; if (!request || request.status === 'applying') return; - this.updateDestination({ destinationPartitionIndex: partitionIndex }, true); + this.updateDestination({ + destinationPartitionIndex: partitionIndex, + ...(request.destinationStrategy === 'shared' && request.destinationMode === 'existing' + ? { destinationVolumeName: '' } + : {}), + }); } setDestinationVolumeName(volumeName: string): void { const request = this.request; if (!request || request.status === 'applying') return; - this.updateDestination({ destinationVolumeName: volumeName.slice(0, 16) }, false); + this.updateDestination({ destinationVolumeName: volumeName.slice(0, 16) }); } renameVolume(itemId: string, name: string): void { @@ -251,7 +240,6 @@ export class PackageBatchImportWorkflow { opaqueSequenceActions: { ...this.request.opaqueSequenceActions, [key]: action }, hasUnvalidatedChanges: true, }; - void this.replan(); } destinationName(itemId: string): string { @@ -475,10 +463,8 @@ export class PackageBatchImportWorkflow { try { await this.dependencies.refreshSession({ partitionIndex: request.destinationPartitionIndex! }); if (generation !== this.generation || !this.request) return; - await this.plan(generation); - if (generation === this.generation && this.request?.status === 'ready' && this.request.plan) { - this.dependencies.setStatus('Image changed; import conflicts checked again'); - } + this.request = { ...this.request, status: 'ready' }; + this.dependencies.setStatus('Image changed; check import conflicts again'); } catch (error) { if (generation !== this.generation || !this.request) return; const message = userFacingMessage(error); @@ -555,20 +541,24 @@ export class PackageBatchImportWorkflow { this.request = { ...this.request, items: [...items], completedFiles: index + 1 }; } const destination = normalizedBatchDestination(this.request, items, this.destinations()); - this.request = { ...this.request, ...destination, status: 'planning' }; - await this.plan(generation); + this.request = { + ...this.request, + ...destination, + status: 'ready', + hasUnvalidatedChanges: true, + }; } catch (error) { await Promise.all(sources.map((item) => this.releaseUpload(item.upload))); if (generation !== this.generation || !this.request) return; + reportError('Inspect package batch failed', error); this.request = { ...this.request, items: [], status: 'choosing', error: userFacingMessage(error) }; } } private async plan(generation: number, replacePlanToken?: string): Promise { - const automaticPlanToken = await this.planOnce(generation, replacePlanToken, true); - if (!automaticPlanToken) return; try { - await this.planOnce(generation, automaticPlanToken, false); + const automaticPlanToken = await this.planOnce(generation, replacePlanToken, true); + if (automaticPlanToken) await this.planOnce(generation, automaticPlanToken, false); } catch (error) { if (generation === this.generation && this.request) { this.request = { ...this.request, status: 'ready', error: userFacingMessage(error) }; @@ -591,47 +581,40 @@ export class PackageBatchImportWorkflow { } return null; } + if (generation !== this.generation) return null; this.request = { ...request, status: 'planning', error: '' }; - try { - const plan = await this.dependencies.transport.planImagePackageImport( - sessionId, - selectedItems.map((item) => item.source), - arguments_.destination, - arguments_.renames, - arguments_.programSlotAssignments, - replacePlanToken, - arguments_.opaqueSequenceDecisions, - ); - if (generation !== this.generation || !this.request) { - await this.releasePlan(plan); - return null; - } - const merged = mergeBatchPlanSuggestions(request, selectedItems, plan); - const checkSuggestedProgramSlots = - allowAutomaticProgramSlotCheck && - merged.suggestedSlotsAdded && - plan.programSlotPlacements.some( - (placement) => - !placement.applied && placement.mode !== 'UNAVAILABLE' && placement.mappings.length > 0, - ); - this.request = { - ...this.request, - plan, - volumeNames: merged.volumeNames, - renames: merged.renames, - programSlots: merged.programSlots, - hasUnvalidatedChanges: merged.suggestedSlotsAdded, - status: checkSuggestedProgramSlots ? 'planning' : 'ready', - error: '', - }; - this.plannedItemIds = selectedItems.map((item) => item.id); - return checkSuggestedProgramSlots ? plan.planToken : null; - } catch (error) { - if (generation === this.generation && this.request) { - this.request = { ...this.request, status: 'ready', error: userFacingMessage(error) }; - } + const plan = await this.dependencies.transport.planImagePackageImport( + sessionId, + selectedItems.map((item) => item.source), + arguments_.destination, + arguments_.renames, + arguments_.programSlotAssignments, + replacePlanToken, + arguments_.opaqueSequenceDecisions, + ); + if (generation !== this.generation || !this.request) { + await this.releasePlan(plan); return null; } + const merged = mergeBatchPlanSuggestions(request, selectedItems, plan); + const checkSuggestedProgramSlots = + allowAutomaticProgramSlotCheck && + merged.suggestedSlotsAdded && + plan.programSlotPlacements.some( + (placement) => !placement.applied && placement.mode !== 'UNAVAILABLE' && placement.mappings.length > 0, + ); + this.request = { + ...this.request, + plan, + volumeNames: merged.volumeNames, + renames: merged.renames, + programSlots: merged.programSlots, + hasUnvalidatedChanges: merged.suggestedSlotsAdded, + status: checkSuggestedProgramSlots ? 'planning' : 'ready', + error: '', + }; + this.plannedItemIds = selectedItems.map((item) => item.id); + return checkSuggestedProgramSlots ? plan.planToken : null; } private updateDestination( @@ -641,7 +624,6 @@ export class PackageBatchImportWorkflow { 'destinationStrategy' | 'destinationMode' | 'destinationPartitionIndex' | 'destinationVolumeName' > >, - automaticallyPlan: boolean, ): void { const request = this.request; if (!request || request.status === 'applying') return; @@ -656,23 +638,10 @@ export class PackageBatchImportWorkflow { status: request.items.length > 0 ? ('ready' as const) : request.status, error: '', }; - const destinationReady = - next.destinationStrategy === 'separate' - ? next.destinationPartitionIndex !== null && - separateVolumesAvailable(next.items.filter((item) => item.selected)) - : importDestination( - next.destinationMode, - next.destinationPartitionIndex, - next.destinationVolumeName, - ) !== null; - const shouldPlan = automaticallyPlan && next.items.some((item) => item.selected) && destinationReady; - const generation = ++this.generation; - this.request = { ...next, status: shouldPlan ? 'planning' : next.status }; - void (async () => { - await this.releasePlan(previousPlan); - if (!shouldPlan || generation !== this.generation || !this.request) return; - await this.plan(generation); - })(); + ++this.generation; + this.plannedItemIds = []; + this.request = next; + void this.releasePlan(previousPlan); } private destinations() { diff --git a/apps/axkdeck/src/features/import/packageBatchWorkflow.test.ts b/apps/axkdeck/src/features/import/packageBatchWorkflow.test.ts index af977c0e..6d6e37aa 100644 --- a/apps/axkdeck/src/features/import/packageBatchWorkflow.test.ts +++ b/apps/axkdeck/src/features/import/packageBatchWorkflow.test.ts @@ -78,6 +78,270 @@ function plan(names: string[], token: string): ImageSessionPackageImportPlan { } describe('PackageBatchImportWorkflow', () => { + it('clears a shared existing volume without planning when its partition changes', () => { + const firstVolume: DiskTreeItem = { + id: 'volume-0', + name: 'First', + kind: 'volume', + childCount: 0, + partitionIndex: 0, + }; + const secondVolume: DiskTreeItem = { + id: 'volume-1', + name: 'Second', + kind: 'volume', + childCount: 0, + partitionIndex: 1, + }; + const planImagePackageImport = vi.fn(); + const workflow = new PackageBatchImportWorkflow({ + transport: { planImagePackageImport } as unknown as ImageTransport, + jobs: {} as JobController, + picker: new PickerController(() => undefined), + pickerHistory: new PackagePickerHistory(), + isDesktop: false, + sessionId: () => 17, + invalidateSession: vi.fn(), + refreshSession: vi.fn(), + setStatus: vi.fn(), + sourceItems: () => [firstVolume, secondVolume], + }); + + workflow.open(firstVolume); + workflow.setDestinationPartition(1); + + expect(workflow.request).toMatchObject({ + destinationStrategy: 'shared', + destinationMode: 'existing', + destinationPartitionIndex: 1, + destinationVolumeName: '', + plan: null, + }); + expect(planImagePackageImport).not.toHaveBeenCalled(); + }); + + it('waits for an explicit conflict check after loading packages', async () => { + const sources = [serverFileLocation({ rootId: 'workspace', relativePath: 'one.axkvol' }, 'one.axkvol')]; + const planImagePackageImport = vi.fn().mockResolvedValue(plan(['One'], 'explicit-plan')); + const picker = new PickerController(() => undefined); + const workflow = new PackageBatchImportWorkflow({ + transport: { + inspectPackage: vi.fn().mockResolvedValue(inspection('package-0', 'One', 1)), + planImagePackageImport, + releaseImagePackageImportPlan: vi.fn().mockResolvedValue(undefined), + } as unknown as ImageTransport, + jobs: {} as JobController, + picker, + pickerHistory: new PackagePickerHistory(), + isDesktop: false, + sessionId: () => 17, + invalidateSession: vi.fn().mockResolvedValue(undefined), + refreshSession: vi.fn().mockResolvedValue(undefined), + setStatus: vi.fn(), + }); + const partition: DiskTreeItem = { + id: 'partition-0', + name: 'Partition 1', + kind: 'partition', + childCount: 0, + partitionIndex: 0, + }; + + workflow.open(partition); + const choosing = workflow.chooseWorkspace(); + picker.finish(sources); + await choosing; + + expect(planImagePackageImport).not.toHaveBeenCalled(); + expect(workflow.request).toMatchObject({ + plan: null, + status: 'ready', + hasUnvalidatedChanges: true, + }); + + await workflow.replan(); + + expect(planImagePackageImport).toHaveBeenCalledTimes(1); + expect(workflow.request?.plan?.planToken).toBe('explicit-plan'); + + const itemId = workflow.request!.items[0].id; + workflow.opaqueSequenceAction(itemId, 'sequence-1', 'SKIP'); + workflow.setDestinationPartition(1); + await Promise.resolve(); + + expect(planImagePackageImport).toHaveBeenCalledTimes(1); + expect(workflow.request).toMatchObject({ + plan: null, + status: 'ready', + hasUnvalidatedChanges: true, + }); + }); + + it('checks suggested Program slots once within an explicit batch conflict check', async () => { + const sources = [ + serverFileLocation({ rootId: 'workspace', relativePath: 'pads-1.axkvol' }, 'pads-1.axkvol'), + serverFileLocation({ rootId: 'workspace', relativePath: 'pads-2.axkvol' }, 'pads-2.axkvol'), + serverFileLocation({ rootId: 'workspace', relativePath: 'pads-3.axkvol' }, 'pads-3.axkvol'), + ]; + const suggestedPlan = plan(['Pads 1', 'Pads 2', 'Pads 3'], 'suggested-plan'); + suggestedPlan.valid = false; + suggestedPlan.programSlotPlacements = [ + { + placementId: 'placement-1', + partitionIndex: 0, + volumeName: 'Target', + mode: 'CONTIGUOUS', + applied: false, + suggestedStartSlot: 21, + requiredSlotCount: 3, + availableSlotCount: 108, + occupiedRanges: [{ first: 1, last: 20 }], + sourceRanges: [{ first: 1, last: 3 }], + destinationRanges: [{ first: 21, last: 23 }], + mappings: sources.map((_, packageIndex) => ({ + packageIndex, + nodeId: `package-${packageIndex}-0`, + sourceSlot: packageIndex + 1, + destinationSlot: packageIndex + 21, + requiresUserAction: false, + })), + }, + ]; + const checkedPlan = plan(['Pads 1', 'Pads 2', 'Pads 3'], 'checked-plan'); + checkedPlan.programSlotPlacements = suggestedPlan.programSlotPlacements.map((placement) => ({ + ...placement, + applied: true, + })); + const planImagePackageImport = vi.fn().mockResolvedValueOnce(suggestedPlan).mockResolvedValueOnce(checkedPlan); + const picker = new PickerController(() => undefined); + const volume: DiskTreeItem = { + id: 'volume-0', + name: 'Target', + kind: 'volume', + childCount: 20, + partitionIndex: 0, + }; + const workflow = new PackageBatchImportWorkflow({ + transport: { + inspectPackage: vi + .fn() + .mockResolvedValueOnce(inspection('package-0', 'Pads 1', 1)) + .mockResolvedValueOnce(inspection('package-1', 'Pads 2', 1)) + .mockResolvedValueOnce(inspection('package-2', 'Pads 3', 1)), + planImagePackageImport, + releaseImagePackageImportPlan: vi.fn().mockResolvedValue(undefined), + } as unknown as ImageTransport, + jobs: {} as JobController, + picker, + pickerHistory: new PackagePickerHistory(), + isDesktop: false, + sessionId: () => 17, + invalidateSession: vi.fn().mockResolvedValue(undefined), + refreshSession: vi.fn().mockResolvedValue(undefined), + setStatus: vi.fn(), + sourceItems: () => [volume], + }); + + workflow.open(volume); + const choosing = workflow.chooseWorkspace(); + picker.finish(sources); + await choosing; + await workflow.replan(); + + expect(planImagePackageImport).toHaveBeenCalledTimes(2); + expect(planImagePackageImport).toHaveBeenNthCalledWith( + 2, + 17, + sources, + { kind: 'EXISTING_VOLUME', partitionIndex: 0, volumeName: 'Target' }, + [], + sources.map((_, packageIndex) => ({ + packageIndex, + nodeId: `package-${packageIndex}-0`, + destinationSlot: packageIndex + 21, + })), + 'suggested-plan', + [], + ); + expect(workflow.request).toMatchObject({ + plan: { planToken: 'checked-plan', valid: true }, + status: 'ready', + hasUnvalidatedChanges: false, + }); + expect(Object.values(workflow.request!.programSlots)).toEqual([21, 22, 23]); + }); + + it('limits automatic batch Program slot checks to one replacement plan', async () => { + const source = serverFileLocation({ rootId: 'workspace', relativePath: 'one.axkvol' }, 'one.axkvol'); + const firstPlan = plan(['One'], 'suggested-plan-1'); + firstPlan.valid = false; + firstPlan.programSlotPlacements = [ + { + placementId: 'placement-1', + partitionIndex: 0, + volumeName: 'One', + mode: 'CONTIGUOUS', + applied: false, + suggestedStartSlot: 5, + requiredSlotCount: 1, + availableSlotCount: 124, + occupiedRanges: [{ first: 1, last: 4 }], + sourceRanges: [{ first: 1, last: 1 }], + destinationRanges: [{ first: 5, last: 5 }], + mappings: [ + { + packageIndex: 0, + nodeId: 'package-0-0', + sourceSlot: 1, + destinationSlot: 5, + requiresUserAction: false, + }, + ], + }, + ]; + const secondPlan = { + ...firstPlan, + planToken: 'suggested-plan-2', + planId: 'suggested-plan-2-id', + }; + const planImagePackageImport = vi.fn().mockResolvedValueOnce(firstPlan).mockResolvedValueOnce(secondPlan); + const picker = new PickerController(() => undefined); + const workflow = new PackageBatchImportWorkflow({ + transport: { + inspectPackage: vi.fn().mockResolvedValue(inspection('package-0', 'One', 1)), + planImagePackageImport, + releaseImagePackageImportPlan: vi.fn().mockResolvedValue(undefined), + } as unknown as ImageTransport, + jobs: {} as JobController, + picker, + pickerHistory: new PackagePickerHistory(), + isDesktop: false, + sessionId: () => 17, + invalidateSession: vi.fn().mockResolvedValue(undefined), + refreshSession: vi.fn().mockResolvedValue(undefined), + setStatus: vi.fn(), + }); + + workflow.open({ + id: 'partition-0', + name: 'Partition 1', + kind: 'partition', + childCount: 0, + partitionIndex: 0, + }); + const choosing = workflow.chooseWorkspace(); + picker.finish([source]); + await choosing; + await workflow.replan(); + + expect(planImagePackageImport).toHaveBeenCalledTimes(2); + expect(workflow.request).toMatchObject({ + plan: { planToken: 'suggested-plan-2', valid: false }, + status: 'ready', + hasUnvalidatedChanges: false, + }); + }); + it('previews unique server suggestions, replans edited names, and applies one atomic plan', async () => { const sources = [ serverFileLocation({ rootId: 'workspace', relativePath: 'one.axkvol' }, 'one.axkvol'), @@ -130,6 +394,9 @@ describe('PackageBatchImportWorkflow', () => { picker.finish(sources); await choosing; + expect(planImagePackageImport).not.toHaveBeenCalled(); + await workflow.replan(); + expect(workflow.request?.plan?.packages.map((item) => item.destinationVolumeName)).toEqual([ 'Drums', 'Drums 2', @@ -184,6 +451,68 @@ describe('PackageBatchImportWorkflow', () => { expect(workflow.request).toBeNull(); }); + it('accumulates multiple object rename drafts before one explicit replan', async () => { + const sources = [ + serverFileLocation({ rootId: 'workspace', relativePath: 'one.axkvol' }, 'one.axkvol'), + serverFileLocation({ rootId: 'workspace', relativePath: 'two.axkvol' }, 'two.axkvol'), + ]; + const planImagePackageImport = vi + .fn() + .mockResolvedValueOnce(plan(['One', 'Two'], 'initial-plan')) + .mockResolvedValueOnce(plan(['One', 'Two'], 'renamed-plan')); + const picker = new PickerController(() => undefined); + const workflow = new PackageBatchImportWorkflow({ + transport: { + inspectPackage: vi + .fn() + .mockResolvedValueOnce(inspection('package-0', 'One', 1)) + .mockResolvedValueOnce(inspection('package-1', 'Two', 1)), + planImagePackageImport, + releaseImagePackageImportPlan: vi.fn().mockResolvedValue(undefined), + } as unknown as ImageTransport, + jobs: {} as JobController, + picker, + pickerHistory: new PackagePickerHistory(), + isDesktop: false, + sessionId: () => 17, + invalidateSession: vi.fn().mockResolvedValue(undefined), + refreshSession: vi.fn().mockResolvedValue(undefined), + setStatus: vi.fn(), + }); + const partition: DiskTreeItem = { + id: 'partition-0', + name: 'Partition 1', + kind: 'partition', + childCount: 1, + partitionIndex: 0, + }; + + workflow.open(partition); + const choosing = workflow.chooseWorkspace(); + picker.finish(sources); + await choosing; + + await workflow.replan(); + + const [one, two] = workflow.request!.items; + workflow.rename(one.id, 'sample-1', 'Kick fixed'); + workflow.rename(two.id, 'sample-2', 'Snare fixed'); + + expect(workflow.request?.renames).toEqual({ + [`${one.id}:sample-1`]: 'Kick fixed', + [`${two.id}:sample-2`]: 'Snare fixed', + }); + expect(planImagePackageImport).toHaveBeenCalledTimes(1); + + await workflow.replan(); + + expect(planImagePackageImport).toHaveBeenCalledTimes(2); + expect(planImagePackageImport.mock.calls[1][3]).toEqual([ + { packageIndex: 0, nodeId: 'sample-1', destinationName: 'Kick fixed' }, + { packageIndex: 1, nodeId: 'sample-2', destinationName: 'Snare fixed' }, + ]); + }); + it('preserves package state while replanning and importing only selected packages', async () => { const sources = [ serverFileLocation({ rootId: 'workspace', relativePath: 'one.axkvol' }, 'one.axkvol'), @@ -240,6 +569,8 @@ describe('PackageBatchImportWorkflow', () => { picker.finish(sources); await choosing; + await workflow.replan(); + const [one, two, three] = workflow.request!.items; expect(workflow.request?.items.every((item) => item.selected)).toBe(true); workflow.renameVolume(three.id, 'Three edited'); @@ -287,6 +618,77 @@ describe('PackageBatchImportWorkflow', () => { expect(setStatus).toHaveBeenCalledWith('Imported 2 packages'); }); + it('keeps multiple volume packages targeted at the selected existing volume', async () => { + const sources = [ + serverFileLocation({ rootId: 'workspace', relativePath: 'one.axkvol' }, 'one.axkvol'), + serverFileLocation({ rootId: 'workspace', relativePath: 'two.axkvol' }, 'two.axkvol'), + ]; + const planImagePackageImport = vi.fn().mockResolvedValue(plan(['Target', 'Target'], 'volume-plan')); + const picker = new PickerController(() => undefined); + const volume: DiskTreeItem = { + id: 'volume-0', + name: 'Target', + kind: 'volume', + childCount: 0, + partitionIndex: 0, + }; + const partition: DiskTreeItem = { + id: 'partition-0', + name: 'Partition 1', + kind: 'partition', + childCount: 1, + partitionIndex: 0, + children: [volume], + }; + const workflow = new PackageBatchImportWorkflow({ + transport: { + inspectPackage: vi + .fn() + .mockResolvedValueOnce(inspection('package-0', 'One', 1)) + .mockResolvedValueOnce(inspection('package-1', 'Two', 1)), + planImagePackageImport, + releaseImagePackageImportPlan: vi.fn().mockResolvedValue(undefined), + } as unknown as ImageTransport, + jobs: {} as JobController, + picker, + pickerHistory: new PackagePickerHistory(), + isDesktop: false, + sessionId: () => 17, + invalidateSession: vi.fn().mockResolvedValue(undefined), + refreshSession: vi.fn().mockResolvedValue(undefined), + setStatus: vi.fn(), + sourceItems: () => [partition], + }); + + workflow.open(volume); + const choosing = workflow.chooseWorkspace(); + picker.finish(sources); + await choosing; + + expect(workflow.request).toMatchObject({ + destinationStrategy: 'shared', + destinationMode: 'existing', + destinationPartitionIndex: 0, + destinationVolumeName: 'Target', + plan: null, + hasUnvalidatedChanges: true, + }); + expect(workflow.canUseSeparateVolumes()).toBe(true); + expect(planImagePackageImport).not.toHaveBeenCalled(); + + await workflow.replan(); + + expect(planImagePackageImport).toHaveBeenCalledWith( + 17, + sources, + { kind: 'EXISTING_VOLUME', partitionIndex: 0, volumeName: 'Target' }, + [], + [], + undefined, + [], + ); + }); + it('plans mixed package kinds into one selected existing volume', async () => { const sources = [ serverFileLocation({ rootId: 'workspace', relativePath: 'one.axkprg' }, 'one.axkprg'), @@ -334,6 +736,8 @@ describe('PackageBatchImportWorkflow', () => { picker.finish(sources); await choosing; + await workflow.replan(); + expect(workflow.request?.destinationStrategy).toBe('shared'); expect(workflow.canUseSeparateVolumes()).toBe(false); expect(planImagePackageImport).toHaveBeenCalledWith( @@ -404,7 +808,7 @@ describe('PackageBatchImportWorkflow', () => { ); }); - it('refreshes and replans automatically when the image changed before apply', async () => { + it('refreshes and waits for an explicit conflict check when the image changed before apply', async () => { const source = serverFileLocation({ rootId: 'workspace', relativePath: 'one.axkvol' }, 'one.axkvol'); const planImagePackageImport = vi .fn() @@ -452,10 +856,21 @@ describe('PackageBatchImportWorkflow', () => { const choosing = workflow.chooseWorkspace(); picker.finish([source]); await choosing; + await workflow.replan(); await workflow.apply(); expect(refreshSession).toHaveBeenCalledWith({ partitionIndex: 0 }); expect(releaseImagePackageImportPlan).toHaveBeenCalledWith('stale-plan'); + expect(planImagePackageImport).toHaveBeenCalledTimes(1); + expect(workflow.request).toMatchObject({ + plan: null, + status: 'ready', + hasUnvalidatedChanges: true, + }); + expect(setStatus).toHaveBeenLastCalledWith('Image changed; check import conflicts again'); + + await workflow.replan(); + expect(planImagePackageImport).toHaveBeenCalledTimes(2); expect(planImagePackageImport).toHaveBeenLastCalledWith( 17, @@ -472,7 +887,6 @@ describe('PackageBatchImportWorkflow', () => { ); expect(workflow.request?.plan?.planToken).toBe('fresh-plan'); expect(workflow.request?.status).toBe('ready'); - expect(setStatus).toHaveBeenLastCalledWith('Image changed; import conflicts checked again'); }); it('refreshes and closes after an already-submitted job cannot be confirmed', async () => { @@ -521,6 +935,7 @@ describe('PackageBatchImportWorkflow', () => { const choosing = workflow.chooseWorkspace(); picker.finish([source]); await choosing; + await workflow.replan(); await workflow.apply(); expect(refreshSession).toHaveBeenCalledWith({ partitionIndex: 0, volumeName: 'One' }); diff --git a/apps/axkdeck/src/features/import/packageWorkflow.svelte.ts b/apps/axkdeck/src/features/import/packageWorkflow.svelte.ts index cbd72a84..ff9d578c 100644 --- a/apps/axkdeck/src/features/import/packageWorkflow.svelte.ts +++ b/apps/axkdeck/src/features/import/packageWorkflow.svelte.ts @@ -83,7 +83,7 @@ export class PackageImportWorkflow { this.abortController = null; const destination = initialImportDestination(item) ?? { mode: 'existing' as const, - partitionIndex: null, + partitionIndex: this.destinations().partitions[0]?.partitionIndex ?? null, volumeName: '', }; this.request = { @@ -132,19 +132,9 @@ export class PackageImportWorkflow { const request = this.request; if (!request || request.status === 'applying' || request.destinationMode === mode) return; const destinations = this.destinations(); - const currentVolume = destinations.volumes.find( - (option) => - option.partitionIndex === request.destinationPartitionIndex && - option.volumeName === request.destinationVolumeName, - ); - const partitionIndex = - mode === 'existing' - ? (currentVolume?.partitionIndex ?? null) - : (request.destinationPartitionIndex ?? destinations.partitions[0]?.partitionIndex ?? null); + const partitionIndex = request.destinationPartitionIndex ?? destinations.partitions[0]?.partitionIndex ?? null; const volumeName = - mode === 'existing' - ? (currentVolume?.volumeName ?? '') - : suggestedPackageVolumeName(request.sourceName, request.inspection); + mode === 'existing' ? '' : suggestedPackageVolumeName(request.sourceName, request.inspection); this.updateDestination( { destinationMode: mode, @@ -179,7 +169,13 @@ export class PackageImportWorkflow { setDestinationPartition(partitionIndex: number): void { const request = this.request; if (!request || request.status === 'applying' || request.destinationPartitionIndex === partitionIndex) return; - this.updateDestination({ destinationPartitionIndex: partitionIndex }, true); + this.updateDestination( + { + destinationPartitionIndex: partitionIndex, + ...(request.destinationMode === 'existing' ? { destinationVolumeName: '' } : {}), + }, + true, + ); } setDestinationVolumeName(volumeName: string): void { diff --git a/apps/axkdeck/src/features/import/packageWorkflow.test.ts b/apps/axkdeck/src/features/import/packageWorkflow.test.ts index 35fa6c5f..71366255 100644 --- a/apps/axkdeck/src/features/import/packageWorkflow.test.ts +++ b/apps/axkdeck/src/features/import/packageWorkflow.test.ts @@ -824,7 +824,7 @@ describe('PackageImportWorkflow', () => { await workflow.requestDroppedFile(dropped); expect(workflow.request?.destinationMode).toBe('existing'); - expect(workflow.request?.destinationPartitionIndex).toBeNull(); + expect(workflow.request?.destinationPartitionIndex).toBe(0); expect(workflow.request?.destinationVolumeName).toBe(''); expect(workflow.request?.plan).toBeNull(); expect(workflow.request?.hasUnvalidatedChanges).toBe(true); @@ -846,4 +846,42 @@ describe('PackageImportWorkflow', () => { [], ); }); + + it('clears an existing volume atomically when its destination partition changes', () => { + const firstVolume: DiskTreeItem = { + id: 'volume-0', + name: 'First', + kind: 'volume', + childCount: 0, + partitionIndex: 0, + }; + const secondVolume: DiskTreeItem = { + id: 'volume-1', + name: 'Second', + kind: 'volume', + childCount: 0, + partitionIndex: 1, + }; + const workflow = new PackageImportWorkflow({ + transport: {} as ImageTransport, + jobs: {} as JobController, + picker: new PickerController(() => undefined), + isDesktop: false, + sessionId: () => 17, + sourceItems: () => [firstVolume, secondVolume], + invalidateSession: vi.fn(), + refreshSession: vi.fn(), + setStatus: vi.fn(), + }); + + workflow.open(firstVolume); + workflow.setDestinationPartition(1); + + expect(workflow.request).toMatchObject({ + destinationMode: 'existing', + destinationPartitionIndex: 1, + destinationVolumeName: '', + plan: null, + }); + }); }); diff --git a/apps/axkdeck/src/features/mutation/workflow.svelte.ts b/apps/axkdeck/src/features/mutation/workflow.svelte.ts index 5435320c..31fb8428 100644 --- a/apps/axkdeck/src/features/mutation/workflow.svelte.ts +++ b/apps/axkdeck/src/features/mutation/workflow.svelte.ts @@ -56,7 +56,7 @@ export class MutationWorkflow { volumeAvailable = $state(false); partitionAvailable = $state(false); objectRenameAvailable = $state(false); - volumeAction = $state<{ item: DiskTreeItem; action: ImageTreeAction } | null>(null); + volumeAction = $state<{ items: DiskTreeItem[]; action: ImageTreeAction } | null>(null); volumeActionBusy = $state(false); volumeActionPhase = $state<'idle' | 'checking' | 'submitting'>('idle'); volumeActionError = $state(''); @@ -114,13 +114,30 @@ export class MutationWorkflow { if ((action === 'rename-volume' || action === 'delete-volume') && item.kind !== 'volume') return false; this.volumeActionError = ''; this.volumeDeletionInspection = null; - const request = { item, action }; + const request = { items: [item], action }; const generation = ++this.volumeActionGeneration; this.volumeAction = request; if (action === 'delete-volume') void this.inspectVolumeDeletion(request, generation); return true; } + requestVolumeDeletion(items: DiskTreeItem[]): boolean { + if (!this.volumeAvailable || items.length === 0) return false; + const uniqueItems = new Map(); + for (const item of items) { + if (item.kind !== 'volume' || item.partitionIndex === undefined) return false; + uniqueItems.set(`${item.partitionIndex}\0${item.name}`, item); + } + if (uniqueItems.size !== items.length) return false; + this.volumeActionError = ''; + this.volumeDeletionInspection = null; + const request = { items: [...items], action: 'delete-volume' as const }; + const generation = ++this.volumeActionGeneration; + this.volumeAction = request; + void this.inspectVolumeDeletion(request, generation); + return true; + } + requestObjectRename(target: ObjectRenameTarget): void { if (!this.objectRenameAvailable || this.dependencies.sessionId() === null) return; this.objectRenameRequest = { target, busy: false, error: '' }; @@ -328,28 +345,36 @@ export class MutationWorkflow { if (!this.volumeAction || !this.dependencies.imageOpen()) return; const requested = this.volumeAction; if (requested.action === 'delete-volume' && !this.volumeDeletionInspection?.canDelete) return; - const partitionIndex = requested.item.partitionIndex; + const primaryItem = requested.items[0]; + if (!primaryItem) return; + const partitionIndex = primaryItem.partitionIndex; if (partitionIndex === undefined) return; - const previousVolumeName = requested.item.kind === 'volume' ? requested.item.name : undefined; - const volumeMutation: VolumeMutation | null = + const previousVolumeName = primaryItem.kind === 'volume' ? primaryItem.name : undefined; + const volumeMutations: VolumeMutation[] = requested.action === 'add-volume' - ? { kind: 'add', partitionIndex, volumeName: name } + ? [{ kind: 'add', partitionIndex, volumeName: name }] : requested.action === 'rename-volume' - ? { - kind: 'rename', - partitionIndex, - volumeName: requested.item.name, - newVolumeName: name, - } + ? [ + { + kind: 'rename', + partitionIndex, + volumeName: primaryItem.name, + newVolumeName: name, + }, + ] : requested.action === 'delete-volume' - ? { kind: 'delete', partitionIndex, volumeName: requested.item.name } - : null; + ? requested.items.map((item) => ({ + kind: 'delete' as const, + partitionIndex: item.partitionIndex!, + volumeName: item.name, + })) + : []; const partitionMutation: PartitionMutation | null = requested.action === 'rename-partition' ? { kind: 'rename', partitionIndex, - partitionName: requested.item.name, + partitionName: primaryItem.name, newPartitionName: name, } : null; @@ -363,7 +388,7 @@ export class MutationWorkflow { requested.action === 'add-volume' ? 'Adding volume' : requested.action === 'delete-volume' - ? 'Deleting volume' + ? `Deleting ${requested.items.length} ${requested.items.length === 1 ? 'volume' : 'volumes'}` : requested.action === 'rename-partition' ? 'Renaming partition' : 'Renaming volume', @@ -381,7 +406,7 @@ export class MutationWorkflow { () => partitionMutation ? this.dependencies.transport.startPartitionMutation(sessionId, partitionMutation) - : this.dependencies.transport.startVolumeMutation(sessionId, volumeMutation!), + : this.dependencies.transport.startVolumeMutations(sessionId, volumeMutations), (update) => { if (update.progress?.label) this.dependencies.setStatus(update.progress.label); }, @@ -392,7 +417,7 @@ export class MutationWorkflow { ++this.volumeActionGeneration; this.volumeAction = null; await this.dependencies.refreshSession({ partitionIndex, volumeName: preferredVolumeName }); - this.dependencies.reportTiming(requested.action, started, 1); + this.dependencies.reportTiming(requested.action, started, requested.items.length); } catch (error) { this.volumeActionError = userFacingMessage(error); this.dependencies.setStatus(this.volumeActionError); @@ -408,12 +433,11 @@ export class MutationWorkflow { } private async inspectVolumeDeletion( - requested: { item: DiskTreeItem; action: ImageTreeAction }, + requested: { items: DiskTreeItem[]; action: ImageTreeAction }, generation: number, ): Promise { - const partitionIndex = requested.item.partitionIndex; const sessionId = this.dependencies.sessionId(); - if (partitionIndex === undefined || sessionId === null) { + if (sessionId === null) { if (this.isCurrentVolumeAction(generation)) { this.volumeActionError = 'Image session is no longer available'; } @@ -425,8 +449,10 @@ export class MutationWorkflow { try { const inspection = await this.dependencies.transport.inspectVolumeDeletion( sessionId, - partitionIndex, - requested.item.name, + requested.items.map((item) => ({ + partitionIndex: item.partitionIndex!, + volumeName: item.name, + })), ); if (this.isCurrentVolumeAction(generation)) this.volumeDeletionInspection = inspection; } catch (error) { diff --git a/apps/axkdeck/src/features/mutation/workflow.test.ts b/apps/axkdeck/src/features/mutation/workflow.test.ts index 5121a94e..78d21939 100644 --- a/apps/axkdeck/src/features/mutation/workflow.test.ts +++ b/apps/axkdeck/src/features/mutation/workflow.test.ts @@ -89,6 +89,7 @@ describe('MutationWorkflow', () => { categoryName: 'SBNK', sfsId: 1, storedSizeBytes: 512, + sizeWithDependenciesBytes: null, sampleRate: 44_100, rootKey: 60, frameCount: 1, @@ -142,6 +143,7 @@ describe('MutationWorkflow', () => { categoryName: 'SBNK', sfsId: 1, storedSizeBytes: 512, + sizeWithDependenciesBytes: null, sampleRate: 44_100, rootKey: 60, frameCount: 1, @@ -222,6 +224,7 @@ describe('MutationWorkflow', () => { categoryName: 'SBNK', sfsId: 1, storedSizeBytes: 512, + sizeWithDependenciesBytes: null, sampleRate: 44_100, rootKey: 60, frameCount: 1, @@ -281,6 +284,7 @@ describe('MutationWorkflow', () => { categoryName: 'SBNK', sfsId: 1, storedSizeBytes: 512, + sizeWithDependenciesBytes: null, sampleRate: 44_100, rootKey: 60, frameCount: 1, @@ -325,23 +329,59 @@ describe('MutationWorkflow', () => { const inspectVolumeDeletion = vi.fn().mockResolvedValue({ imageId: 'image-1', revision: 1, - partitionIndex: 0, - volumeName: 'Samples', + targets: [{ partitionIndex: 0, volumeName: 'Samples' }], canDelete: false, crossingRelationshipCount: 1, blockers: [{ code: 'KNOWN_RELATIONSHIP_CROSSES_VOLUME', message: 'Crossing link', count: 1 }], }); - const startVolumeMutation = vi.fn(); - const { workflow } = workflowWith({ inspectVolumeDeletion, startVolumeMutation }); + const startVolumeMutations = vi.fn(); + const { workflow } = workflowWith({ inspectVolumeDeletion, startVolumeMutations }); expect(workflow.requestVolumeAction(volume, 'delete-volume')).toBe(true); await vi.waitFor(() => expect(workflow.volumeDeletionInspection?.canDelete).toBe(false)); await workflow.submitVolumeAction('Samples'); - expect(startVolumeMutation).not.toHaveBeenCalled(); + expect(startVolumeMutations).not.toHaveBeenCalled(); expect(workflow.placementRepairRequest).toBeNull(); }); + it('deletes multiple volumes in one alteration job after inspecting their union', async () => { + const secondVolume = { ...volume, id: 'volume-2', name: 'Programs', partitionIndex: 2 }; + const targets = [ + { partitionIndex: 0, volumeName: 'Samples' }, + { partitionIndex: 2, volumeName: 'Programs' }, + ]; + const inspectVolumeDeletion = vi.fn().mockResolvedValue({ + imageId: 'image-1', + revision: 1, + targets, + canDelete: true, + crossingRelationshipCount: 0, + blockers: [], + }); + const startVolumeMutations = vi.fn().mockResolvedValue({ jobId: 1, status: 'queued' }); + const { workflow, refreshSession } = workflowWith({ inspectVolumeDeletion, startVolumeMutations }); + + expect(workflow.requestVolumeDeletion([volume, secondVolume])).toBe(true); + await vi.waitFor(() => expect(workflow.volumeDeletionInspection?.canDelete).toBe(true)); + await workflow.submitVolumeAction(''); + + expect(inspectVolumeDeletion).toHaveBeenCalledWith(7, targets); + expect(startVolumeMutations).toHaveBeenCalledWith(7, [ + { kind: 'delete', partitionIndex: 0, volumeName: 'Samples' }, + { kind: 'delete', partitionIndex: 2, volumeName: 'Programs' }, + ]); + expect(refreshSession).toHaveBeenCalledWith({ partitionIndex: 0, volumeName: undefined }); + }); + + it('rejects duplicate deletion targets even when their tree ids differ', () => { + const duplicateVolume = { ...volume, id: 'duplicate-volume' }; + const { workflow } = workflowWith({}); + + expect(workflow.requestVolumeDeletion([volume, duplicateVolume])).toBe(false); + expect(workflow.volumeAction).toBeNull(); + }); + it('repairs a uniquely attributable volume placement through the explicit action', async () => { const inspectPlacement = vi .fn() diff --git a/apps/axkdeck/src/features/sequence/SequenceWorkspace.test.ts b/apps/axkdeck/src/features/sequence/SequenceWorkspace.test.ts index ff1a31bd..4585e52d 100644 --- a/apps/axkdeck/src/features/sequence/SequenceWorkspace.test.ts +++ b/apps/axkdeck/src/features/sequence/SequenceWorkspace.test.ts @@ -16,6 +16,7 @@ function sequence(name: string, eventCount: number): SequenceItem { categoryName: 'SEQU', sfsId: 1, storedSizeBytes: 512, + sizeWithDependenciesBytes: null, sampleRate: 0, rootKey: 0, frameCount: 0, diff --git a/apps/axkdeck/src/features/workspace/WorkspaceShell.svelte b/apps/axkdeck/src/features/workspace/WorkspaceShell.svelte index 05e0c4fe..e78fa565 100644 --- a/apps/axkdeck/src/features/workspace/WorkspaceShell.svelte +++ b/apps/axkdeck/src/features/workspace/WorkspaceShell.svelte @@ -32,7 +32,7 @@ WaveDataItem, WorkspaceView, } from '../../lib/types'; - import type { PackageExportSelectionState } from '../../lib/objectSelection'; + import type { ObjectSelectionMode, PackageExportSelectionState } from '../../lib/objectSelection'; import { desktopBuildInfo, type DesktopBuildInfo, type DesktopBuildInfoState } from '../../lib/desktopBuildInfo'; interface WorkspaceTab { @@ -51,6 +51,7 @@ imageLocation: ImageLocation | null; sourceItems: DiskTreeItem[]; selectedSource: DiskTreeItem; + selectedVolumeIds: readonly string[]; imageOpening: boolean; sessionId: number | null; catalog: CatalogWorkflow; @@ -84,13 +85,15 @@ sequenceExportAvailable: boolean; mediaConversionAvailable: boolean; allocationInspectionAvailable: boolean; + samplerOrderingEnabled?: boolean; openConnectionSettings: () => void; openImage: () => void; createImage: () => void; closeImage: () => void; showImageIntegrity: () => void; manageLocations: () => void; - selectSource: (item: DiskTreeItem) => void; + selectSource: (item: DiskTreeItem, mode: ObjectSelectionMode, visibleVolumes: readonly DiskTreeItem[]) => void; + selectSourceForContext: (item: DiskTreeItem, visibleVolumes: readonly DiskTreeItem[]) => void; imageAction: (item: DiskTreeItem, action: ImageTreeAction) => void; selectWorkspace: (view: WorkspaceView) => void; exportPackage: (items: PackageExportObject[]) => void; @@ -114,6 +117,7 @@ imageLocation, sourceItems, selectedSource, + selectedVolumeIds, imageOpening, sessionId, catalog, @@ -147,6 +151,7 @@ sequenceExportAvailable, mediaConversionAvailable, allocationInspectionAvailable, + samplerOrderingEnabled = false, openConnectionSettings, openImage, createImage, @@ -154,6 +159,7 @@ showImageIntegrity, manageLocations, selectSource, + selectSourceForContext, imageAction, selectWorkspace, exportPackage, @@ -354,6 +360,7 @@ image={imageLocation} items={sourceItems} selectedId={selectedSource.id} + {selectedVolumeIds} opening={imageOpening} storageLocationsAvailable={transport.storageMode === 'server'} onopen={openImage} @@ -362,6 +369,7 @@ onintegrity={showImageIntegrity} onmanagelocations={manageLocations} onselect={selectSource} + oncontextselect={selectSourceForContext} volumeActionsEnabled={mutation.volumeAvailable} partitionActionsEnabled={mutation.partitionAvailable} packageImportEnabled={packageImportAvailable} @@ -371,6 +379,7 @@ audioExportEnabled={audioExportAvailable} mediaConversionEnabled={mediaConversionAvailable} allocationInspectionEnabled={allocationInspectionAvailable} + {samplerOrderingEnabled} onimageaction={imageAction} onloadchildren={(parentId, offset, limit) => sessionId === null diff --git a/apps/axkdeck/src/lib/allocationInspector.test.ts b/apps/axkdeck/src/lib/allocationInspector.test.ts index d9bddf87..680ed64e 100644 --- a/apps/axkdeck/src/lib/allocationInspector.test.ts +++ b/apps/axkdeck/src/lib/allocationInspector.test.ts @@ -8,11 +8,39 @@ import { allocationExportFilename, allocationSpaceStatistic, formatAllocationBytes, + resolveAllocationServerConnection, saveAllocationMap, } from './allocationInspector'; describe('allocation inspector presentation', () => { - beforeEach(() => mocks.invoke.mockReset()); + beforeEach(() => { + mocks.invoke.mockReset(); + delete window.__AXKLIB_SERVER__; + }); + + it('retrieves the shared desktop connection for a secondary allocation window', async () => { + const connection = { + mode: 'local', + baseUrl: 'http://127.0.0.1:42101/api/v1', + bearerToken: 'test-token', + } as const; + mocks.invoke.mockResolvedValue(connection); + + await expect(resolveAllocationServerConnection()).resolves.toEqual(connection); + expect(mocks.invoke).toHaveBeenCalledWith('server_connection'); + expect(window.__AXKLIB_SERVER__).toEqual(connection); + }); + + it('reuses a connection already available in the allocation window', async () => { + window.__AXKLIB_SERVER__ = { + mode: 'remote', + baseUrl: 'https://example.test/api/v1', + bearerToken: 'test-token', + }; + + await expect(resolveAllocationServerConnection()).resolves.toEqual(window.__AXKLIB_SERVER__); + expect(mocks.invoke).not.toHaveBeenCalled(); + }); it('presents free clusters as allocatable bytes with the cluster count retained', () => { expect(allocationSpaceStatistic(10, 1024)).toEqual({ primary: '10 KiB', secondary: '10 clusters' }); diff --git a/apps/axkdeck/src/lib/allocationInspector.ts b/apps/axkdeck/src/lib/allocationInspector.ts index 12823c25..ce2e64b2 100644 --- a/apps/axkdeck/src/lib/allocationInspector.ts +++ b/apps/axkdeck/src/lib/allocationInspector.ts @@ -12,6 +12,15 @@ export interface AllocationMapSaveRequest { document: unknown; } +type ServerConnection = NonNullable; + +export async function resolveAllocationServerConnection(): Promise { + if (window.__AXKLIB_SERVER__) return window.__AXKLIB_SERVER__; + const connection = await invoke('server_connection'); + window.__AXKLIB_SERVER__ = connection ?? undefined; + return connection; +} + export function formatAllocationBytes(bytes: number): string { if (bytes < 1024) return `${bytes.toLocaleString()} B`; const units = ['KiB', 'MiB', 'GiB']; diff --git a/apps/axkdeck/src/lib/components/AboutDialog.svelte b/apps/axkdeck/src/lib/components/AboutDialog.svelte index 0361d159..6d65b689 100644 --- a/apps/axkdeck/src/lib/components/AboutDialog.svelte +++ b/apps/axkdeck/src/lib/components/AboutDialog.svelte @@ -40,11 +40,11 @@
Version
-
{state.buildInfo.semanticVersion}
-
-
-
Build
-
{state.buildInfo.sourceIdentity}
+
+ {state.buildInfo.isRelease + ? state.buildInfo.semanticVersion + : state.buildInfo.sourceIdentity} +
Web view
diff --git a/apps/axkdeck/src/lib/components/AboutDialog.test.ts b/apps/axkdeck/src/lib/components/AboutDialog.test.ts index d2ce29a0..3fe095f5 100644 --- a/apps/axkdeck/src/lib/components/AboutDialog.test.ts +++ b/apps/axkdeck/src/lib/components/AboutDialog.test.ts @@ -17,7 +17,7 @@ describe('AboutDialog', () => { await waitFor(() => expect(document.activeElement).toBe(within(dialog).getByRole('button', { name: 'Close' }))); }); - it('renders version and source identity and closes with Escape', async () => { + it('renders the source identity as the version for a branch build and closes with Escape', async () => { const onclose = vi.fn(); render(AboutDialog, { props: { @@ -25,9 +25,9 @@ describe('AboutDialog', () => { status: 'ready', buildInfo: { schemaVersion: 1, - semanticVersion: '0.4.0', + semanticVersion: '0.4.0-pre', projectVersion: '0.4.0', - sourceIdentity: 'v0.4.0-1234567-mod', + sourceIdentity: '0.4.0-pre-dirty-1234567', releaseTag: '', isRelease: false, webviewEngine: 'Microsoft Edge WebView2', @@ -39,14 +39,40 @@ describe('AboutDialog', () => { }); const dialog = screen.getByRole('dialog', { name: 'About axkdeck' }); - expect(within(dialog).getByText('0.4.0')).toBeTruthy(); - expect(within(dialog).getByText('v0.4.0-1234567-mod')).toBeTruthy(); + expect(within(dialog).getByText('0.4.0-pre-dirty-1234567')).toBeTruthy(); + expect(within(dialog).queryByText('0.4.0-pre')).toBeNull(); + expect(within(dialog).queryByText('Build')).toBeNull(); expect(within(dialog).getByText('Microsoft Edge WebView2 120.0.2210.144')).toBeTruthy(); await fireEvent.keyDown(dialog, { key: 'Escape' }); expect(onclose).toHaveBeenCalledOnce(); }); + it('renders the canonical semantic version for a tagged release', () => { + render(AboutDialog, { + props: { + state: { + status: 'ready', + buildInfo: { + schemaVersion: 1, + semanticVersion: '0.4.0', + projectVersion: '0.4.0', + sourceIdentity: 'v0.4.0-1234567', + releaseTag: 'v0.4.0', + isRelease: true, + webviewEngine: 'WebKitGTK', + webviewVersion: '2.50.4', + }, + }, + onclose: vi.fn(), + }, + }); + + const dialog = screen.getByRole('dialog', { name: 'About axkdeck' }); + expect(within(dialog).getByText('0.4.0')).toBeTruthy(); + expect(within(dialog).queryByText('v0.4.0-1234567')).toBeNull(); + }); + it('keeps build information available when webview introspection fails', () => { render(AboutDialog, { props: { @@ -56,7 +82,7 @@ describe('AboutDialog', () => { schemaVersion: 1, semanticVersion: '0.4.0', projectVersion: '0.4.0', - sourceIdentity: 'v0.4.0-1234567-mod', + sourceIdentity: 'main-dirty-1234567', releaseTag: '', isRelease: false, webviewEngine: 'WebKitGTK', @@ -69,7 +95,7 @@ describe('AboutDialog', () => { const dialog = screen.getByRole('dialog', { name: 'About axkdeck' }); expect(within(dialog).getByText('WebKitGTK unavailable')).toBeTruthy(); - expect(within(dialog).getByText('v0.4.0-1234567-mod')).toBeTruthy(); + expect(within(dialog).getByText('main-dirty-1234567')).toBeTruthy(); }); it('shows a stable fallback when build information cannot be loaded', () => { diff --git a/apps/axkdeck/src/lib/components/AudioImportDialog.svelte b/apps/axkdeck/src/lib/components/AudioImportDialog.svelte index a7361759..d102c1c5 100644 --- a/apps/axkdeck/src/lib/components/AudioImportDialog.svelte +++ b/apps/axkdeck/src/lib/components/AudioImportDialog.svelte @@ -558,7 +558,7 @@ display: grid; gap: 5px; color: var(--color-text-muted); - font-size: 11px; + font-size: var(--dialog-body-font-size); } .import-target-settings { display: grid; @@ -569,7 +569,7 @@ display: grid; gap: 5px; color: var(--color-text-muted); - font-size: 11px; + font-size: var(--dialog-label-font-size); } .import-target-settings select, .import-target-settings input { @@ -578,7 +578,7 @@ .field-error { margin: 0; color: var(--color-danger); - font-size: 11px; + font-size: var(--dialog-body-font-size); } .inspection-progress progress { width: 100%; diff --git a/apps/axkdeck/src/lib/components/AudioImportDialog.test.ts b/apps/axkdeck/src/lib/components/AudioImportDialog.test.ts index e3b8be12..5d8c438f 100644 --- a/apps/axkdeck/src/lib/components/AudioImportDialog.test.ts +++ b/apps/axkdeck/src/lib/components/AudioImportDialog.test.ts @@ -9,6 +9,10 @@ import type { ClientUploadSource } from '../clientUploadSource'; import type { AudioImportCapabilities, AudioSourceInfo, ImageTransport } from '../transport'; import AudioImportDialog from './AudioImportDialog.svelte'; +const audioImportDialogSource = readFileSync( + resolve(process.cwd(), 'src/lib/components/AudioImportDialog.svelte'), + 'utf8', +); const audioImportRowsSource = readFileSync(resolve(process.cwd(), 'src/lib/components/AudioImportRows.svelte'), 'utf8'); const audioSamplerSettingsSource = readFileSync( resolve(process.cwd(), 'src/lib/components/AudioSamplerSettings.svelte'), @@ -99,13 +103,31 @@ function destinationProps(volumeName: string, partitionIndex = 0) { } describe('AudioImportDialog', () => { - it('keeps sampler fields aligned while reserving clearance for the scrollbar', () => { - expect(audioSamplerSettingsSource).toContain('grid-template-columns: repeat(7, minmax(0, 1fr));'); - expect(audioSamplerSettingsSource).not.toContain('repeat(auto-fit'); + it('owns the compact spacing around the reusable destination chooser', () => { + expect(audioImportDialogSource).toMatch(/\.audio-import-body\s*\{[^}]*gap:\s*10px;/s); + }); + + it('left-packs sampler fields independently from the identity columns', () => { + expect(audioSamplerSettingsSource).toMatch( + /\.settings-fields\s*\{[^}]*display:\s*flex;[^}]*flex-wrap:\s*wrap;[^}]*justify-content:\s*flex-start;/s, + ); + expect(audioSamplerSettingsSource).toMatch(/\.settings-fields\s*>\s*label\s*\{[^}]*flex:\s*0 0 auto;/s); + expect(audioSamplerSettingsSource).not.toContain('grid-template-columns: repeat(7'); expect(audioImportRowsSource).toContain('padding-right: 12px;'); expect(audioImportRowsSource).toContain('scrollbar-gutter: stable;'); }); + it('keeps each audio file compact while allowing its source details to wrap', () => { + expect(audioImportRowsSource).toContain('